import Foundation import IndieSync import SwiftData import Testing @testable import Notes /// The v3 transcript model: attempts accumulate on `AudioInfo`, the latest one /// is displayed, `payload.text` stays purely user writing, and v2 files whose /// transcript was baked into `text` migrate at read time. @MainActor struct TranscriptionAttemptTests { private func makeEngine() throws -> (engine: SyncEngine, container: ModelContainer) { let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none) let container = try ModelContainer(for: NoteEntity.self, configurations: config) return (SyncEngine(modelContainer: container), container) } private func attempt(_ text: String, locale: String = "en-US", confidence: Double? = 0.9) -> Note.TranscriptionAttempt { Note.TranscriptionAttempt( text: text, localeID: locale, confidence: confidence, transcribedAt: Date(timeIntervalSince1970: 1_700_000_100), languageWasForced: false ) } // MARK: - Model @Test func latestAttemptWins() { var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done) audio.attempts = [attempt("first"), attempt("второй", locale: "ru-RU")] #expect(audio.latestAttempt?.text == "второй") } @Test func displayTextPrefersUserTextThenTranscript() { var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done) audio.attempts = [attempt("spoken words")] var note = Note.create(text: "", source: .transcribed, context: .empty, audio: audio) #expect(note.displayText == "spoken words") #expect(note.title == "spoken words") note.payload.text = "my own words" #expect(note.displayText == "my own words") } /// A v2 file (no `attempts` key) must decode with empty attempts, not fail. @Test func v2AudioInfoDecodesWithoutAttemptsKey() throws { let json = """ {"duration": 5, "transcriberDeviceID": "d", "transcriptionStatus": "done", "transcriptionLocaleID": "en-US"} """ let audio = try JSONDecoder().decode(Note.AudioInfo.self, from: Data(json.utf8)) #expect(audio.attempts.isEmpty) #expect(audio.transcriptionStatus == .done) } @Test func attemptsSurviveCodableRoundTrip() throws { var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done) audio.attempts = [attempt("hello", confidence: nil), attempt("привет", locale: "ru-RU")] let note = Note.create(text: "", source: .transcribed, context: .empty, audio: audio) let decoded = try DocumentCoder.decode(Note.self, from: DocumentCoder.encode(note)) #expect(decoded.payload.audio?.attempts.count == 2) #expect(decoded.payload.audio?.latestAttempt?.localeID == "ru-RU") #expect(decoded.payload.audio?.attempts.first?.confidence == nil) } // MARK: - v2 → v3 migration @Test func migrationMovesCompletedTranscriptIntoAttempts() throws { let (engine, container) = try makeEngine() defer { withExtendedLifetime(container) {} } var note = Note.create( text: "the old transcript", source: .transcribed, context: .empty, audio: Note.AudioInfo( duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done, transcriptionLocaleID: "en-US", transcribedAt: Date(timeIntervalSince1970: 1_700_000_100) ) ) note.meta.schemaVersion = 2 let migrated = engine.migrateIfNeeded(note) #expect(migrated.meta.schemaVersion == Note.currentSchemaVersion) #expect(migrated.payload.text.isEmpty) #expect(migrated.payload.audio?.attempts.count == 1) #expect(migrated.payload.audio?.latestAttempt?.text == "the old transcript") #expect(migrated.payload.audio?.latestAttempt?.localeID == "en-US") #expect(migrated.payload.audio?.latestAttempt?.confidence == nil) #expect(migrated.displayText == "the old transcript") } @Test func migrationLeavesPendingAndTextNotesAlone() throws { let (engine, container) = try makeEngine() defer { withExtendedLifetime(container) {} } var pending = Note.create( text: "", source: .transcribed, context: .empty, audio: Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .pending) ) pending.meta.schemaVersion = 2 let migratedPending = engine.migrateIfNeeded(pending) #expect(migratedPending.payload.audio?.attempts.isEmpty == true) #expect(migratedPending.meta.schemaVersion == Note.currentSchemaVersion) var textNote = Note.create(text: "just typing", context: .empty) textNote.meta.schemaVersion = 1 let migratedText = engine.migrateIfNeeded(textNote) #expect(migratedText.payload.text == "just typing") #expect(migratedText.meta.schemaVersion == Note.currentSchemaVersion) } // MARK: - Cache columns @Test func mapperCachesLatestAttempt() throws { let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none) let container = try ModelContainer(for: NoteEntity.self, configurations: config) defer { withExtendedLifetime(container) {} } let context = container.mainContext var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done, transcriptionLocaleID: "ru-RU") audio.attempts = [attempt("first draft"), attempt("окончательный", locale: "ru-RU", confidence: 0.8)] let note = Note.create(text: "", source: .transcribed, context: .empty, audio: audio) NoteMapper.upsert(note, relativePath: note.relativePath, into: context) let entity = try #require(NoteMapper.fetch(id: note.meta.id.stringValue, in: context)) #expect(entity.transcriptText == "окончательный") #expect(entity.transcriptConfidence == 0.8) #expect(entity.displayText == "окончательный") #expect(entity.title == "окончательный") } // MARK: - Search scoring @Test func searchRanksUserTextAboveTranscript() throws { let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none) let container = try ModelContainer(for: NoteEntity.self, configurations: config) defer { withExtendedLifetime(container) {} } let context = container.mainContext let typed = Note.create(text: "coffee order", context: .empty) var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done) audio.attempts = [attempt("coffee with sam", confidence: 0.9)] let voice = Note.create(text: "", source: .transcribed, context: .empty, audio: audio) NoteMapper.upsert(typed, relativePath: typed.relativePath, into: context) NoteMapper.upsert(voice, relativePath: voice.relativePath, into: context) let typedEntity = try #require(NoteMapper.fetch(id: typed.meta.id.stringValue, in: context)) let voiceEntity = try #require(NoteMapper.fetch(id: voice.meta.id.stringValue, in: context)) let typedScore = try #require(typedEntity.searchScore(for: "coffee")) let voiceScore = try #require(voiceEntity.searchScore(for: "coffee")) #expect(typedScore > voiceScore) #expect(typedEntity.searchScore(for: "zzz") == nil) // Higher-confidence transcripts outrank shakier ones. var lowAudio = audio lowAudio.attempts = [attempt("coffee maybe", confidence: 0.2)] voiceEntity.audio = lowAudio let lowScore = try #require(voiceEntity.searchScore(for: "coffee")) #expect(voiceScore > lowScore) } }