An alignment tick when the drop proposal lands somewhere new
NSHapticFeedbackManager .alignment at propose()'s change edge — the one funnel every retarget commits through, whose existing guards (value-equal refusal, hold freeze) already make firings per-landing- spot rather than per-pixel; withdrawal stays silent (losing a target is not an alignment), and the external-file mode gets nothing. Force Touch hardware only, silent no-op elsewhere; performanceTime .default syncs the tick to the reflow the change triggers. The performer rides an injectable seam beside holdTimeout for the same reason it has one. Drag-perf card 11b85111 — the cluster's last. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -929,3 +929,121 @@ struct DragRestingLayoutTests {
|
||||
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The alignment haptic
|
||||
|
||||
/// **A tick per genuine new landing spot, never per pixel** (`DragSession.propose`): the
|
||||
/// hysteresis/dead-region model above already makes proposal changes sparse, which is what lets a
|
||||
/// tick fire on every one of them without landing in Apple's own "don't overuse haptics" caution for
|
||||
/// `.alignment`.
|
||||
///
|
||||
/// The seam is `hapticTick`, a closure substituted for the real `NSHapticFeedbackManager` call —
|
||||
/// `holdTimeout`'s own reason: a test bundle has no Force Touch trackpad to drive, so a test counts
|
||||
/// firings instead of feeling them.
|
||||
@MainActor
|
||||
@Suite("The alignment haptic")
|
||||
struct HapticTickTests {
|
||||
|
||||
private static let lane1 = ItemID(rawValue: Ident.lane1)
|
||||
private static let card1 = ItemID(rawValue: Ident.card1)
|
||||
|
||||
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("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
/// A picked-up card session with the tick wired to a counter instead of the real performer.
|
||||
private func pickedUp(_ store: BoardStore) -> (session: DragSession, ticks: () -> Int) {
|
||||
let session = DragSession()
|
||||
var count = 0
|
||||
session.hapticTick = { count += 1 }
|
||||
session.beginCards(
|
||||
[Self.card1],
|
||||
folders: [store.rootURL
|
||||
.appendingPathComponent(Ident.lane1, isDirectory: true)
|
||||
.appendingPathComponent(Ident.card1, isDirectory: true)],
|
||||
heights: [44],
|
||||
container: .board,
|
||||
source: store
|
||||
)
|
||||
return (session, { count })
|
||||
}
|
||||
|
||||
private func target(_ store: BoardStore, index: Int) -> DropTarget {
|
||||
DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: index)
|
||||
}
|
||||
|
||||
@Test("A fresh proposal ticks once")
|
||||
func newProposalTicksOnce() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let (session, ticks) = pickedUp(store)
|
||||
|
||||
session.propose(target(store, index: 0))
|
||||
|
||||
#expect(ticks() == 1)
|
||||
}
|
||||
|
||||
@Test("Re-proposing the same slot ticks zero more times")
|
||||
func sameValueReproposeDoesNotTick() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let (session, ticks) = pickedUp(store)
|
||||
|
||||
session.propose(target(store, index: 0))
|
||||
session.propose(target(store, index: 0))
|
||||
|
||||
#expect(ticks() == 1, "the second propose named the slot the first already stood at")
|
||||
}
|
||||
|
||||
@Test("Withdrawing the proposal does not tick — losing a target isn't an alignment")
|
||||
func withdrawalDoesNotTick() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let (session, ticks) = pickedUp(store)
|
||||
|
||||
session.propose(target(store, index: 0))
|
||||
let afterLanding = ticks()
|
||||
|
||||
session.propose(nil)
|
||||
|
||||
#expect(ticks() == afterLanding, "the withdrawal itself must add nothing")
|
||||
}
|
||||
|
||||
@Test("A proposal refused by a committed hold does not tick")
|
||||
func proposeUnderHoldDoesNotTick() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let (session, ticks) = pickedUp(store)
|
||||
|
||||
session.propose(target(store, index: 0))
|
||||
session.commit(into: store)
|
||||
let afterCommit = ticks()
|
||||
|
||||
session.propose(target(store, index: 1))
|
||||
|
||||
#expect(ticks() == afterCommit, "propose already refuses once a release has settled")
|
||||
}
|
||||
|
||||
@Test("A change from one slot to another ticks once")
|
||||
func slotToSlotChangeTicksOnce() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let (session, ticks) = pickedUp(store)
|
||||
|
||||
session.propose(target(store, index: 0))
|
||||
let afterFirstLanding = ticks()
|
||||
|
||||
session.propose(target(store, index: 1))
|
||||
|
||||
#expect(ticks() == afterFirstLanding + 1, "slot A to slot B is one new landing spot")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user