Files
notes/NotesTests/TranscriptionAttemptTests.swift
rzen c5c90e9441 Decouple transcripts from note text; end-to-end watch transfer acks
Schema v3: transcription attempts accumulate on AudioInfo (text, locale,
confidence, forced flag) — latest attempt is displayed, payload.text is
purely user writing, v2 notes migrate at read time. Editor shows the
transcript in its own live-updating section with insert-into-note; search
matches transcripts weighted by confidence. Watch recordings are now
deleted only on the phone's durable-ingest ack (transferUserInfo), closing
every phone-side loss window; failed sidecar writes unstage instead of
orphaning, and unreadable inbox recordings are surfaced in logs.

Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
2026-07-16 12:29:49 -04:00

168 lines
7.9 KiB
Swift

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)
}
}