Files
notes/NotesTests/NoteMapperTests.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

138 lines
5.9 KiB
Swift

import Foundation
import SwiftData
import Testing
@testable import Notes
@MainActor
struct NoteMapperTests {
/// Returns the container too — `mainContext` does not keep its container
/// alive, and letting it deallocate mid-test crashes the process.
private func makeStore() throws -> (container: ModelContainer, context: ModelContext) {
let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none)
let container = try ModelContainer(for: NoteEntity.self, configurations: config)
return (container, container.mainContext)
}
private func makeNote(text: String = "Barista's name is Sam") -> Note {
var context = CaptureContext.empty
context.latitude = 37.8044
context.longitude = -122.2712
context.placeName = "Blue Bottle Coffee"
context.timeZoneID = "America/Los_Angeles"
return Note.create(text: text, tags: ["coffee"], context: context)
}
@Test func upsertInsertsThenUpdates() throws {
let (container, context) = try makeStore()
defer { withExtendedLifetime(container) {} }
let note = makeNote()
NoteMapper.upsert(note, relativePath: note.relativePath, into: context)
let inserted = NoteMapper.fetch(id: note.meta.id.stringValue, in: context)
#expect(inserted?.text == "Barista's name is Sam")
#expect(inserted?.placeName == "Blue Bottle Coffee")
var edited = note
edited.payload.text = "Barista's name is Sam — likes cortados"
edited.payload.tags = ["coffee", "people"]
NoteMapper.upsert(edited, relativePath: note.relativePath, into: context)
let all = try context.fetch(FetchDescriptor<NoteEntity>())
#expect(all.count == 1)
#expect(all.first?.text == "Barista's name is Sam — likes cortados")
#expect(all.first?.tags == ["coffee", "people"])
}
@Test func upsertWritesProjectAndNoteRestoresIt() throws {
let (container, context) = try makeStore()
defer { withExtendedLifetime(container) {} }
var note = makeNote()
note.payload.project = "Kitchen Remodel"
NoteMapper.upsert(note, relativePath: note.relativePath, into: context)
let entity = try #require(NoteMapper.fetch(id: note.meta.id.stringValue, in: context))
#expect(entity.project == "Kitchen Remodel")
let restored = try #require(NoteMapper.note(from: entity))
#expect(restored.payload.project == "Kitchen Remodel")
#expect(restored.payload == note.payload)
}
@Test func entityRoundTripsBackToNote() throws {
let (container, context) = try makeStore()
defer { withExtendedLifetime(container) {} }
let note = makeNote()
NoteMapper.upsert(note, relativePath: note.relativePath, into: context)
let entity = try #require(NoteMapper.fetch(id: note.meta.id.stringValue, in: context))
let restored = try #require(NoteMapper.note(from: entity))
#expect(restored.payload == note.payload)
#expect(restored.meta.id == note.meta.id)
}
// MARK: - Audio columns
@Test func pendingAudioColumnsRoundTrip() throws {
let (container, context) = try makeStore()
defer { withExtendedLifetime(container) {} }
var ctx = CaptureContext.empty
ctx.device = "Watch"
let audio = Note.AudioInfo(
duration: 30,
transcriberDeviceID: "install-42",
transcriptionStatus: .pending,
transcriptionLocaleID: nil,
transcribedAt: nil
)
let note = Note.create(text: "", source: .transcribed, context: ctx, 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.hasAudio)
#expect(entity.audioDuration == 30)
#expect(entity.transcriberDeviceID == "install-42")
#expect(entity.transcriptionStatusRaw == "pending")
#expect(entity.source == .transcribed)
// A pending audio note carries no `transcribedAt`, so it reconstructs
// losslessly (that field is the only one the cache intentionally drops).
let restored = try #require(NoteMapper.note(from: entity))
#expect(restored.payload == note.payload)
#expect(restored.payload.audio == audio)
}
@Test func doneTranscriptionCachesColumnsButNotTranscribedAt() throws {
let (container, context) = try makeStore()
defer { withExtendedLifetime(container) {} }
let audio = Note.AudioInfo(
duration: 5,
transcriberDeviceID: "install-7",
transcriptionStatus: .done,
transcriptionLocaleID: "en_US",
transcribedAt: Date()
)
let note = Note.create(text: "Hello there", 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.transcriptionStatusRaw == "done")
#expect(entity.transcriptionLocaleID == "en_US")
// The file is authoritative for the timestamp — the cache doesn't store it.
#expect(entity.audio?.transcribedAt == nil)
#expect(entity.audio?.transcriptionStatus == .done)
}
@Test func textNoteHasNoAudioColumns() throws {
let (container, context) = try makeStore()
defer { withExtendedLifetime(container) {} }
let note = makeNote()
NoteMapper.upsert(note, relativePath: note.relativePath, into: context)
let entity = try #require(NoteMapper.fetch(id: note.meta.id.stringValue, in: context))
#expect(!entity.hasAudio)
#expect(entity.audio == nil)
#expect(entity.audioDuration == nil)
#expect(entity.transcriptionStatusRaw == nil)
}
}