Initial scaffold: context-aware notes app for iOS + macOS
XcodeGen project (Notes / NotesMac / NotesTests), Swift 6 strict concurrency, iCloud-document storage via IndieSync with a SwiftData cache, automatic capture context (location, place, time zone, device) on every note, context-based relevance ranking with a 'Right here, right now' shelf, tags, search, and the square.and.pencil-on-red app icon.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Notes
|
||||
|
||||
struct RelevanceTests {
|
||||
private let coffeeShop = (lat: 37.8044, lon: -122.2712) // Oakland
|
||||
private let farAway = (lat: 40.7128, lon: -74.0060) // NYC
|
||||
|
||||
private func signals(
|
||||
lat: Double? = nil,
|
||||
lon: Double? = nil,
|
||||
place: String? = nil,
|
||||
createdAt: Date = Date(timeIntervalSinceNow: -7 * 86_400)
|
||||
) -> RelevanceSignals {
|
||||
RelevanceSignals(
|
||||
latitude: lat, longitude: lon, placeName: place, locality: nil,
|
||||
createdAt: createdAt, timeZoneID: TimeZone.current.identifier
|
||||
)
|
||||
}
|
||||
|
||||
@Test func nearbyNoteOutscoresFarNote() {
|
||||
let query = RelevanceQuery(latitude: coffeeShop.lat, longitude: coffeeShop.lon)
|
||||
let near = Relevance.score(signals(lat: coffeeShop.lat, lon: coffeeShop.lon), query: query)
|
||||
let far = Relevance.score(signals(lat: farAway.lat, lon: farAway.lon), query: query)
|
||||
#expect(near > far)
|
||||
#expect(near >= Relevance.surfacingThreshold)
|
||||
}
|
||||
|
||||
@Test func farOldNoteStaysBelowThreshold() {
|
||||
let query = RelevanceQuery(latitude: coffeeShop.lat, longitude: coffeeShop.lon)
|
||||
let score = Relevance.score(
|
||||
signals(lat: farAway.lat, lon: farAway.lon, createdAt: Date(timeIntervalSinceNow: -180 * 86_400)),
|
||||
query: query
|
||||
)
|
||||
#expect(score < Relevance.surfacingThreshold)
|
||||
}
|
||||
|
||||
@Test func samePlaceNameBoostsWithoutCoordinates() {
|
||||
var query = RelevanceQuery()
|
||||
query.placeName = "Blue Bottle Coffee"
|
||||
let matching = Relevance.score(signals(place: "blue bottle coffee"), query: query)
|
||||
let other = Relevance.score(signals(place: "Some Other Bar"), query: query)
|
||||
#expect(matching > other)
|
||||
}
|
||||
|
||||
@Test func sameHourOutscoresOppositeHour() {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
let sameHourYesterday = calendar.date(byAdding: .day, value: -1, to: now)!
|
||||
let twelveHoursOff = calendar.date(byAdding: .hour, value: -12, to: sameHourYesterday)!
|
||||
var query = RelevanceQuery()
|
||||
query.date = now
|
||||
let same = Relevance.score(signals(createdAt: sameHourYesterday), query: query)
|
||||
let opposite = Relevance.score(signals(createdAt: twelveHoursOff), query: query)
|
||||
#expect(same > opposite)
|
||||
}
|
||||
|
||||
@Test func fresherNoteScoresHigher() {
|
||||
let query = RelevanceQuery()
|
||||
// Same clock time on different days isolates the recency term.
|
||||
let calendar = Calendar.current
|
||||
let recent = calendar.date(byAdding: .day, value: -7, to: query.date)!
|
||||
let old = calendar.date(byAdding: .day, value: -98, to: query.date)!
|
||||
let fresh = Relevance.score(signals(createdAt: recent), query: query)
|
||||
let stale = Relevance.score(signals(createdAt: old), query: query)
|
||||
#expect(fresh > stale)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user