mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
f1e8f3070f
First of three v2.3 commits. Adds the data model + view-model plumbing for folder grouping and soft-archive; no UI changes yet (sidebar still renders a flat list). ProjectEntry gains two optional fields: - `folder: String?` — opaque single-level label for sidebar grouping; nil means top-level. Custom Codable decodeIfPresent so v2.2 registry files parse cleanly. - `archived: Bool` — soft-delete flag; defaults to false via custom decoder. Archived projects stay on disk and in the registry; the v2.3 sidebar just hides them unless Show Archived is toggled on. Custom encode(to:) omits both fields when they're at their default values. Keeps registry files clean for the common (top-level, unarchived) case and means v2.2 Scarf still loads a v2.3-written registry of projects that never used the new features — forward + backward compat by construction. ProjectsViewModel grows four verbs: - moveProject(_:toFolder:) — update the folder assignment - renameProject(_:to:) — rename with duplicate-name + empty-name rejection; preserves selection across the rename so the user stays on the same project - archiveProject(_:) — sets archived=true, clears selection if the archived project was selected (avoids lingering on a hidden view) - unarchiveProject(_:) — sets archived=false; does NOT re-select (unhiding ≠ focusing) - `folders: [String]` computed property — distinct folder labels, sorted, for the sidebar + move-to-folder sheet Two new test suites: - ProjectRegistryMigrationTests: round-trips v2.2 → v2.3 and back, asserts encoder cleanliness (defaults omitted), identity stability under folder / archive changes. - ProjectsViewModelTests: verbs hit the real ~/.hermes/scarf/projects.json via TestRegistryLock for cross-suite serialization. Covers happy paths, duplicate / empty-name rename rejection, and folder dedup. 73/73 Swift tests pass (was 58; 15 new). No behavior change on v2.2 registry files yet — the sidebar UI lands in commit 2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 lines
5.5 KiB
Swift
130 lines
5.5 KiB
Swift
import Testing
|
|
import Foundation
|
|
@testable import scarf
|
|
|
|
/// v2.3 grew `ProjectEntry` with `folder` and `archived` fields.
|
|
/// Both are optional/defaulted at the decoder so v2.2-era
|
|
/// `~/.hermes/scarf/projects.json` files still parse cleanly, and
|
|
/// v2.3-written files are forward-compatible with v2.2 readers
|
|
/// (which ignore unknown keys). These tests lock in both ends of
|
|
/// that contract.
|
|
///
|
|
/// No disk or Hermes dependency — we work entirely with in-memory
|
|
/// `Data`, so the `TestRegistryLock` from `ProjectTemplateTests` isn't
|
|
/// needed. Safe to run in parallel with every other test suite.
|
|
@Suite struct ProjectRegistryMigrationTests {
|
|
|
|
@Test func decodesV22RegistryWithoutNewFields() throws {
|
|
// v2.2-era file: just name + path. No folder, no archived.
|
|
let json = """
|
|
{
|
|
"projects": [
|
|
{ "name": "Legacy", "path": "/Users/x/legacy" },
|
|
{ "name": "Another", "path": "/Users/x/another" }
|
|
]
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json)
|
|
|
|
#expect(registry.projects.count == 2)
|
|
#expect(registry.projects[0].name == "Legacy")
|
|
#expect(registry.projects[0].path == "/Users/x/legacy")
|
|
// Defaults hydrate for absent v2.3 fields.
|
|
#expect(registry.projects[0].folder == nil)
|
|
#expect(registry.projects[0].archived == false)
|
|
}
|
|
|
|
@Test func decodesV23RegistryWithFolderAndArchived() throws {
|
|
let json = """
|
|
{
|
|
"projects": [
|
|
{ "name": "Client A", "path": "/Users/x/a", "folder": "Clients" },
|
|
{ "name": "Client B", "path": "/Users/x/b", "folder": "Clients", "archived": true },
|
|
{ "name": "Personal", "path": "/Users/x/p" }
|
|
]
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json)
|
|
|
|
#expect(registry.projects.count == 3)
|
|
#expect(registry.projects[0].folder == "Clients")
|
|
#expect(registry.projects[0].archived == false)
|
|
#expect(registry.projects[1].folder == "Clients")
|
|
#expect(registry.projects[1].archived == true)
|
|
#expect(registry.projects[2].folder == nil)
|
|
#expect(registry.projects[2].archived == false)
|
|
}
|
|
|
|
@Test func encodeOmitsDefaultedFields() throws {
|
|
// A top-level, non-archived project should encode with ONLY
|
|
// name + path keys. This keeps v2.3-written registries
|
|
// loadable by v2.2 Scarf (which ignores unknown keys), and
|
|
// keeps the file clean for the common case.
|
|
let entry = ProjectEntry(name: "Plain", path: "/Users/x/plain")
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.sortedKeys]
|
|
let data = try encoder.encode(entry)
|
|
let s = try #require(String(data: data, encoding: .utf8))
|
|
#expect(s == #"{"name":"Plain","path":"\/Users\/x\/plain"}"#)
|
|
}
|
|
|
|
@Test func encodeIncludesFolderWhenPresent() throws {
|
|
let entry = ProjectEntry(name: "Acme", path: "/a", folder: "Clients")
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.sortedKeys]
|
|
let data = try encoder.encode(entry)
|
|
let s = try #require(String(data: data, encoding: .utf8))
|
|
#expect(s.contains(#""folder":"Clients""#))
|
|
// archived still omitted when false — cleanliness matters.
|
|
#expect(!s.contains(#""archived""#))
|
|
}
|
|
|
|
@Test func encodeIncludesArchivedOnlyWhenTrue() throws {
|
|
let archived = ProjectEntry(name: "Old", path: "/o", archived: true)
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.sortedKeys]
|
|
let data = try encoder.encode(archived)
|
|
let s = try #require(String(data: data, encoding: .utf8))
|
|
#expect(s.contains(#""archived":true"#))
|
|
|
|
let active = ProjectEntry(name: "New", path: "/n", archived: false)
|
|
let data2 = try encoder.encode(active)
|
|
let s2 = try #require(String(data: data2, encoding: .utf8))
|
|
#expect(!s2.contains(#""archived""#))
|
|
}
|
|
|
|
@Test func roundTripPreservesAllFields() throws {
|
|
let original = ProjectRegistry(projects: [
|
|
ProjectEntry(name: "Top", path: "/t"),
|
|
ProjectEntry(name: "InFolder", path: "/f", folder: "Work"),
|
|
ProjectEntry(name: "ArchivedTop", path: "/a", archived: true),
|
|
ProjectEntry(name: "ArchivedInFolder", path: "/af", folder: "Work", archived: true)
|
|
])
|
|
|
|
let encoded = try JSONEncoder().encode(original)
|
|
let decoded = try JSONDecoder().decode(ProjectRegistry.self, from: encoded)
|
|
|
|
#expect(decoded.projects.count == 4)
|
|
#expect(decoded.projects[0].folder == nil && decoded.projects[0].archived == false)
|
|
#expect(decoded.projects[1].folder == "Work" && decoded.projects[1].archived == false)
|
|
#expect(decoded.projects[2].folder == nil && decoded.projects[2].archived == true)
|
|
#expect(decoded.projects[3].folder == "Work" && decoded.projects[3].archived == true)
|
|
}
|
|
|
|
@Test func identityStaysKeyedOnName() throws {
|
|
// ProjectEntry.id should remain `name`, so selecting by id
|
|
// across a folder-move or archive-flip still works without
|
|
// a reselection step.
|
|
let a = ProjectEntry(name: "Foo", path: "/p")
|
|
let b = ProjectEntry(name: "Foo", path: "/p", folder: "Clients")
|
|
let c = ProjectEntry(name: "Foo", path: "/p", archived: true)
|
|
#expect(a.id == "Foo")
|
|
#expect(b.id == "Foo")
|
|
#expect(c.id == "Foo")
|
|
#expect(a.id == b.id)
|
|
#expect(a.id == c.id)
|
|
}
|
|
}
|