Files
notes/NotesTests/NoteDocumentTests.swift
rzen b2f16e8600 Add Projects: third tab, swipe-to-file, per-project note capture
Notes gain an optional project (schema v4, additive). Projects derive
from note values like tags — no separate entity. Tab naming
(Projects/Categories) is a Settings choice applied across the UI.

Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
2026-07-16 18:24:11 -04:00

193 lines
7.7 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")
}
// MARK: - Schema v4 (project)
@Test func projectCodableRoundTrip() throws {
var context = CaptureContext.empty
context.device = "iPhone"
let note = Note.create(text: "Ship the release notes", context: context, project: "Contextful v0.3")
let data = try DocumentCoder.encode(note)
let decoded = try DocumentCoder.decode(Note.self, from: data)
#expect(decoded.payload == note.payload)
#expect(decoded.payload.project == "Contextful v0.3")
}
/// A v3 file predates the `project` key. It must decode unchanged — as an
/// unfiled note with `project == nil` — and pass the forward gate (3 ≤ current).
@Test func v3DocumentDecodesWithNoProject() throws {
let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 7, randomLo: 8)
let json = """
{
"meta": {
"id": "\(id.stringValue)",
"schemaVersion": 3,
"createdAt": "2023-11-14T22:13:20Z",
"modifiedAt": "2023-11-14T22:13:20Z"
},
"payload": {
"text": "A v3 note",
"source": "typed",
"tags": [],
"context": { "device": "iPhone", "timeZoneID": "UTC" }
}
}
"""
let note = try DocumentCoder.decode(Note.self, from: Data(json.utf8))
#expect(note.meta.schemaVersion == 3)
#expect(note.payload.project == nil)
#expect(note.isReadable)
}
@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))
}
}