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
168 lines
7.9 KiB
Swift
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)
|
|
}
|
|
}
|