Realign code with the 2026-07-31 rulings

The trash sorts by modified descending — the arrival rank mint retires
(Ranks.isOrderedForTrash one comparator, loader + merged order agree;
the legacy deleted: migration stamps modified from the tombstone
timestamp where parseable; delete undo steps validate existence-only;
agent guide v8). Trash selection goes kind-blind — ranges, marquee,
Select All, and the successor walk sweep both kinds; the guard moves to
the exits (mixed-payload drop refusal, copy/cut validation). The copy
stamping preflight widens back to comment depth (load-scoped posture —
the board always loads, the gesture refuses whole). Fixes a latent
no-op: trashed-lane drag restore never fired (DragSession.beginLanes
hard-coded the board container).

2403 tests in 413 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 18:35:07 -04:00
parent 542ab169a3
commit bec75e4282
37 changed files with 1200 additions and 551 deletions
+74
View File
@@ -241,6 +241,80 @@ struct TrashDropTests {
}
}
// MARK: - The mixed-kind drag out of the trash
/// **"A mixed-kind drag never leaves the trash"** (04-interactions.md The trash, ruled 2026-07-31
/// with kind-blind trash selection): "pickup is allowed the selection is legal but every
/// out-of-trash drop target refuses the mixed payload, and the release surfaces a notice explaining
/// the rule the refused drag ends like any refusal, rows staying put".
///
/// The refusal itself lives in `BoardDropContext.commitDrop`, which needs a live window and is not
/// unit-testable the same split every other drop suite makes. What is testable is the whole of
/// what the refusal is *made* of: the flag a pickup records, and the notice the release posts.
@MainActor
@Suite("The mixed-kind drag out of the trash")
struct MixedTrashDragTests {
private static let card1 = ItemID(rawValue: Ident.card1)
private static let lane1 = ItemID(rawValue: Ident.lane1)
/// One lane and one trashed card enough for a store to exist and a session to name folders.
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Trashed"))
return fixture
}
/// Pickup is allowed, and the flag is what travels instead of the rows that cannot ride a
/// per-kind payload so nothing falls silently out of the drag.
@Test("A pickup records whether its selection spanned both kinds")
func theFlagTravels() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = DragSession()
let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true)
session.beginCards([Self.card1], folders: [folder], heights: [44], container: .trash, source: store)
#expect(!session.mixesKinds, "an ordinary trash-card drag carries no flag")
session.beginCards(
[Self.card1], folders: [folder], heights: [44],
container: .trash, source: store, mixesKinds: true
)
#expect(session.mixesKinds)
#expect(session.container == .trash)
// The lane level records it the same way, and a trashed lane row's session is in `.trash`
// which is what routes its release to the restore rather than to a strip permutation.
session.beginLanes(
[Self.lane1], folders: [folder], units: [1],
container: .trash, source: store, mixesKinds: true
)
#expect(session.mixesKinds)
#expect(session.container == .trash)
// And an ordinary strip drag is unaffected: board container, no flag.
session.beginLanes([Self.lane1], folders: [folder], units: [1], source: store)
#expect(!session.mixesKinds)
#expect(session.container == .board)
}
/// The notice is 04's own sentence, and it is a **loss row** nothing failed and no write was
/// attempted, but the gesture the user made did not happen (the `postSkippedFolders` register).
@Test("The release's notice is the rule, in the design's own words")
func theNoticeExplainsTheRule() {
let banners = BannerCenter()
banners.postMixedTrashDrag()
#expect(banners.losses.map(\.message)
== ["Cards and lanes leave the trash separately \u{2014} restore one kind at a time"])
#expect(banners.oneShots.isEmpty, "no write failed — this is not an error row")
}
}
// MARK: - The committed-overlay hold
@Suite("CommittedHold")