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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user