//! 跨 official release 的 Translation Memory SQLite 仓储。 use crate::path_security::{set_file_mode, STATE_FILE_MODE}; use async_trait::async_trait; use bat_core::domain::{ TranslationMemoryContext, TranslationMemoryDraft, TranslationMemoryEntry, TranslationMemoryMatch, TranslationMemoryMatchKind, TranslationMemorySourceKind, TranslationMemorySummary, TranslationMemoryTrustStatus, }; use bat_core::repositories::TranslationMemoryRepository; use bat_core::{Error, Result}; use serde::de::DeserializeOwned; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; use sqlx::{Row, SqlitePool}; use std::fs; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// TM SQLite schema 版本。 pub const TRANSLATION_MEMORY_SCHEMA_VERSION: u32 = 1; /// TM schema migration component。 pub const TRANSLATION_MEMORY_SCHEMA_COMPONENT: &str = "translation_memory"; /// 默认 TM 数据库文件名。 pub const TRANSLATION_MEMORY_REPOSITORY_FILE: &str = "translation-memory.sqlite"; /// SQLite-backed Translation Memory 仓储。 #[derive(Debug, Clone)] pub struct SqliteTranslationMemoryRepository { pub(crate) pool: SqlitePool, } impl SqliteTranslationMemoryRepository { /// 创建或打开 TM 数据库并执行迁移。 pub async fn new(path: impl AsRef) -> Result { Self::open_with(path.as_ref(), true).await } /// 只打开已有 TM 数据库,不创建新文件。 pub async fn open(path: impl AsRef) -> Result { Self::open_with(path.as_ref(), false).await } /// 根据 active release 根目录计算默认的跨 release TM 路径。 /// /// 正式 release 根目录形如 `/versions/`,因此默认结果为 /// `/translation-memory.sqlite`,不会写入已发布版本目录。 pub fn repository_path(resource_root: &Path) -> PathBuf { if resource_root .parent() .and_then(Path::file_name) .is_some_and(|name| name == "versions") { if let Some(output_root) = resource_root.parent().and_then(Path::parent) { return output_root.join(TRANSLATION_MEMORY_REPOSITORY_FILE); } } resource_root.join(TRANSLATION_MEMORY_REPOSITORY_FILE) } async fn open_with(path: &Path, create_if_missing: bool) -> Result { let absolute = bat_infrastructure_absolute(path)?; let parent = absolute.parent().ok_or_else(|| { Error::InvalidArgument(format!("TM 数据库缺少父目录:{}", absolute.display())) })?; ensure_safe_tm_parent(parent)?; if create_if_missing { tokio::fs::create_dir_all(parent).await?; ensure_safe_tm_parent(parent)?; } if let Ok(metadata) = fs::symlink_metadata(&absolute) { if metadata.file_type().is_symlink() { return Err(Error::InvalidArgument(format!( "TM 数据库不能是 symlink:{}", absolute.display() ))); } if !metadata.is_file() { return Err(Error::InvalidArgument(format!( "TM 数据库不是普通文件:{}", absolute.display() ))); } } else if !create_if_missing { return Err(Error::NotFound(absolute.display().to_string())); } let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", absolute.display())) .map_err(|error| Error::Other(error.into()))? .create_if_missing(create_if_missing) .journal_mode(SqliteJournalMode::Wal) .busy_timeout(Duration::from_secs(30)); let pool = SqlitePoolOptions::new() .max_connections(1) .connect_with(options) .await .map_err(db_error)?; set_file_mode(&absolute, STATE_FILE_MODE, "Translation Memory 数据库") .map_err(Error::InvalidArgument)?; let repository = Self { pool }; repository.init_schema().await?; Ok(repository) } async fn init_schema(&self) -> Result<()> { sqlx::query( r#" CREATE TABLE IF NOT EXISTS schema_migrations ( component TEXT PRIMARY KEY NOT NULL, version INTEGER NOT NULL CHECK(version >= 1) ) "#, ) .execute(&self.pool) .await .map_err(db_error)?; sqlx::query( r#" CREATE TABLE IF NOT EXISTS translation_memory ( record_id TEXT PRIMARY KEY NOT NULL, source_text TEXT NOT NULL, source_hash TEXT NOT NULL, normalized_source_text TEXT NOT NULL, source_context_json TEXT NOT NULL, source_context_hash TEXT NOT NULL, translated_text TEXT NOT NULL, translation_source_kind TEXT NOT NULL, trust_status TEXT NOT NULL, official_release_id TEXT NOT NULL, source_trace_json TEXT NOT NULL, provider TEXT, provider_run_id TEXT, created_unix_seconds INTEGER NOT NULL, updated_unix_seconds INTEGER NOT NULL, trusted_unix_seconds INTEGER, trusted_by TEXT, trusted_reason TEXT, supersedes_record_id TEXT, superseded_by_record_id TEXT, CHECK (length(source_text) > 0), CHECK (length(source_hash) > 0), CHECK (length(source_context_hash) > 0), CHECK (length(official_release_id) > 0), CHECK (translation_source_kind IN ('provider', 'manual', 'imported')), CHECK (trust_status IN ('candidate', 'trusted', 'superseded', 'rejected')) ) "#, ) .execute(&self.pool) .await .map_err(db_error)?; sqlx::query( "CREATE INDEX IF NOT EXISTS idx_translation_memory_source_hash \ ON translation_memory(source_hash)", ) .execute(&self.pool) .await .map_err(db_error)?; sqlx::query( "CREATE INDEX IF NOT EXISTS idx_translation_memory_normalized_source \ ON translation_memory(normalized_source_text)", ) .execute(&self.pool) .await .map_err(db_error)?; sqlx::query( "CREATE INDEX IF NOT EXISTS idx_translation_memory_context \ ON translation_memory(source_hash, source_context_hash)", ) .execute(&self.pool) .await .map_err(db_error)?; let current: Option = sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1") .bind(TRANSLATION_MEMORY_SCHEMA_COMPONENT) .fetch_optional(&self.pool) .await .map_err(db_error)?; if current.is_some_and(|version| version > i64::from(TRANSLATION_MEMORY_SCHEMA_VERSION)) { return Err(Error::InvalidArgument(format!( "不支持的 Translation Memory schema 版本:{}", current.unwrap_or_default() ))); } sqlx::query( r#" INSERT INTO schema_migrations(component, version) VALUES (?1, ?2) ON CONFLICT(component) DO UPDATE SET version = excluded.version "#, ) .bind(TRANSLATION_MEMORY_SCHEMA_COMPONENT) .bind(i64::from(TRANSLATION_MEMORY_SCHEMA_VERSION)) .execute(&self.pool) .await .map_err(db_error)?; Ok(()) } async fn find_optional(&self, record_id: &str) -> Result> { let row = sqlx::query( r#" SELECT record_id, source_text, source_hash, normalized_source_text, source_context_json, source_context_hash, translated_text, translation_source_kind, trust_status, official_release_id, source_trace_json, provider, provider_run_id, created_unix_seconds, updated_unix_seconds, trusted_unix_seconds, trusted_by, trusted_reason, supersedes_record_id, superseded_by_record_id FROM translation_memory WHERE record_id = ?1 "#, ) .bind(record_id) .fetch_optional(&self.pool) .await .map_err(db_error)?; row.map(row_to_entry).transpose() } } #[async_trait] impl TranslationMemoryRepository for SqliteTranslationMemoryRepository { async fn upsert_candidate( &self, draft: TranslationMemoryDraft, ) -> Result { validate_draft(&draft)?; let entry = entry_from_draft(draft)?; if let Some(existing) = self.find_optional(&entry.record_id).await? { if existing.trust_status != TranslationMemoryTrustStatus::Candidate { return Ok(existing); } let source_trace_json = serde_json::to_string(&entry.source_trace) .map_err(|error| Error::Serialization(error.to_string()))?; sqlx::query( r#" UPDATE translation_memory SET source_trace_json = ?2, provider = ?3, provider_run_id = ?4, updated_unix_seconds = ?5 WHERE record_id = ?1 AND trust_status = 'candidate' "#, ) .bind(&entry.record_id) .bind(source_trace_json) .bind(&entry.provider) .bind(&entry.provider_run_id) .bind(i64::try_from(entry.updated_unix_seconds).unwrap_or(i64::MAX)) .execute(&self.pool) .await .map_err(db_error)?; return self.find(&entry.record_id).await; } let source_context_json = serde_json::to_string(&entry.source_context) .map_err(|error| Error::Serialization(error.to_string()))?; let source_trace_json = serde_json::to_string(&entry.source_trace) .map_err(|error| Error::Serialization(error.to_string()))?; let result = sqlx::query( r#" INSERT INTO translation_memory ( record_id, source_text, source_hash, normalized_source_text, source_context_json, source_context_hash, translated_text, translation_source_kind, trust_status, official_release_id, source_trace_json, provider, provider_run_id, created_unix_seconds, updated_unix_seconds, trusted_unix_seconds, trusted_by, trusted_reason, supersedes_record_id, superseded_by_record_id ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?14, ?15, ?16, ?17, ?18, ?19) ON CONFLICT(record_id) DO UPDATE SET source_trace_json = excluded.source_trace_json, provider = excluded.provider, provider_run_id = excluded.provider_run_id, updated_unix_seconds = excluded.updated_unix_seconds WHERE translation_memory.trust_status = 'candidate' "#, ) .bind(&entry.record_id) .bind(&entry.source_text) .bind(&entry.source_hash) .bind(&entry.normalized_source_text) .bind(source_context_json) .bind(&entry.source_context_hash) .bind(&entry.translated_text) .bind(entry.translation_source_kind.as_str()) .bind(entry.trust_status.as_str()) .bind(&entry.official_release_id) .bind(source_trace_json) .bind(&entry.provider) .bind(&entry.provider_run_id) .bind(i64::try_from(entry.created_unix_seconds).unwrap_or(i64::MAX)) .bind( entry .trusted_unix_seconds .map(|value| i64::try_from(value).unwrap_or(i64::MAX)), ) .bind(&entry.trusted_by) .bind(&entry.trusted_reason) .bind(&entry.supersedes_record_id) .bind(&entry.superseded_by_record_id) .execute(&self.pool) .await .map_err(db_error)?; if result.rows_affected() == 0 { return self.find(&entry.record_id).await; } Ok(entry) } async fn find_matches( &self, source_text: &str, source_context: &TranslationMemoryContext, limit: usize, ) -> Result> { if source_text.is_empty() { return Err(Error::InvalidArgument( "Translation Memory 查询 source_text 不能为空".to_string(), )); } if limit == 0 { return Err(Error::InvalidArgument( "Translation Memory 查询 limit 必须大于 0".to_string(), )); } let source_hash = hash_text(source_text); let normalized_source_text = normalize_source_text(source_text); let rows = sqlx::query( r#" SELECT record_id, source_text, source_hash, normalized_source_text, source_context_json, source_context_hash, translated_text, translation_source_kind, trust_status, official_release_id, source_trace_json, provider, provider_run_id, created_unix_seconds, updated_unix_seconds, trusted_unix_seconds, trusted_by, trusted_reason, supersedes_record_id, superseded_by_record_id FROM translation_memory WHERE source_hash = ?1 OR normalized_source_text = ?2 ORDER BY updated_unix_seconds DESC, record_id ASC "#, ) .bind(source_hash) .bind(&normalized_source_text) .fetch_all(&self.pool) .await .map_err(db_error)?; let mut matches = rows .into_iter() .map(row_to_entry) .collect::>>()? .into_iter() .filter_map(|entry| { let raw_exact = entry.source_text == source_text; let normalized_exact = entry.normalized_source_text == normalized_source_text; if !raw_exact && !normalized_exact { return None; } let same_context = !source_context.is_empty() && !entry.source_context.is_empty() && entry.source_context == *source_context; let strong = raw_exact && same_context && entry.trust_status == TranslationMemoryTrustStatus::Trusted; let match_kind = if strong { TranslationMemoryMatchKind::StrongExact } else if raw_exact { TranslationMemoryMatchKind::CandidateExact } else { TranslationMemoryMatchKind::SourceOnly }; Some(TranslationMemoryMatch { can_auto_reuse: strong, entry, match_kind, }) }) .collect::>(); matches.sort_by(|left, right| { match_rank(left) .cmp(&match_rank(right)) .then_with(|| { right .entry .updated_unix_seconds .cmp(&left.entry.updated_unix_seconds) }) .then_with(|| left.entry.record_id.cmp(&right.entry.record_id)) }); matches.truncate(limit); Ok(matches) } async fn confirm( &self, record_id: &str, reviewer: &str, reason: Option, ) -> Result { if reviewer.trim().is_empty() { return Err(Error::InvalidArgument( "Translation Memory reviewer 不能为空".to_string(), )); } let current = self.find(record_id).await?; if current.trust_status == TranslationMemoryTrustStatus::Trusted { return Ok(current); } if current.trust_status != TranslationMemoryTrustStatus::Candidate { return Err(Error::InvalidArgument(format!( "Translation Memory 记录 {} 当前状态为 {},不能确认", record_id, current.trust_status.as_str() ))); } let now = unix_seconds_now(); sqlx::query( r#" UPDATE translation_memory SET trust_status = 'trusted', trusted_unix_seconds = ?2, trusted_by = ?3, trusted_reason = ?4, updated_unix_seconds = ?2 WHERE record_id = ?1 AND trust_status = 'candidate' "#, ) .bind(record_id) .bind(i64::try_from(now).unwrap_or(i64::MAX)) .bind(reviewer.trim()) .bind(reason.filter(|value| !value.trim().is_empty())) .execute(&self.pool) .await .map_err(db_error)?; self.find(record_id).await } async fn find(&self, record_id: &str) -> Result { self.find_optional(record_id) .await? .ok_or_else(|| Error::NotFound(record_id.to_string())) } async fn summary(&self) -> Result { let row = sqlx::query( r#" SELECT COUNT(*) AS record_count, SUM(CASE WHEN trust_status = 'trusted' THEN 1 ELSE 0 END) AS trusted_count, SUM(CASE WHEN trust_status = 'candidate' THEN 1 ELSE 0 END) AS candidate_count, SUM(CASE WHEN trust_status = 'superseded' THEN 1 ELSE 0 END) AS superseded_count, SUM(CASE WHEN trust_status = 'rejected' THEN 1 ELSE 0 END) AS rejected_count FROM translation_memory "#, ) .fetch_one(&self.pool) .await .map_err(db_error)?; Ok(TranslationMemorySummary { schema_version: TRANSLATION_MEMORY_SCHEMA_VERSION, record_count: row.try_get::("record_count").map_err(db_error)? as u64, trusted_count: row.try_get::("trusted_count").map_err(db_error)? as u64, candidate_count: row.try_get::("candidate_count").map_err(db_error)? as u64, superseded_count: row .try_get::("superseded_count") .map_err(db_error)? as u64, rejected_count: row.try_get::("rejected_count").map_err(db_error)? as u64, }) } } /// 由 active official release 根目录计算默认 TM 数据库路径。 pub fn translation_memory_repository_path(resource_root: &Path) -> PathBuf { SqliteTranslationMemoryRepository::repository_path(resource_root) } /// 从 TextUnit 定位字段构建 TM 上下文。 #[allow(clippy::too_many_arguments)] pub fn translation_memory_context( destination: &str, archive_entry: Option<&str>, serialized_file: Option<&str>, path_id: Option, class_id: Option, field_path: Option<&str>, format: Option<&str>, asset_name: Option<&str>, text_source_kind: Option<&str>, parser_context: &TranslationMemoryContext, ) -> TranslationMemoryContext { let mut context = TranslationMemoryContext::new(); context.insert("destination".to_string(), destination.to_string()); insert_optional(&mut context, "archive_entry", archive_entry); insert_optional(&mut context, "serialized_file", serialized_file); if let Some(value) = path_id { context.insert("path_id".to_string(), value.to_string()); } if let Some(value) = class_id { context.insert("class_id".to_string(), value.to_string()); } insert_optional(&mut context, "field_path", field_path); insert_optional(&mut context, "format", format); insert_optional(&mut context, "asset_name", asset_name); insert_optional(&mut context, "text_source_kind", text_source_kind); for (key, value) in parser_context { context.insert(format!("context.{key}"), value.clone()); } context } fn insert_optional(context: &mut TranslationMemoryContext, key: &str, value: Option<&str>) { if let Some(value) = value.filter(|value| !value.is_empty()) { context.insert(key.to_string(), value.to_string()); } } fn entry_from_draft(draft: TranslationMemoryDraft) -> Result { let source_hash = hash_text(&draft.source_text); let normalized_source_text = normalize_source_text(&draft.source_text); let source_context_hash = hash_context(&draft.source_context)?; let source_kind = draft.translation_source_kind; let record_id = record_id( &source_hash, &source_context_hash, &draft.translated_text, &draft.official_release_id, &source_kind, ); let observed = draft.observed_unix_seconds; Ok(TranslationMemoryEntry { record_id, source_text: draft.source_text, source_hash, normalized_source_text, source_context: draft.source_context, source_context_hash, translated_text: draft.translated_text, translation_source_kind: source_kind, trust_status: TranslationMemoryTrustStatus::Candidate, official_release_id: draft.official_release_id, source_trace: draft.source_trace, provider: draft.provider, provider_run_id: draft.provider_run_id, created_unix_seconds: observed, updated_unix_seconds: observed, trusted_unix_seconds: None, trusted_by: None, trusted_reason: None, supersedes_record_id: None, superseded_by_record_id: None, }) } fn validate_draft(draft: &TranslationMemoryDraft) -> Result<()> { if draft.source_text.is_empty() { return Err(Error::InvalidArgument( "Translation Memory source_text 不能为空".to_string(), )); } if draft.translated_text.trim().is_empty() { return Err(Error::InvalidArgument( "Translation Memory translated_text 不能为空".to_string(), )); } if draft.official_release_id.trim().is_empty() || draft.source_trace.official_release_id.trim().is_empty() { return Err(Error::InvalidArgument( "Translation Memory official_release_id 不能为空".to_string(), )); } if draft.official_release_id != draft.source_trace.official_release_id { return Err(Error::InvalidArgument( "Translation Memory draft 的 release provenance 不一致".to_string(), )); } Ok(()) } fn row_to_entry(row: sqlx::sqlite::SqliteRow) -> Result { let source_context = parse_json(row.try_get("source_context_json").map_err(db_error)?)?; let source_trace = parse_json(row.try_get("source_trace_json").map_err(db_error)?)?; Ok(TranslationMemoryEntry { record_id: row.try_get("record_id").map_err(db_error)?, source_text: row.try_get("source_text").map_err(db_error)?, source_hash: row.try_get("source_hash").map_err(db_error)?, normalized_source_text: row.try_get("normalized_source_text").map_err(db_error)?, source_context, source_context_hash: row.try_get("source_context_hash").map_err(db_error)?, translated_text: row.try_get("translated_text").map_err(db_error)?, translation_source_kind: parse_source_kind( row.try_get::("translation_source_kind") .map_err(db_error)? .as_str(), )?, trust_status: parse_trust_status( row.try_get::("trust_status") .map_err(db_error)? .as_str(), )?, official_release_id: row.try_get("official_release_id").map_err(db_error)?, source_trace, provider: row.try_get("provider").map_err(db_error)?, provider_run_id: row.try_get("provider_run_id").map_err(db_error)?, created_unix_seconds: i64_to_u64( row.try_get("created_unix_seconds").map_err(db_error)?, "created", )?, updated_unix_seconds: i64_to_u64( row.try_get("updated_unix_seconds").map_err(db_error)?, "updated", )?, trusted_unix_seconds: optional_i64_to_u64( row.try_get("trusted_unix_seconds").map_err(db_error)?, "trusted", )?, trusted_by: row.try_get("trusted_by").map_err(db_error)?, trusted_reason: row.try_get("trusted_reason").map_err(db_error)?, supersedes_record_id: row.try_get("supersedes_record_id").map_err(db_error)?, superseded_by_record_id: row.try_get("superseded_by_record_id").map_err(db_error)?, }) } fn parse_json(value: String) -> Result { serde_json::from_str(&value).map_err(|error| Error::Serialization(error.to_string())) } fn parse_source_kind(value: &str) -> Result { match value { "provider" => Ok(TranslationMemorySourceKind::Provider), "manual" => Ok(TranslationMemorySourceKind::Manual), "imported" => Ok(TranslationMemorySourceKind::Imported), _ => Err(Error::Serialization(format!( "未知 Translation Memory source kind:{value}" ))), } } fn parse_trust_status(value: &str) -> Result { match value { "candidate" => Ok(TranslationMemoryTrustStatus::Candidate), "trusted" => Ok(TranslationMemoryTrustStatus::Trusted), "superseded" => Ok(TranslationMemoryTrustStatus::Superseded), "rejected" => Ok(TranslationMemoryTrustStatus::Rejected), _ => Err(Error::Serialization(format!( "未知 Translation Memory trust status:{value}" ))), } } fn match_rank(value: &TranslationMemoryMatch) -> u8 { match value.match_kind { TranslationMemoryMatchKind::StrongExact if value.can_auto_reuse => 0, TranslationMemoryMatchKind::CandidateExact => 1, TranslationMemoryMatchKind::SourceOnly => 2, TranslationMemoryMatchKind::StrongExact => 1, } } fn hash_text(value: &str) -> String { blake3::hash(value.as_bytes()).to_hex().to_string() } fn normalize_source_text(value: &str) -> String { value.replace("\r\n", "\n").replace('\r', "\n") } fn hash_context(context: &TranslationMemoryContext) -> Result { let bytes = serde_json::to_vec(context).map_err(|error| Error::Serialization(error.to_string()))?; Ok(blake3::hash(&bytes).to_hex().to_string()) } fn record_id( source_hash: &str, context_hash: &str, translated_text: &str, official_release_id: &str, source_kind: &TranslationMemorySourceKind, ) -> String { let mut key = Vec::new(); for value in [ source_hash, context_hash, translated_text, official_release_id, source_kind.as_str(), ] { key.extend_from_slice(value.as_bytes()); key.push(0); } format!("tm-{}", blake3::hash(&key).to_hex()) } fn unix_seconds_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } fn i64_to_u64(value: i64, label: &str) -> Result { u64::try_from(value) .map_err(|_| Error::Serialization(format!("Translation Memory {label} 时间无效"))) } fn optional_i64_to_u64(value: Option, label: &str) -> Result> { value.map(|value| i64_to_u64(value, label)).transpose() } fn db_error(error: sqlx::Error) -> Error { Error::Other(error.into()) } fn bat_infrastructure_absolute(path: &Path) -> Result { crate::path_security::lexical_absolute(path).map_err(Error::InvalidArgument) } fn ensure_safe_tm_parent(parent: &Path) -> Result<()> { crate::path_security::ensure_safe_directory_path(parent, "Translation Memory 数据库") .map_err(Error::InvalidArgument) } #[cfg(test)] mod tests { use super::*; use bat_core::domain::TranslationMemorySourceTrace; fn draft(release: &str, source: &str, translated: &str) -> TranslationMemoryDraft { let source_trace = TranslationMemorySourceTrace { official_release_id: release.to_string(), unit_id: Some(format!("{release}-unit")), task_id: Some(format!("{release}-task")), destination: Some("Bundles/story.bundle".to_string()), archive_entry: None, serialized_file: Some("CAB-story".to_string()), path_id: Some(1), class_id: Some(49), field_path: Some("m_Text".to_string()), format: Some("plain".to_string()), asset_name: Some("Story".to_string()), text_source_kind: Some("text_asset".to_string()), source_url: Some("https://example.invalid/story".to_string()), }; TranslationMemoryDraft { source_text: source.to_string(), source_context: translation_memory_context( "Bundles/story.bundle", None, Some("CAB-story"), Some(1), Some(49), Some("m_Text"), Some("plain"), Some("Story"), Some("text_asset"), &TranslationMemoryContext::new(), ), translated_text: translated.to_string(), translation_source_kind: TranslationMemorySourceKind::Provider, official_release_id: release.to_string(), source_trace, provider: Some("mock".to_string()), provider_run_id: Some(format!("mock:{release}")), observed_unix_seconds: 1, } } #[tokio::test] async fn initializes_schema_and_reuses_trusted_entry_across_releases() { let temp = tempfile::TempDir::new().unwrap(); let repository = SqliteTranslationMemoryRepository::new( temp.path().join(TRANSLATION_MEMORY_REPOSITORY_FILE), ) .await .unwrap(); let entry = repository .upsert_candidate(draft("release-1", "Hello", "你好")) .await .unwrap(); assert_eq!(repository.summary().await.unwrap().candidate_count, 1); let trusted = repository .confirm(&entry.record_id, "reviewer", Some("accepted".to_string())) .await .unwrap(); assert_eq!(trusted.trust_status, TranslationMemoryTrustStatus::Trusted); let query = draft("release-2", "Hello", "ignored"); let matches = repository .find_matches("Hello", &query.source_context, 10) .await .unwrap(); assert_eq!(matches.len(), 1); assert_eq!( matches[0].match_kind, TranslationMemoryMatchKind::StrongExact ); assert!(matches[0].can_auto_reuse); assert_eq!(matches[0].entry.translated_text, "你好"); assert_eq!(matches[0].entry.official_release_id, "release-1"); } #[tokio::test] async fn rejects_future_schema_version() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("tm.sqlite"); let repository = SqliteTranslationMemoryRepository::new(&path).await.unwrap(); sqlx::query("UPDATE schema_migrations SET version = ?2 WHERE component = ?1") .bind(TRANSLATION_MEMORY_SCHEMA_COMPONENT) .bind(i64::from(TRANSLATION_MEMORY_SCHEMA_VERSION) + 1) .execute(&repository.pool) .await .unwrap(); repository.pool.close().await; let error = SqliteTranslationMemoryRepository::new(&path) .await .unwrap_err(); assert!(error .to_string() .contains("不支持的 Translation Memory schema")); } #[tokio::test] async fn different_context_is_candidate_only_and_provider_repeat_is_idempotent() { let temp = tempfile::TempDir::new().unwrap(); let repository = SqliteTranslationMemoryRepository::new(temp.path().join("tm.sqlite")) .await .unwrap(); let first = draft("release-1", "Hello", "你好"); repository.upsert_candidate(first.clone()).await.unwrap(); repository.upsert_candidate(first).await.unwrap(); assert_eq!(repository.summary().await.unwrap().record_count, 1); let mut different = draft("release-2", "Hello", "你好"); different .source_context .insert("field_path".to_string(), "m_Other".to_string()); let matches = repository .find_matches("Hello", &different.source_context, 10) .await .unwrap(); assert_eq!( matches[0].match_kind, TranslationMemoryMatchKind::CandidateExact ); assert!(!matches[0].can_auto_reuse); } #[tokio::test] async fn rejects_empty_translation_candidates() { let temp = tempfile::TempDir::new().unwrap(); let repository = SqliteTranslationMemoryRepository::new(temp.path().join("tm.sqlite")) .await .unwrap(); let error = repository .upsert_candidate(draft("release-1", "Hello", " \n")) .await .unwrap_err(); assert!(error.to_string().contains("translated_text")); assert_eq!(repository.summary().await.unwrap().record_count, 0); } }