- Schema v2: Note.AudioInfo, .m4a sidecar next to the note JSON in iCloud - AudioRecorderService (iOS/macOS) + SpeechAnalyzer transcription pipeline with enabled-language set and confidence-based auto-pick, re-transcribe menu - Settings > Transcription > Languages with asset download/reserve handling - watchOS app + accessory complication: one-button recorder, WCSession transferFile to phone, WatchInbox staging, direct-upsert ingestion - Player UI, transcription status chips, quick-capture mic in notes list - Version 0.2, changelog + README updated
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 forwardGateQuarantinesV3Fixture() throws {
|
|
let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 3, randomLo: 4)
|
|
let json = """
|
|
{
|
|
"meta": {
|
|
"id": "\(id.stringValue)",
|
|
"schemaVersion": 3,
|
|
"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 == 3)
|
|
#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))
|
|
}
|
|
}
|