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
155 lines
6.2 KiB
Swift
155 lines
6.2 KiB
Swift
import Foundation
|
|
import IndieSync
|
|
import Testing
|
|
@testable import Notes
|
|
|
|
struct NoteDocumentTests {
|
|
private func makeNote() -> Note {
|
|
var context = CaptureContext.empty
|
|
context.latitude = 37.8044
|
|
context.longitude = -122.2712
|
|
context.placeName = "Blue Bottle Coffee"
|
|
context.locality = "Oakland"
|
|
context.timeZoneID = "America/Los_Angeles"
|
|
context.device = "iPhone"
|
|
return Note.create(text: "Barista's name is Sam\nLikes cortados", tags: ["coffee", "people"], context: context)
|
|
}
|
|
|
|
@Test func codableRoundTrip() throws {
|
|
let note = makeNote()
|
|
let data = try DocumentCoder.encode(note)
|
|
let decoded = try DocumentCoder.decode(Note.self, from: data)
|
|
#expect(decoded.payload == note.payload)
|
|
#expect(decoded.meta.id == note.meta.id)
|
|
#expect(decoded.meta.schemaVersion == note.meta.schemaVersion)
|
|
// ISO-8601 encoding truncates sub-second precision.
|
|
#expect(abs(decoded.meta.createdAt.timeIntervalSince(note.meta.createdAt)) < 1)
|
|
#expect(abs(decoded.meta.modifiedAt.timeIntervalSince(note.meta.modifiedAt)) < 1)
|
|
}
|
|
|
|
@Test func forwardGateQuarantinesNewerSchema() {
|
|
var note = makeNote()
|
|
note.meta.schemaVersion = Note.currentSchemaVersion + 1
|
|
#expect(!note.isReadable)
|
|
#expect(makeNote().isReadable)
|
|
}
|
|
|
|
@Test func relativePathIsUTCBucketed() {
|
|
let note = makeNote()
|
|
let path = note.relativePath
|
|
#expect(path.hasSuffix("\(note.meta.id.stringValue).json"))
|
|
#expect(path == TimeBucketedLayout.relativePath(for: note.meta.id))
|
|
}
|
|
|
|
@Test func titleIsFirstLine() {
|
|
#expect(makeNote().title == "Barista's name is Sam")
|
|
}
|
|
|
|
// MARK: - Schema v2 (audio)
|
|
|
|
/// A v1 file predates the `audio` key. It must decode unchanged — as a text
|
|
/// note with `audio == nil` — and pass the forward gate (1 ≤ current).
|
|
@Test func v1DocumentDecodesAsTextNoteWithNoAudio() throws {
|
|
let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 1, randomLo: 2)
|
|
let json = """
|
|
{
|
|
"meta": {
|
|
"id": "\(id.stringValue)",
|
|
"schemaVersion": 1,
|
|
"createdAt": "2023-11-14T22:13:20Z",
|
|
"modifiedAt": "2023-11-14T22:13:20Z"
|
|
},
|
|
"payload": {
|
|
"text": "A v1 note",
|
|
"source": "typed",
|
|
"tags": ["legacy"],
|
|
"context": { "device": "iPhone", "timeZoneID": "UTC" }
|
|
}
|
|
}
|
|
"""
|
|
let note = try DocumentCoder.decode(Note.self, from: Data(json.utf8))
|
|
#expect(note.meta.schemaVersion == 1)
|
|
#expect(note.payload.audio == nil)
|
|
#expect(note.payload.text == "A v1 note")
|
|
#expect(note.isReadable)
|
|
}
|
|
|
|
/// A file stamped with a newer schema (and carrying a field this build
|
|
/// doesn't model) must be quarantined by the gate — Codable silently drops
|
|
/// the unknown key, so partial-decoding then rewriting would downgrade it.
|
|
@Test func forwardGateQuarantinesNewerFixture() throws {
|
|
let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 3, randomLo: 4)
|
|
let json = """
|
|
{
|
|
"meta": {
|
|
"id": "\(id.stringValue)",
|
|
"schemaVersion": \(Note.currentSchemaVersion + 1),
|
|
"createdAt": "2023-11-14T22:13:20Z",
|
|
"modifiedAt": "2023-11-14T22:13:20Z"
|
|
},
|
|
"payload": {
|
|
"text": "From the future",
|
|
"source": "typed",
|
|
"tags": [],
|
|
"context": {},
|
|
"audio": null,
|
|
"unknownFutureField": { "whatever": true }
|
|
}
|
|
}
|
|
"""
|
|
let note = try DocumentCoder.decode(Note.self, from: Data(json.utf8))
|
|
#expect(note.meta.schemaVersion == Note.currentSchemaVersion + 1)
|
|
#expect(!note.isReadable)
|
|
}
|
|
|
|
@Test func audioNoteCodableRoundTrip() throws {
|
|
var context = CaptureContext.empty
|
|
context.device = "Watch"
|
|
context.timeZoneID = "Europe/Berlin"
|
|
let audio = Note.AudioInfo(
|
|
duration: 12.5,
|
|
transcriberDeviceID: "install-abc",
|
|
transcriptionStatus: .done,
|
|
transcriptionLocaleID: "ru_RU",
|
|
transcribedAt: Date(timeIntervalSince1970: 1_700_000_100)
|
|
)
|
|
let note = Note.create(text: "привет", source: .transcribed, tags: ["voice"], context: context, audio: audio)
|
|
let data = try DocumentCoder.encode(note)
|
|
let decoded = try DocumentCoder.decode(Note.self, from: data)
|
|
#expect(decoded.payload == note.payload)
|
|
#expect(decoded.payload.audio?.transcriptionStatus == .done)
|
|
#expect(decoded.payload.audio?.transcriptionLocaleID == "ru_RU")
|
|
}
|
|
|
|
@Test func audioPathDerivationSwapsExtension() {
|
|
let id = ULID()
|
|
let jsonPath = Note.relativePath(forID: id)
|
|
#expect(jsonPath.hasSuffix(".json"))
|
|
#expect(Note.audioRelativePath(forID: id) == jsonPath.replacingOccurrences(of: ".json", with: ".m4a"))
|
|
#expect(Note.audioRelativePath(forID: id).hasSuffix("\(id.stringValue).m4a"))
|
|
}
|
|
|
|
/// The swap must operate on the note's actual path, so a record filed under
|
|
/// a pre-fix local-time-zone month keeps its sidecar in the same bucket.
|
|
@Test func audioPathPreservesLegacyBucket() {
|
|
let id = ULID()
|
|
let legacyJSONPath = "1999/12/\(id.stringValue).json"
|
|
#expect(Note.audioRelativePath(forJSONPath: legacyJSONPath) == "1999/12/\(id.stringValue).m4a")
|
|
}
|
|
|
|
@Test func createIngestedDerivesCreatedAtFromULID() {
|
|
let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 5, randomLo: 6)
|
|
var context = CaptureContext.empty
|
|
context.device = "Watch"
|
|
let note = Note.createIngested(id: id, duration: 8, transcriberDeviceID: "install-x", context: context)
|
|
#expect(note.meta.id == id)
|
|
#expect(note.meta.createdAt == id.timestamp)
|
|
#expect(note.meta.modifiedAt == id.timestamp)
|
|
#expect(note.payload.text.isEmpty)
|
|
#expect(note.payload.source == .transcribed)
|
|
#expect(note.payload.audio?.transcriptionStatus == .pending)
|
|
#expect(note.payload.audio?.transcriberDeviceID == "install-x")
|
|
#expect(note.relativePath == Note.relativePath(forID: id))
|
|
}
|
|
}
|