Files
scarf/scarf/scarfTests/ProjectRegistryMigrationTests.swift
T
Alan Wizemann f1e8f3070f feat(projects): registry schema v2 — folder + archived fields
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>
2026-04-23 22:57:02 +02:00

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)
}
}