mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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>
This commit is contained in:
@@ -11,7 +11,63 @@ struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
|
|||||||
let name: String
|
let name: String
|
||||||
let path: String
|
let path: String
|
||||||
|
|
||||||
|
/// Folder path for sidebar grouping. `nil` means top-level (no
|
||||||
|
/// folder). Introduced in v2.3 — v2.2 registry files have no
|
||||||
|
/// `folder` key, which decodes cleanly as `nil` via
|
||||||
|
/// `decodeIfPresent` below.
|
||||||
|
///
|
||||||
|
/// We leave shape flexible: today this is treated as an opaque
|
||||||
|
/// single-level label (e.g. "Clients"), and the sidebar renders
|
||||||
|
/// one DisclosureGroup per distinct value. If nesting becomes a
|
||||||
|
/// requirement later, we can interpret the string as a slash-
|
||||||
|
/// separated path without a migration (old single-label values
|
||||||
|
/// still mean a top-level folder with that name).
|
||||||
|
var folder: String?
|
||||||
|
|
||||||
|
/// Soft-archive flag. Archived projects are hidden from the
|
||||||
|
/// sidebar by default; a Show Archived toggle surfaces them.
|
||||||
|
/// Non-destructive — nothing is deleted on disk. Introduced in
|
||||||
|
/// v2.3; v2.2 registry files default to `false` via the custom
|
||||||
|
/// decoder below.
|
||||||
|
var archived: Bool
|
||||||
|
|
||||||
var dashboardPath: String { path + "/.scarf/dashboard.json" }
|
var dashboardPath: String { path + "/.scarf/dashboard.json" }
|
||||||
|
|
||||||
|
init(name: String, path: String, folder: String? = nil, archived: Bool = false) {
|
||||||
|
self.name = name
|
||||||
|
self.path = path
|
||||||
|
self.folder = folder
|
||||||
|
self.archived = archived
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Codable (custom for backward compat)
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case name, path, folder, archived
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.name = try c.decode(String.self, forKey: .name)
|
||||||
|
self.path = try c.decode(String.self, forKey: .path)
|
||||||
|
// Both new fields: tolerate absence for v2.2-era registries.
|
||||||
|
self.folder = try c.decodeIfPresent(String.self, forKey: .folder)
|
||||||
|
self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try c.encode(name, forKey: .name)
|
||||||
|
try c.encode(path, forKey: .path)
|
||||||
|
// Only emit optional fields when they carry meaning — keeps
|
||||||
|
// registry files clean for the common (top-level, unarchived)
|
||||||
|
// case and means v2.2 Scarf can still load a v2.3-written
|
||||||
|
// registry of projects that never used the new features.
|
||||||
|
try c.encodeIfPresent(folder, forKey: .folder)
|
||||||
|
if archived {
|
||||||
|
try c.encode(archived, forKey: .archived)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dashboard
|
// MARK: - Dashboard
|
||||||
|
|||||||
@@ -73,6 +73,101 @@ final class ProjectsViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - v2.3 registry verbs (folder / archive / rename)
|
||||||
|
|
||||||
|
/// Move a project into a folder. `nil` folder returns the project
|
||||||
|
/// to the top level. No-op when the target already matches.
|
||||||
|
func moveProject(_ project: ProjectEntry, toFolder folder: String?) {
|
||||||
|
mutateEntry(project) { $0.folder = folder }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename a project. `name` is the registry's unique key + the
|
||||||
|
/// Identifiable id; we reject renames that would collide with
|
||||||
|
/// another project's name. Returns true on success.
|
||||||
|
@discardableResult
|
||||||
|
func renameProject(_ project: ProjectEntry, to newName: String) -> Bool {
|
||||||
|
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return false }
|
||||||
|
guard trimmed != project.name else { return true }
|
||||||
|
var registry = service.loadRegistry()
|
||||||
|
// Reject collisions — a second project already owns that name.
|
||||||
|
guard !registry.projects.contains(where: { $0.name == trimmed }) else { return false }
|
||||||
|
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return false }
|
||||||
|
let old = registry.projects[index]
|
||||||
|
registry.projects[index] = ProjectEntry(
|
||||||
|
name: trimmed,
|
||||||
|
path: old.path,
|
||||||
|
folder: old.folder,
|
||||||
|
archived: old.archived
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
try service.saveRegistry(registry)
|
||||||
|
} catch {
|
||||||
|
logger.error("renameProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
projects = registry.projects
|
||||||
|
// Preserve selection across the rename — the selected project
|
||||||
|
// still exists, it just has a new id.
|
||||||
|
if selectedProject?.name == project.name {
|
||||||
|
selectedProject = registry.projects[index]
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-archive a project. It stays on disk and in the registry;
|
||||||
|
/// the sidebar just hides it unless `showArchived` is on.
|
||||||
|
func archiveProject(_ project: ProjectEntry) {
|
||||||
|
mutateEntry(project) { $0.archived = true }
|
||||||
|
// If the archived project was selected, clear selection so
|
||||||
|
// the dashboard doesn't linger on a hidden project.
|
||||||
|
if selectedProject?.name == project.name {
|
||||||
|
selectedProject = nil
|
||||||
|
dashboard = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore an archived project to the default view.
|
||||||
|
func unarchiveProject(_ project: ProjectEntry) {
|
||||||
|
mutateEntry(project) { $0.archived = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distinct folder labels across the current project set, sorted
|
||||||
|
/// alphabetically. Drives the sidebar's DisclosureGroups (commit
|
||||||
|
/// 2) and the Move-to-Folder sheet's existing-folder list. An
|
||||||
|
/// "empty" folder (folder with zero projects) can't exist under
|
||||||
|
/// this model — folders are implicit in the data — which is
|
||||||
|
/// intentional: v2.3 doesn't need first-class empty folders.
|
||||||
|
var folders: [String] {
|
||||||
|
let set = Set(projects.compactMap(\.folder).filter { !$0.isEmpty })
|
||||||
|
return set.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Fetch the registry, apply `mutation` to the matched entry,
|
||||||
|
/// persist, update in-memory state. Centralises the save +
|
||||||
|
/// re-publish dance shared by `moveProject`, `archiveProject`,
|
||||||
|
/// and `unarchiveProject`. Callers that need different matching
|
||||||
|
/// semantics (rename, remove) handle their own registry mutation.
|
||||||
|
private func mutateEntry(_ project: ProjectEntry, _ mutation: (inout ProjectEntry) -> Void) {
|
||||||
|
var registry = service.loadRegistry()
|
||||||
|
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return }
|
||||||
|
var entry = registry.projects[index]
|
||||||
|
mutation(&entry)
|
||||||
|
registry.projects[index] = entry
|
||||||
|
do {
|
||||||
|
try service.saveRegistry(registry)
|
||||||
|
} catch {
|
||||||
|
logger.error("mutateEntry couldn't persist registry for \(project.name, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projects = registry.projects
|
||||||
|
if selectedProject?.name == project.name {
|
||||||
|
selectedProject = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func refreshDashboard() {
|
func refreshDashboard() {
|
||||||
guard let project = selectedProject else { return }
|
guard let project = selectedProject else { return }
|
||||||
loadDashboard(for: project)
|
loadDashboard(for: project)
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import scarf
|
||||||
|
|
||||||
|
/// Exercises the v2.3 registry verbs added to ProjectsViewModel:
|
||||||
|
/// moveProject, renameProject, archiveProject, unarchiveProject,
|
||||||
|
/// + the derived `folders` list. All verbs write through to
|
||||||
|
/// `~/.hermes/scarf/projects.json` via ProjectDashboardService, so
|
||||||
|
/// each test uses TestRegistryLock to snapshot + restore the real
|
||||||
|
/// file. Cross-suite serialization ensures we don't race with other
|
||||||
|
/// registry-touching tests.
|
||||||
|
@MainActor @Suite(.serialized) struct ProjectsViewModelTests {
|
||||||
|
|
||||||
|
@Test func moveProjectSetsFolder() async throws {
|
||||||
|
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||||
|
defer { TestRegistryLock.restore(snapshot) }
|
||||||
|
try seedRegistry(.init(projects: [
|
||||||
|
ProjectEntry(name: "Alpha", path: "/a"),
|
||||||
|
ProjectEntry(name: "Beta", path: "/b")
|
||||||
|
]))
|
||||||
|
|
||||||
|
let vm = ProjectsViewModel(context: .local)
|
||||||
|
vm.load()
|
||||||
|
#expect(vm.projects.count == 2)
|
||||||
|
|
||||||
|
vm.moveProject(vm.projects[0], toFolder: "Clients")
|
||||||
|
|
||||||
|
#expect(vm.projects.count == 2)
|
||||||
|
#expect(vm.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients")
|
||||||
|
#expect(vm.projects.first(where: { $0.name == "Beta" })?.folder == nil)
|
||||||
|
|
||||||
|
// Round-trip: reload from disk and confirm the move persisted.
|
||||||
|
let fresh = ProjectDashboardService(context: .local).loadRegistry()
|
||||||
|
#expect(fresh.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func moveProjectToNilReturnsToTopLevel() async throws {
|
||||||
|
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||||
|
defer { TestRegistryLock.restore(snapshot) }
|
||||||
|
try seedRegistry(.init(projects: [
|
||||||
|
ProjectEntry(name: "Nested", path: "/n", folder: "Clients")
|
||||||
|
]))
|
||||||
|
|
||||||
|
let vm = ProjectsViewModel(context: .local)
|
||||||
|
vm.load()
|
||||||
|
vm.moveProject(vm.projects[0], toFolder: nil)
|
||||||
|
|
||||||
|
#expect(vm.projects[0].folder == nil)
|
||||||
|
let fresh = ProjectDashboardService(context: .local).loadRegistry()
|
||||||
|
#expect(fresh.projects[0].folder == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func renameProjectUpdatesNameAndPreservesOtherFields() async throws {
|
||||||
|
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||||
|
defer { TestRegistryLock.restore(snapshot) }
|
||||||
|
try seedRegistry(.init(projects: [
|
||||||
|
ProjectEntry(name: "OldName", path: "/p", folder: "Work", archived: false)
|
||||||
|
]))
|
||||||
|
|
||||||
|
let vm = ProjectsViewModel(context: .local)
|
||||||
|
vm.load()
|
||||||
|
vm.selectProject(vm.projects[0])
|
||||||
|
|
||||||
|
let ok = vm.renameProject(vm.projects[0], to: "NewName")
|
||||||
|
#expect(ok == true)
|
||||||
|
#expect(vm.projects.count == 1)
|
||||||
|
#expect(vm.projects[0].name == "NewName")
|
||||||
|
#expect(vm.projects[0].folder == "Work")
|
||||||
|
#expect(vm.projects[0].archived == false)
|
||||||
|
// Selection follows the rename — the user stays on the same
|
||||||
|
// project they were on.
|
||||||
|
#expect(vm.selectedProject?.name == "NewName")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func renameProjectRejectsDuplicateName() async throws {
|
||||||
|
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||||
|
defer { TestRegistryLock.restore(snapshot) }
|
||||||
|
try seedRegistry(.init(projects: [
|
||||||
|
ProjectEntry(name: "A", path: "/a"),
|
||||||
|
ProjectEntry(name: "B", path: "/b")
|
||||||
|
]))
|
||||||
|
|
||||||
|
let vm = ProjectsViewModel(context: .local)
|
||||||
|
vm.load()
|
||||||
|
|
||||||
|
// Renaming A to B should be refused — B already exists.
|
||||||
|
let ok = vm.renameProject(vm.projects[0], to: "B")
|
||||||
|
#expect(ok == false)
|
||||||
|
// Registry unchanged.
|
||||||
|
#expect(vm.projects.map(\.name) == ["A", "B"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func renameProjectRejectsEmptyName() async throws {
|
||||||
|
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||||
|
defer { TestRegistryLock.restore(snapshot) }
|
||||||
|
try seedRegistry(.init(projects: [
|
||||||
|
ProjectEntry(name: "Foo", path: "/f")
|
||||||
|
]))
|
||||||
|
|
||||||
|
let vm = ProjectsViewModel(context: .local)
|
||||||
|
vm.load()
|
||||||
|
|
||||||
|
#expect(vm.renameProject(vm.projects[0], to: "") == false)
|
||||||
|
#expect(vm.renameProject(vm.projects[0], to: " ") == false)
|
||||||
|
#expect(vm.projects[0].name == "Foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func renameProjectToSameNameIsNoOpSuccess() async throws {
|
||||||
|
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||||
|
defer { TestRegistryLock.restore(snapshot) }
|
||||||
|
try seedRegistry(.init(projects: [
|
||||||
|
ProjectEntry(name: "Foo", path: "/f")
|
||||||
|
]))
|
||||||
|
|
||||||
|
let vm = ProjectsViewModel(context: .local)
|
||||||
|
vm.load()
|
||||||
|
|
||||||
|
#expect(vm.renameProject(vm.projects[0], to: "Foo") == true)
|
||||||
|
// Whitespace around matching name also no-ops.
|
||||||
|
#expect(vm.renameProject(vm.projects[0], to: " Foo ") == true)
|
||||||
|
#expect(vm.projects[0].name == "Foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func archiveAndUnarchiveProject() async throws {
|
||||||
|
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||||
|
defer { TestRegistryLock.restore(snapshot) }
|
||||||
|
try seedRegistry(.init(projects: [
|
||||||
|
ProjectEntry(name: "Target", path: "/t")
|
||||||
|
]))
|
||||||
|
|
||||||
|
let vm = ProjectsViewModel(context: .local)
|
||||||
|
vm.load()
|
||||||
|
vm.selectProject(vm.projects[0])
|
||||||
|
#expect(vm.projects[0].archived == false)
|
||||||
|
#expect(vm.selectedProject != nil)
|
||||||
|
|
||||||
|
vm.archiveProject(vm.projects[0])
|
||||||
|
#expect(vm.projects[0].archived == true)
|
||||||
|
// Archiving clears the selection so the dashboard doesn't
|
||||||
|
// linger on a project the sidebar will hide.
|
||||||
|
#expect(vm.selectedProject == nil)
|
||||||
|
|
||||||
|
vm.unarchiveProject(vm.projects[0])
|
||||||
|
#expect(vm.projects[0].archived == false)
|
||||||
|
// Unarchive doesn't re-select — the user chose to hide it,
|
||||||
|
// surfacing it doesn't mean they want focus back.
|
||||||
|
#expect(vm.selectedProject == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func foldersListIsSortedAndDeduped() async throws {
|
||||||
|
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||||
|
defer { TestRegistryLock.restore(snapshot) }
|
||||||
|
try seedRegistry(.init(projects: [
|
||||||
|
ProjectEntry(name: "A", path: "/a", folder: "Work"),
|
||||||
|
ProjectEntry(name: "B", path: "/b", folder: "Personal"),
|
||||||
|
ProjectEntry(name: "C", path: "/c", folder: "Work"),
|
||||||
|
ProjectEntry(name: "D", path: "/d"), // top-level
|
||||||
|
ProjectEntry(name: "E", path: "/e", folder: "") // empty string treated as nil
|
||||||
|
]))
|
||||||
|
|
||||||
|
let vm = ProjectsViewModel(context: .local)
|
||||||
|
vm.load()
|
||||||
|
|
||||||
|
#expect(vm.folders == ["Personal", "Work"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func seedRegistry(_ registry: ProjectRegistry) throws {
|
||||||
|
try ProjectDashboardService(context: .local).saveRegistry(registry)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user