@dimina-kit/view-anchor 0.1.0-dev.20260610082053

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1184 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
2
+ import { createViewAnchor, createPlacementAnchor } from './view-anchor.js'
3
+ import type { Bounds, Placement, ViewAnchorOptions } from './types.js'
4
+
5
+ // ── ResizeObserver / RAF stubs ───────────────────────────────────────
6
+ //
7
+ // - `FakeResizeObserver` records observed elements + disconnect, and
8
+ // exposes `fire()` to synchronously invoke its callback.
9
+ // - The anchor publishes SYNCHRONOUSLY (no RAF defer) — see the module
10
+ // header. We install `requestAnimationFrame`/`cancelAnimationFrame`
11
+ // spies NOT to drive a fake queue but as a regression guard: the anchor
12
+ // must NEVER call either (asserted in the "never schedules a RAF" test
13
+ // and implicitly available everywhere via `rafSpy`/`cancelSpy`).
14
+
15
+ class FakeResizeObserver {
16
+ static instances: FakeResizeObserver[] = []
17
+ observed: Element[] = []
18
+ disconnected = false
19
+ constructor(public cb: ResizeObserverCallback) {
20
+ FakeResizeObserver.instances.push(this)
21
+ }
22
+ observe(el: Element): void {
23
+ this.observed.push(el)
24
+ }
25
+ unobserve(): void {
26
+ /* unused */
27
+ }
28
+ disconnect(): void {
29
+ this.disconnected = true
30
+ }
31
+ fire(): void {
32
+ this.cb([], this)
33
+ }
34
+ }
35
+
36
+ // RAF regression guards: if the anchor ever re-introduces a RAF defer these
37
+ // spies will record a call, and the dedicated "never schedules a RAF" test
38
+ // fails. They do NOT queue anything — publishes are synchronous.
39
+ const rafSpy = vi.fn(() => 0)
40
+ const cancelSpy = vi.fn()
41
+ const resizeAddSpy = vi.fn()
42
+ const resizeRemoveSpy = vi.fn()
43
+ // Handlers added to the real window via the addEventListener spy, tracked so
44
+ // afterEach can remove leaked 'resize' listeners — tests create anchors
45
+ // directly and don't dispose them, so their listeners would otherwise
46
+ // accumulate across tests and fire in later tests.
47
+ let leakedResize: unknown[] = []
48
+
49
+ beforeEach(() => {
50
+ FakeResizeObserver.instances = []
51
+ rafSpy.mockClear()
52
+ cancelSpy.mockClear()
53
+ resizeAddSpy.mockClear()
54
+ resizeRemoveSpy.mockClear()
55
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver)
56
+ vi.stubGlobal(
57
+ 'requestAnimationFrame',
58
+ rafSpy as unknown as typeof window.requestAnimationFrame,
59
+ )
60
+ vi.stubGlobal(
61
+ 'cancelAnimationFrame',
62
+ cancelSpy as unknown as typeof window.cancelAnimationFrame,
63
+ )
64
+
65
+ // Spy on window resize listener add/remove so we can assert the anchor
66
+ // installs exactly one resize listener when present, and removes it on
67
+ // dispose / present=false. We forward to the real implementation so
68
+ // dispatching a real 'resize' event still works.
69
+ const realAdd = window.addEventListener.bind(window)
70
+ const realRemove = window.removeEventListener.bind(window)
71
+ vi.spyOn(window, 'addEventListener').mockImplementation(
72
+ (type: string, ...rest: unknown[]) => {
73
+ if (type === 'resize') {
74
+ resizeAddSpy(...rest)
75
+ leakedResize.push(rest[0])
76
+ }
77
+ return (realAdd as unknown as (...a: unknown[]) => void)(type, ...rest)
78
+ },
79
+ )
80
+ vi.spyOn(window, 'removeEventListener').mockImplementation(
81
+ (type: string, ...rest: unknown[]) => {
82
+ if (type === 'resize') {
83
+ resizeRemoveSpy(...rest)
84
+ leakedResize = leakedResize.filter((h) => h !== rest[0])
85
+ }
86
+ return (realRemove as unknown as (...a: unknown[]) => void)(type, ...rest)
87
+ },
88
+ )
89
+ })
90
+
91
+ afterEach(() => {
92
+ // Remove resize listeners leaked by undisposed anchors before restoring
93
+ // the spies, so they can't fire in (and pollute) a later test.
94
+ leakedResize.forEach((h) =>
95
+ window.removeEventListener('resize', h as EventListener),
96
+ )
97
+ leakedResize = []
98
+ vi.restoreAllMocks()
99
+ vi.unstubAllGlobals()
100
+ })
101
+
102
+ /** Assert an observer was installed, then return it. Used so a missing
103
+ * observer (skeleton no-op) surfaces as a clear "expected 1, got 0"
104
+ * behavioural assertion instead of a TypeError on a later `.fire()`. */
105
+ function firstObserver(): FakeResizeObserver {
106
+ expect(FakeResizeObserver.instances.length).toBeGreaterThanOrEqual(1)
107
+ return FakeResizeObserver.instances[0]!
108
+ }
109
+
110
+ /** Same, for the most-recently created observer (post-update). */
111
+ function lastObserver(): FakeResizeObserver {
112
+ expect(FakeResizeObserver.instances.length).toBeGreaterThanOrEqual(1)
113
+ return FakeResizeObserver.instances.at(-1)!
114
+ }
115
+
116
+ // ── Element fixture ──────────────────────────────────────────────────
117
+ //
118
+ // jsdom's `getBoundingClientRect` always returns zeros, so we stub it.
119
+ // `setRect` lets a test move the element after the anchor was created (to
120
+ // prove a tick reads the *current* rect, not a captured one).
121
+
122
+ function buildElement(rect: {
123
+ x: number
124
+ y: number
125
+ w: number
126
+ h: number
127
+ }): { el: HTMLElement; setRect: (next: typeof rect) => void } {
128
+ const el = document.createElement('div')
129
+ let current = rect
130
+ vi.spyOn(el, 'getBoundingClientRect').mockImplementation(
131
+ () =>
132
+ ({
133
+ x: current.x,
134
+ y: current.y,
135
+ left: current.x,
136
+ top: current.y,
137
+ right: current.x + current.w,
138
+ bottom: current.y + current.h,
139
+ width: current.w,
140
+ height: current.h,
141
+ toJSON: () => ({}),
142
+ }) as DOMRect,
143
+ )
144
+ return {
145
+ el,
146
+ setRect(next) {
147
+ current = next
148
+ },
149
+ }
150
+ }
151
+
152
+ const opts = (o: ViewAnchorOptions): ViewAnchorOptions => o
153
+
154
+ // ── Contract 1: present=true with geometry → immediate sync publish ──
155
+ // Bug it catches: a missing initial emit means the main process never
156
+ // learns where the view is — the native view never attaches.
157
+
158
+ describe('createViewAnchor — present=true initial sync', () => {
159
+ it('publishes the rounded/clamped rect once, synchronously, on create', () => {
160
+ const publish = vi.fn()
161
+ const { el } = buildElement({ x: 10, y: 20, w: 300, h: 400 })
162
+ createViewAnchor(el, opts({ present: true, publish }))
163
+
164
+ expect(publish).toHaveBeenCalledTimes(1)
165
+ expect(publish).toHaveBeenCalledWith({
166
+ x: 10,
167
+ y: 20,
168
+ width: 300,
169
+ height: 400,
170
+ })
171
+ })
172
+
173
+ it('rounds x/y (negatives allowed) and clamps width/height to ≥0', () => {
174
+ const publish = vi.fn()
175
+ // Fractional + negative left/top: x/y are ROUNDED (not clamped) — an
176
+ // element scrolled off the top/left edge has a legitimately negative
177
+ // origin and the native view must track it there. width/height are
178
+ // clamped to ≥0 (0 = the canonical hidden signal).
179
+ const { el } = buildElement({ x: -3.4, y: 12.6, w: 100.49, h: 0.5 })
180
+ createViewAnchor(el, opts({ present: true, publish }))
181
+
182
+ expect(publish).toHaveBeenCalledWith({
183
+ x: -3, // Math.round(-3.4) — NOT clamped to 0
184
+ y: 13, // Math.round(12.6)
185
+ width: 100, // Math.max(0, Math.round(100.49))
186
+ height: 1, // Math.max(0, Math.round(0.5))
187
+ })
188
+ })
189
+ })
190
+
191
+ // ── Contract 2: present=false → immediate zero, no observer ──────────
192
+ // Bug it catches: if present=false does NOT publish zero, the native view
193
+ // never detaches and its old frame stays painted over the content.
194
+ // Bug it also catches: installing a ResizeObserver while detached wastes
195
+ // work and can re-publish a non-zero rect, re-attaching the view.
196
+
197
+ describe('createViewAnchor — present=false', () => {
198
+ it('publishes {0,0,0,0} immediately and does NOT observe', () => {
199
+ const publish = vi.fn()
200
+ const { el } = buildElement({ x: 10, y: 20, w: 300, h: 400 })
201
+ createViewAnchor(el, opts({ present: false, publish }))
202
+
203
+ expect(publish).toHaveBeenCalledTimes(1)
204
+ expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
205
+ // No ResizeObserver and no resize listener while detached.
206
+ expect(FakeResizeObserver.instances).toHaveLength(0)
207
+ expect(resizeAddSpy).not.toHaveBeenCalled()
208
+ })
209
+ })
210
+
211
+ // ── Contract 3: present=true installs observers; ticks publish SYNC ──
212
+ // Bug it catches: a tick that defers to a RAF stacks a second compositor
213
+ // frame on top of the unavoidable cross-process frame — the overlay
214
+ // visibly trails the region edge during a drag. The new contract is to
215
+ // publish in the triggering tick itself, no RAF.
216
+
217
+ describe('createViewAnchor — present=true observation', () => {
218
+ it('observes the target and adds a window resize listener', () => {
219
+ const publish = vi.fn()
220
+ const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
221
+ createViewAnchor(el, opts({ present: true, publish }))
222
+
223
+ expect(FakeResizeObserver.instances).toHaveLength(1)
224
+ expect(FakeResizeObserver.instances[0]!.observed).toContain(el)
225
+ expect(resizeAddSpy).toHaveBeenCalledTimes(1)
226
+ })
227
+
228
+ it('a ResizeObserver tick re-publishes SYNCHRONOUSLY (no RAF) with the current rect', () => {
229
+ const publish = vi.fn()
230
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
231
+ createViewAnchor(el, opts({ present: true, publish }))
232
+ publish.mockClear()
233
+
234
+ // Move the element, then fire the observer — the publish lands in the
235
+ // same synchronous tick, reading the *current* rect.
236
+ setRect({ x: 5, y: 6, w: 120, h: 130 })
237
+ firstObserver().fire()
238
+
239
+ expect(publish).toHaveBeenCalledTimes(1)
240
+ expect(publish).toHaveBeenCalledWith({ x: 5, y: 6, width: 120, height: 130 })
241
+ })
242
+
243
+ it('a window resize tick re-publishes synchronously', () => {
244
+ const publish = vi.fn()
245
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
246
+ createViewAnchor(el, opts({ present: true, publish }))
247
+ publish.mockClear()
248
+
249
+ setRect({ x: 1, y: 2, w: 50, h: 60 })
250
+ window.dispatchEvent(new Event('resize'))
251
+
252
+ expect(publish).toHaveBeenCalledTimes(1)
253
+ expect(publish).toHaveBeenCalledWith({ x: 1, y: 2, width: 50, height: 60 })
254
+ })
255
+
256
+ it('never schedules a requestAnimationFrame (RAF defer must not creep back)', () => {
257
+ const publish = vi.fn()
258
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
259
+ const handle = createViewAnchor(el, opts({ present: true, publish }))
260
+
261
+ // Exercise every code path that used to schedule a RAF.
262
+ setRect({ x: 5, y: 6, w: 120, h: 130 })
263
+ firstObserver().fire()
264
+ window.dispatchEvent(new Event('resize'))
265
+ handle.update({ present: false, publish })
266
+
267
+ expect(rafSpy).not.toHaveBeenCalled()
268
+ expect(cancelSpy).not.toHaveBeenCalled()
269
+ })
270
+ })
271
+
272
+ // ── Contract 4: dedup-coalescing ─────────────────────────────────────
273
+ // Bug it catches: without dedup, a burst of RO+resize ticks in one frame —
274
+ // or a continuous drag that keeps re-firing the same final rect — produces N
275
+ // publishes (and N native-view setBounds calls) → IPC flood / jitter. The
276
+ // contract: a tick whose measured rect is byte-identical to the last published
277
+ // one is dropped; a distinct rect always publishes.
278
+
279
+ describe('createViewAnchor — dedup coalescing', () => {
280
+ it('N ticks measuring the SAME rect → exactly ONE publish', () => {
281
+ const publish = vi.fn()
282
+ const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
283
+ createViewAnchor(el, opts({ present: true, publish }))
284
+ publish.mockClear()
285
+
286
+ // A same-frame burst of RO + resize, all measuring the same (unchanged)
287
+ // rect: only the first emits, the rest dedup away.
288
+ const ro = firstObserver()
289
+ ro.fire()
290
+ ro.fire()
291
+ window.dispatchEvent(new Event('resize'))
292
+ ro.fire()
293
+
294
+ expect(publish).not.toHaveBeenCalled() // rect unchanged since create
295
+ })
296
+
297
+ it('a tick whose rect differs from the create-time rect publishes once', () => {
298
+ const publish = vi.fn()
299
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
300
+ createViewAnchor(el, opts({ present: true, publish }))
301
+ publish.mockClear()
302
+
303
+ setRect({ x: 7, y: 8, w: 110, h: 120 })
304
+ firstObserver().fire()
305
+ firstObserver().fire() // same rect again → deduped
306
+
307
+ expect(publish).toHaveBeenCalledTimes(1)
308
+ expect(publish).toHaveBeenCalledWith({ x: 7, y: 8, width: 110, height: 120 })
309
+ })
310
+
311
+ it('ticks measuring DIFFERENT rects each publish; identical rects dedup', () => {
312
+ const publish = vi.fn()
313
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
314
+ createViewAnchor(el, opts({ present: true, publish }))
315
+ publish.mockClear()
316
+ const ro = firstObserver()
317
+
318
+ // Move → publish.
319
+ setRect({ x: 5, y: 5, w: 100, h: 100 })
320
+ ro.fire()
321
+ // Same rect twice more → deduped.
322
+ ro.fire()
323
+ ro.fire()
324
+ // Move again → publish.
325
+ setRect({ x: 5, y: 5, w: 200, h: 100 })
326
+ ro.fire()
327
+ // Move back to the first moved rect → distinct from last published → publish.
328
+ setRect({ x: 5, y: 5, w: 100, h: 100 })
329
+ ro.fire()
330
+
331
+ expect(publish).toHaveBeenCalledTimes(3)
332
+ expect(publish.mock.calls.map((c) => c[0])).toEqual([
333
+ { x: 5, y: 5, width: 100, height: 100 },
334
+ { x: 5, y: 5, width: 200, height: 100 },
335
+ { x: 5, y: 5, width: 100, height: 100 },
336
+ ])
337
+ })
338
+ })
339
+
340
+ // ── Contract 5: update() re-publishes immediately per new state ──────
341
+ // Bug it catches: update() that defers (or no-ops) leaves the native view
342
+ // at its old bounds after a present flip — e.g. a panel that hid stays
343
+ // painted, or a panel that showed never re-measures.
344
+
345
+ describe('createViewAnchor — update()', () => {
346
+ it('true → false: publishes zero immediately and stops observing', () => {
347
+ const publish = vi.fn()
348
+ const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
349
+ const handle = createViewAnchor(el, opts({ present: true, publish }))
350
+ expect(firstObserver().disconnected).toBe(false)
351
+ publish.mockClear()
352
+
353
+ handle.update({ present: false, publish })
354
+
355
+ expect(publish).toHaveBeenCalledTimes(1)
356
+ expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
357
+ // Observer disconnected and resize listener removed.
358
+ expect(firstObserver().disconnected).toBe(true)
359
+ expect(resizeRemoveSpy).toHaveBeenCalledTimes(1)
360
+ })
361
+
362
+ it('false → true: measures the current rect and starts observing', () => {
363
+ const publish = vi.fn()
364
+ const { el } = buildElement({ x: 7, y: 8, w: 90, h: 110 })
365
+ const handle = createViewAnchor(el, opts({ present: false, publish }))
366
+ // present=false start: zero, no observer.
367
+ expect(FakeResizeObserver.instances).toHaveLength(0)
368
+ publish.mockClear()
369
+
370
+ handle.update({ present: true, publish })
371
+
372
+ expect(publish).toHaveBeenCalledTimes(1)
373
+ expect(publish).toHaveBeenCalledWith({ x: 7, y: 8, width: 90, height: 110 })
374
+ expect(FakeResizeObserver.instances).toHaveLength(1)
375
+ expect(FakeResizeObserver.instances[0]!.observed).toContain(el)
376
+ })
377
+
378
+ it('swapping publish while present routes new emits to the new callback', () => {
379
+ const first = vi.fn()
380
+ const second = vi.fn()
381
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
382
+ const handle = createViewAnchor(el, opts({ present: true, publish: first }))
383
+ first.mockClear()
384
+
385
+ handle.update({ present: true, publish: second })
386
+ // Immediate re-publish goes to the new callback (apply() resets
387
+ // lastPublished, so an unchanged rect still emits — see next test).
388
+ expect(second).toHaveBeenCalledWith({ x: 0, y: 0, width: 100, height: 100 })
389
+
390
+ second.mockClear()
391
+ setRect({ x: 5, y: 5, w: 100, h: 100 })
392
+ lastObserver().fire()
393
+ expect(second).toHaveBeenCalledTimes(1)
394
+ expect(first).not.toHaveBeenCalled()
395
+ })
396
+
397
+ it('update() with an unchanged rect still forces one publish (lastPublished reset)', () => {
398
+ // Why this matters: zoom rides in the `publish` closure, not in `Bounds`.
399
+ // A zoom change re-calls update() with the SAME geometry but a NEW publish
400
+ // closure — the anchor must re-emit so the new closure runs, even though
401
+ // the dedup would otherwise drop a byte-identical rect. `apply()` resets
402
+ // `lastPublished = null` to guarantee that one fresh publish.
403
+ const first = vi.fn()
404
+ const { el } = buildElement({ x: 12, y: 34, w: 56, h: 78 })
405
+ const handle = createViewAnchor(el, opts({ present: true, publish: first }))
406
+ expect(first).toHaveBeenCalledTimes(1)
407
+
408
+ // Same present/measure, unchanged geometry, but a brand-new publish spy.
409
+ const second = vi.fn()
410
+ handle.update({ present: true, publish: second })
411
+
412
+ expect(second).toHaveBeenCalledTimes(1)
413
+ expect(second).toHaveBeenCalledWith({ x: 12, y: 34, width: 56, height: 78 })
414
+ })
415
+ })
416
+
417
+ // ── Contract 6: dispose() tears everything down ─────────────────────
418
+ // Bug it catches: a dispose that forgets to disconnect the RO / remove the
419
+ // resize listener leaks observers and can publish after teardown (the IPC
420
+ // target may already be gone → throw).
421
+
422
+ describe('createViewAnchor — dispose()', () => {
423
+ it('disconnects the observer and removes the resize listener', () => {
424
+ const publish = vi.fn()
425
+ const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
426
+ const handle = createViewAnchor(el, opts({ present: true, publish }))
427
+
428
+ handle.dispose()
429
+
430
+ expect(firstObserver().disconnected).toBe(true)
431
+ expect(resizeRemoveSpy).toHaveBeenCalledTimes(1)
432
+ // Teardown is synchronous and uses no RAF — nothing to cancel.
433
+ expect(cancelSpy).not.toHaveBeenCalled()
434
+ })
435
+
436
+ it('never publishes again after dispose (later RO/resize events are ignored)', () => {
437
+ const publish = vi.fn()
438
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
439
+ const handle = createViewAnchor(el, opts({ present: true, publish }))
440
+ const ro = firstObserver()
441
+ handle.dispose()
442
+ publish.mockClear()
443
+
444
+ // Move the element so a non-deduped rect WOULD publish if `disposed`
445
+ // weren't read synchronously in every emit. Any event after dispose
446
+ // must be inert — there is no queued frame to outrun the flag.
447
+ setRect({ x: 999, y: 999, w: 999, h: 999 })
448
+ ro.fire()
449
+ window.dispatchEvent(new Event('resize'))
450
+ expect(publish).not.toHaveBeenCalled()
451
+ })
452
+ })
453
+
454
+ // ── Contract 7: teardown safety (no stale tick can overwrite live) ───
455
+ // There is no queued frame anymore, so the old "stale-RAF guard" is now a
456
+ // pure synchronous-read guard: every emit reads `disposed`/`present` live,
457
+ // so a tick after dispose()/update(present=false) can never write a stale
458
+ // rect over the live one.
459
+
460
+ describe('createViewAnchor — teardown safety', () => {
461
+ it('a tick after dispose() does not publish (disposed read synchronously)', () => {
462
+ const publish = vi.fn()
463
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
464
+ const handle = createViewAnchor(el, opts({ present: true, publish }))
465
+ const ro = firstObserver()
466
+ publish.mockClear()
467
+
468
+ handle.dispose()
469
+ setRect({ x: 999, y: 999, w: 999, h: 999 })
470
+ ro.fire()
471
+
472
+ expect(publish).not.toHaveBeenCalled()
473
+ })
474
+
475
+ it('after update(present=false), a later tick does not publish a non-zero rect', () => {
476
+ const publish = vi.fn()
477
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
478
+ const handle = createViewAnchor(el, opts({ present: true, publish }))
479
+ const ro = firstObserver()
480
+
481
+ // Flip to present=false → synchronous zero, observers stopped.
482
+ handle.update({ present: false, publish })
483
+ expect(publish).toHaveBeenLastCalledWith({ x: 0, y: 0, width: 0, height: 0 })
484
+ publish.mockClear()
485
+
486
+ // A late tick (e.g. a detached-but-not-yet-GC'd observer reference) must
487
+ // not republish a non-zero rect over the zero — `present` is read live.
488
+ setRect({ x: 999, y: 999, w: 999, h: 999 })
489
+ ro.fire()
490
+ window.dispatchEvent(new Event('resize'))
491
+ expect(publish).not.toHaveBeenCalled()
492
+ })
493
+ })
494
+
495
+ // Local type assertion so the file fails loudly if the Bounds shape drifts
496
+ // from what these tests assert against.
497
+ const _boundsShape: Bounds = { x: 0, y: 0, width: 0, height: 0 }
498
+ void _boundsShape
499
+
500
+ // ── display:none / first-frame guard (opt-in) ───────────────────────
501
+ //
502
+ // TDD — FAILING-FIRST tests for an ADDITIVE, OPT-IN hardening of
503
+ // `createPlacementAnchor`, gated behind a new `guardDisplayNone?: boolean`
504
+ // option (default false = today's behaviour, unchanged).
505
+ //
506
+ // Two new behaviours, ONLY when `guardDisplayNone: true`:
507
+ // §4 First-frame guard — `visible:true` but the measured rect has
508
+ // `width === 0 || height === 0` (no geometry box: unmounted /
509
+ // display:none / unstable first layout) → publish `{ visible:false }`
510
+ // (a detach, NO bounds) instead of `{ visible:true, bounds:{...,0,0} }`.
511
+ // A 0-area target has no geometry to anchor; emitting a detach avoids
512
+ // the native view flashing at (0,0). NOTE: only ZERO WIDTH OR HEIGHT
513
+ // triggers this — a NON-zero box at position (0,0) is still a normal
514
+ // `{ visible:true }`.
515
+ // §3/§2.E display:none guard — the anchor attaches an
516
+ // `IntersectionObserver` on the target. When the target goes
517
+ // display:none (IO reports `isIntersecting:false` with a zero-area
518
+ // `boundingClientRect`) → publish `{ visible:false }`. When it comes
519
+ // back with a non-zero box (IO intersecting again / a subsequent
520
+ // measure yields non-zero width&height) → re-measure → publish
521
+ // `{ visible:true, bounds }`.
522
+ //
523
+ // Caller intent still wins (§3.1): a caller-`visible:false` anchor is
524
+ // detached regardless — the guard NEVER flips a caller's `visible:false`
525
+ // to true, and NEVER detaches purely because a NON-zero box scrolled
526
+ // off-screen (a non-zero box at a negative/off-screen origin stays
527
+ // `visible:true` with its real, possibly negative-origin, bounds).
528
+ //
529
+ // This increment adds an IntersectionObserver, NOT a RAF — so the existing
530
+ // "never schedules a requestAnimationFrame" guard must stay valid. We assert
531
+ // it here too (`rafSpy`/`cancelSpy` from the shared harness).
532
+ //
533
+ // These tests must be RED today: `guardDisplayNone` is not implemented, so
534
+ // no IntersectionObserver is wired and a 0×0 visible measure still publishes
535
+ // `{ visible:true, bounds:0×0 }`.
536
+
537
+ // A controllable fake IntersectionObserver (jsdom has none). Mirrors the
538
+ // FakeResizeObserver style: records observed elements + disconnect, captures
539
+ // every instance, and exposes `trigger(entries)` to synchronously invoke its
540
+ // callback with a partial IntersectionObserverEntry list (only the fields the
541
+ // guard reads: `isIntersecting` + `boundingClientRect`).
542
+ class FakeIntersectionObserver {
543
+ static instances: FakeIntersectionObserver[] = []
544
+ observed: Element[] = []
545
+ disconnected = false
546
+ constructor(public cb: IntersectionObserverCallback) {
547
+ FakeIntersectionObserver.instances.push(this)
548
+ }
549
+ observe(el: Element): void {
550
+ this.observed.push(el)
551
+ }
552
+ unobserve(): void {
553
+ /* unused */
554
+ }
555
+ disconnect(): void {
556
+ this.disconnected = true
557
+ }
558
+ takeRecords(): IntersectionObserverEntry[] {
559
+ return []
560
+ }
561
+ // Drive the callback synchronously. Tests pass the minimal entry shape the
562
+ // guard inspects; we widen to the full entry type so `cb` typechecks.
563
+ trigger(
564
+ entries: Array<{
565
+ isIntersecting: boolean
566
+ boundingClientRect: { width: number; height: number }
567
+ }>,
568
+ ): void {
569
+ this.cb(
570
+ entries as unknown as IntersectionObserverEntry[],
571
+ this as unknown as IntersectionObserver,
572
+ )
573
+ }
574
+ }
575
+
576
+ describe('createPlacementAnchor — display:none / first-frame guard (opt-in)', () => {
577
+ beforeEach(() => {
578
+ FakeIntersectionObserver.instances = []
579
+ vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
580
+ })
581
+ // (The shared afterEach calls vi.unstubAllGlobals(), which removes the
582
+ // IntersectionObserver stub too — no extra teardown needed here.)
583
+
584
+ /** Assert an IntersectionObserver was installed, then return it (a missing
585
+ * one surfaces as a clear "expected ≥1, got 0" instead of a later
586
+ * TypeError on `.trigger()`). */
587
+ function firstIO(): FakeIntersectionObserver {
588
+ expect(FakeIntersectionObserver.instances.length).toBeGreaterThanOrEqual(1)
589
+ return FakeIntersectionObserver.instances[0]!
590
+ }
591
+
592
+ const HIDDEN: Placement = { visible: false }
593
+
594
+ // a) first measure 0×0 with the guard ON → detach, not visible+0×0.
595
+ it('a) guardDisplayNone:true + first measure 0×0 → publishes { visible:false } (not visible:true+0×0)', () => {
596
+ const publish = vi.fn<(p: Placement) => void>()
597
+ const { el } = buildElement({ x: 42, y: 99, w: 0, h: 0 })
598
+
599
+ createPlacementAnchor(el, {
600
+ visible: true,
601
+ guardDisplayNone: true,
602
+ publish,
603
+ } as Parameters<typeof createPlacementAnchor>[1])
604
+
605
+ expect(publish).toHaveBeenCalledTimes(1)
606
+ const p = publish.mock.calls[0]![0]
607
+ expect(p).toEqual(HIDDEN)
608
+ expect(p.visible).toBe(false)
609
+ expect('bounds' in p).toBe(false)
610
+ // Must NOT be the legitimate-0×0-visible value the default path emits.
611
+ expect(p).not.toEqual({ visible: true, bounds: { x: 42, y: 99, width: 0, height: 0 } })
612
+ // No RAF introduced by the guard.
613
+ expect(rafSpy).not.toHaveBeenCalled()
614
+ })
615
+
616
+ // b) first measure non-zero box with the guard ON → normal visible publish.
617
+ it('b) guardDisplayNone:true + first measure non-zero box → publishes { visible:true, bounds }', () => {
618
+ const publish = vi.fn<(p: Placement) => void>()
619
+ const { el } = buildElement({ x: 10, y: 20, w: 300, h: 400 })
620
+
621
+ createPlacementAnchor(el, {
622
+ visible: true,
623
+ guardDisplayNone: true,
624
+ publish,
625
+ } as Parameters<typeof createPlacementAnchor>[1])
626
+
627
+ expect(publish).toHaveBeenCalledTimes(1)
628
+ expect(publish).toHaveBeenCalledWith({
629
+ visible: true,
630
+ bounds: { x: 10, y: 20, width: 300, height: 400 },
631
+ })
632
+ // Guard installs an IntersectionObserver on the target.
633
+ expect(firstIO().observed).toContain(el)
634
+ })
635
+
636
+ // c) display:none round-trip: visible → IO display:none → detach → restore → visible.
637
+ it('c) IO display:none transition publishes { visible:false }; a restored non-zero box re-publishes { visible:true, bounds }', () => {
638
+ const publish = vi.fn<(p: Placement) => void>()
639
+ const { el, setRect } = buildElement({ x: 5, y: 6, w: 100, h: 120 })
640
+
641
+ createPlacementAnchor(el, {
642
+ visible: true,
643
+ guardDisplayNone: true,
644
+ publish,
645
+ } as Parameters<typeof createPlacementAnchor>[1])
646
+ // Initial: normal visible publish.
647
+ expect(publish).toHaveBeenLastCalledWith({
648
+ visible: true,
649
+ bounds: { x: 5, y: 6, width: 100, height: 120 },
650
+ })
651
+ publish.mockClear()
652
+
653
+ // Target goes display:none: IO reports not-intersecting + a zero-area box.
654
+ setRect({ x: 0, y: 0, w: 0, h: 0 })
655
+ firstIO().trigger([
656
+ { isIntersecting: false, boundingClientRect: { width: 0, height: 0 } },
657
+ ])
658
+
659
+ expect(publish).toHaveBeenCalledTimes(1)
660
+ expect(publish).toHaveBeenLastCalledWith(HIDDEN)
661
+ publish.mockClear()
662
+
663
+ // Target comes back with a real box. A subsequent tick (ResizeObserver or
664
+ // IO intersecting) re-measures a non-zero box → visible again.
665
+ setRect({ x: 5, y: 6, w: 100, h: 120 })
666
+ firstIO().trigger([
667
+ { isIntersecting: true, boundingClientRect: { width: 100, height: 120 } },
668
+ ])
669
+
670
+ expect(publish).toHaveBeenCalledTimes(1)
671
+ expect(publish).toHaveBeenLastCalledWith({
672
+ visible: true,
673
+ bounds: { x: 5, y: 6, width: 100, height: 120 },
674
+ })
675
+ })
676
+
677
+ // d) caller visible:false wins — IO intersecting must NOT flip it to visible.
678
+ it('d) caller visible:false → detached; an IO "intersecting" tick does NOT flip to visible:true', () => {
679
+ const publish = vi.fn<(p: Placement) => void>()
680
+ const { el } = buildElement({ x: 5, y: 6, w: 100, h: 120 })
681
+
682
+ createPlacementAnchor(el, {
683
+ visible: false,
684
+ guardDisplayNone: true,
685
+ publish,
686
+ } as Parameters<typeof createPlacementAnchor>[1])
687
+
688
+ // Detached on create.
689
+ expect(publish).toHaveBeenCalledTimes(1)
690
+ expect(publish).toHaveBeenLastCalledWith(HIDDEN)
691
+ publish.mockClear()
692
+
693
+ // Even if the guard's IO fires "intersecting" with a real box, caller
694
+ // intent (visible:false) wins — no visible:true is published.
695
+ if (FakeIntersectionObserver.instances.length > 0) {
696
+ FakeIntersectionObserver.instances[0]!.trigger([
697
+ { isIntersecting: true, boundingClientRect: { width: 100, height: 120 } },
698
+ ])
699
+ }
700
+
701
+ expect(publish).not.toHaveBeenCalled()
702
+ })
703
+
704
+ // e) non-zero box at a negative/off-screen origin → visible:true, negative origin preserved.
705
+ it('e) non-zero box at negative origin → { visible:true, bounds } (off-screen ≠ detach; negative origin kept)', () => {
706
+ const publish = vi.fn<(p: Placement) => void>()
707
+ const { el } = buildElement({ x: -50, y: -20, w: 300, h: 200 })
708
+
709
+ createPlacementAnchor(el, {
710
+ visible: true,
711
+ guardDisplayNone: true,
712
+ publish,
713
+ } as Parameters<typeof createPlacementAnchor>[1])
714
+
715
+ expect(publish).toHaveBeenCalledTimes(1)
716
+ expect(publish).toHaveBeenCalledWith({
717
+ visible: true,
718
+ bounds: { x: -50, y: -20, width: 300, height: 200 },
719
+ })
720
+ })
721
+
722
+ // f) default-off regression: WITHOUT the option, 0×0 stays visible:true+0×0.
723
+ it('f) default (no guardDisplayNone): 0×0 measure → { visible:true, bounds:0×0 } unchanged', () => {
724
+ const publish = vi.fn<(p: Placement) => void>()
725
+ const { el } = buildElement({ x: 42, y: 99, w: 0, h: 0 })
726
+
727
+ createPlacementAnchor(el, { visible: true, publish })
728
+
729
+ expect(publish).toHaveBeenCalledTimes(1)
730
+ expect(publish).toHaveBeenCalledWith({
731
+ visible: true,
732
+ bounds: { x: 42, y: 99, width: 0, height: 0 },
733
+ })
734
+ // Default off must NOT install an IntersectionObserver.
735
+ expect(FakeIntersectionObserver.instances).toHaveLength(0)
736
+ })
737
+
738
+ // g) dispose disconnects the IO and no late IO callback publishes.
739
+ it('g) dispose() disconnects the IntersectionObserver and a late IO trigger publishes nothing', () => {
740
+ const publish = vi.fn<(p: Placement) => void>()
741
+ const { el, setRect } = buildElement({ x: 5, y: 6, w: 100, h: 120 })
742
+
743
+ const handle = createPlacementAnchor(el, {
744
+ visible: true,
745
+ guardDisplayNone: true,
746
+ publish,
747
+ } as Parameters<typeof createPlacementAnchor>[1])
748
+ const io = firstIO()
749
+ expect(io.disconnected).toBe(false)
750
+
751
+ handle.dispose()
752
+ expect(io.disconnected).toBe(true)
753
+ publish.mockClear()
754
+
755
+ // A late IO callback after dispose must be inert (disposed read live).
756
+ setRect({ x: 0, y: 0, w: 0, h: 0 })
757
+ io.trigger([
758
+ { isIntersecting: false, boundingClientRect: { width: 0, height: 0 } },
759
+ ])
760
+
761
+ expect(publish).not.toHaveBeenCalled()
762
+ })
763
+ })
764
+
765
+ // ── scroll + windowed RAF geometry sentinel (opt-in, increment 2) ────
766
+ //
767
+ // TDD — FAILING-FIRST tests for ADDITIVE, OPT-IN follow options on
768
+ // `createPlacementAnchor`, derived from view-anchor-following.md
769
+ // §2.C / §2.D / §2.F / §6 / §7. Both default OFF (= today's behaviour).
770
+ //
771
+ // followScroll?: boolean — §2.C ancestor-scroll capture listener.
772
+ // followGeometry?: boolean — §2.D windowed RAF geometry sentinel.
773
+ // pulse(durationMs?) — §2.F imperative "open the sentinel window".
774
+ //
775
+ // ────────────────────────────────────────────────────────────────────
776
+ // GOALPOST NOTE (§7): the package-wide "never RAF" invariant is
777
+ // INTENTIONALLY NARROWED by `followGeometry` (opt-in) to "EVENT-driven
778
+ // publishes (ResizeObserver / window resize / scroll) are never
779
+ // RAF-deferred". The sentinel rAF is a POLLING mechanism that publishes
780
+ // SYNCHRONOUSLY within its own frame — it is NOT a deferral of an
781
+ // event-driven publish (see view-anchor-following.md §7). The existing
782
+ // `createViewAnchor` "never schedules a requestAnimationFrame" test
783
+ // (above) REMAINS VALID and is left UNCHANGED, because `followGeometry`
784
+ // defaults off and the forward `createViewAnchor` core never opts in. The
785
+ // tests below re-pin that the DEFAULT / event-driven paths still schedule
786
+ // NO rAF, so the original guarantee holds wherever the sentinel is off.
787
+ // ────────────────────────────────────────────────────────────────────
788
+ //
789
+ // These tests must be RED today: `followScroll` / `followGeometry` /
790
+ // `pulse` are unimplemented, so no capture scroll listener / pointerdown
791
+ // listener / RAF sentinel exists.
792
+
793
+ // A CONTROLLABLE fake requestAnimationFrame. The shared `rafSpy` only
794
+ // returns 0 and stores nothing (it's a regression guard, not a driver), so
795
+ // the sentinel — which actually schedules frames — needs a queue we can
796
+ // flush one frame at a time. This block installs its own rAF/cancel stubs
797
+ // over the shared ones (the outer afterEach's vi.unstubAllGlobals() clears
798
+ // them, mirroring how the display:none block stubs IntersectionObserver).
799
+ class FakeRaf {
800
+ // Pending callbacks keyed by handle. A frame is "scheduled" while non-empty.
801
+ private cbs = new Map<number, FrameRequestCallback>()
802
+ private nextId = 1
803
+ request = vi.fn((cb: FrameRequestCallback): number => {
804
+ const id = this.nextId++
805
+ this.cbs.set(id, cb)
806
+ return id
807
+ })
808
+ cancel = vi.fn((id: number): void => {
809
+ this.cbs.delete(id)
810
+ })
811
+ /** Drain exactly the callbacks pending at call-time and run each once.
812
+ * A callback that re-requests another frame (the sentinel re-arming for
813
+ * the next poll) is NOT run again this flush — it lands in the next one,
814
+ * so `flushFrame()` advances exactly one frame. */
815
+ flushFrame(ts = 0): void {
816
+ const pending = [...this.cbs.entries()]
817
+ this.cbs.clear()
818
+ for (const [, cb] of pending) cb(ts)
819
+ }
820
+ /** Is a frame currently scheduled (sentinel window still open)? */
821
+ get pending(): number {
822
+ return this.cbs.size
823
+ }
824
+ }
825
+
826
+ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt-in, increment 2)', () => {
827
+ let raf: FakeRaf
828
+
829
+ beforeEach(() => {
830
+ raf = new FakeRaf()
831
+ // Install a controllable rAF over the shared regression-guard `rafSpy`.
832
+ // The outer afterEach's vi.unstubAllGlobals() restores everything.
833
+ vi.stubGlobal(
834
+ 'requestAnimationFrame',
835
+ raf.request as unknown as typeof window.requestAnimationFrame,
836
+ )
837
+ vi.stubGlobal(
838
+ 'cancelAnimationFrame',
839
+ raf.cancel as unknown as typeof window.cancelAnimationFrame,
840
+ )
841
+ })
842
+
843
+ /** Build a `[role="separator"]` splitter element (the drag handle). A
844
+ * capture-phase pointerdown whose target matches `[role="separator"]`
845
+ * opens the sentinel window (§2.D). */
846
+ function buildSplitter(): HTMLElement {
847
+ const sep = document.createElement('div')
848
+ sep.setAttribute('role', 'separator')
849
+ document.body.appendChild(sep)
850
+ return sep
851
+ }
852
+
853
+ /** Dispatch a capture-phase scroll on window (an ancestor scroll container
854
+ * scrolling — §2.C: scroll doesn't bubble, but reaches window in capture). */
855
+ function dispatchCaptureScroll(): void {
856
+ window.dispatchEvent(new Event('scroll'))
857
+ }
858
+
859
+ /** Dispatch a real bubbling+capturable pointerdown originating at `target`
860
+ * (so a window capture-phase listener filtering on `[role="separator"]`
861
+ * sees it). */
862
+ function dispatchPointerdown(target: HTMLElement): void {
863
+ target.dispatchEvent(new Event('pointerdown', { bubbles: true }))
864
+ }
865
+
866
+ /** Pointer release — ends a splitter drag so the sentinel may steady-close
867
+ * (§2.D: 关窗只发生在松手之后). Dispatched bubbling from the target and on
868
+ * window directly so a capture/bubble window listener sees it. */
869
+ function dispatchPointerup(target: HTMLElement): void {
870
+ target.dispatchEvent(new Event('pointerup', { bubbles: true }))
871
+ window.dispatchEvent(new Event('pointerup'))
872
+ }
873
+
874
+ // Cast helpers: the new options/method aren't on the public types yet.
875
+ type FollowOpts = Parameters<typeof createPlacementAnchor>[1] & {
876
+ followScroll?: boolean
877
+ followGeometry?: boolean
878
+ }
879
+ type PulseHandle = ReturnType<typeof createPlacementAnchor> & {
880
+ pulse: (durationMs?: number) => void
881
+ }
882
+ const mk = (
883
+ el: HTMLElement,
884
+ o: { visible: boolean; publish: (p: Placement) => void } & {
885
+ followScroll?: boolean
886
+ followGeometry?: boolean
887
+ },
888
+ ): PulseHandle =>
889
+ createPlacementAnchor(el, o as FollowOpts) as PulseHandle
890
+
891
+ // ── A. followScroll — capture-phase ancestor scroll (§2.C) ──────────
892
+
893
+ // A1: capture scroll re-publishes the freshly-measured rect (basic scroll
894
+ // follow works WITHOUT the geometry sentinel — §2.C: "if followGeometry
895
+ // is false, the scroll callback should still at least do a synchronous
896
+ // emit()").
897
+ it('A1) followScroll (followGeometry off): a capture-phase scroll re-publishes the new measured rect synchronously', () => {
898
+ const publish = vi.fn<(p: Placement) => void>()
899
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
900
+ mk(el, { visible: true, followScroll: true, publish })
901
+ publish.mockClear()
902
+
903
+ // Ancestor scrolled → element's screen rect moved (y changed).
904
+ setRect({ x: 0, y: -40, w: 100, h: 100 })
905
+ dispatchCaptureScroll()
906
+
907
+ expect(publish).toHaveBeenCalledTimes(1)
908
+ expect(publish).toHaveBeenLastCalledWith({
909
+ visible: true,
910
+ bounds: { x: 0, y: -40, width: 100, height: 100 },
911
+ })
912
+ // With followGeometry OFF, scroll-follow is purely synchronous: no rAF.
913
+ expect(raf.request).not.toHaveBeenCalled()
914
+ })
915
+
916
+ // A2: the scroll listener is registered with { capture:true } and removed
917
+ // on dispose. We assert via window.addEventListener / removeEventListener
918
+ // spies (the shared beforeEach already spies addEventListener; we read
919
+ // its recorded calls).
920
+ it('A2) followScroll registers a capture-phase window scroll listener and removes it on dispose', () => {
921
+ const addCalls: Array<[string, unknown, unknown]> = []
922
+ const removeCalls: Array<[string, unknown, unknown]> = []
923
+ const addSpy = vi
924
+ .spyOn(window, 'addEventListener')
925
+ .mockImplementation((type: string, cb: unknown, optsArg?: unknown) => {
926
+ addCalls.push([type, cb, optsArg])
927
+ })
928
+ const removeSpy = vi
929
+ .spyOn(window, 'removeEventListener')
930
+ .mockImplementation((type: string, cb: unknown, optsArg?: unknown) => {
931
+ removeCalls.push([type, cb, optsArg])
932
+ })
933
+
934
+ const publish = vi.fn<(p: Placement) => void>()
935
+ const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
936
+ const handle = mk(el, { visible: true, followScroll: true, publish })
937
+
938
+ const scrollAdd = addCalls.find(([t]) => t === 'scroll')
939
+ expect(scrollAdd, 'a window scroll listener must be registered').toBeDefined()
940
+ // Capture phase — either `true` or `{ capture: true }`.
941
+ const optArg = scrollAdd![2]
942
+ const isCapture =
943
+ optArg === true ||
944
+ (typeof optArg === 'object' &&
945
+ optArg !== null &&
946
+ (optArg as { capture?: boolean }).capture === true)
947
+ expect(isCapture, 'scroll listener must be capture-phase').toBe(true)
948
+
949
+ const scrollCb = scrollAdd![1]
950
+ handle.dispose()
951
+ const scrollRemove = removeCalls.find(
952
+ ([t, cb]) => t === 'scroll' && cb === scrollCb,
953
+ )
954
+ expect(
955
+ scrollRemove,
956
+ 'the same scroll listener must be removed on dispose',
957
+ ).toBeDefined()
958
+
959
+ addSpy.mockRestore()
960
+ removeSpy.mockRestore()
961
+ })
962
+
963
+ // A3: §2.C — a followScroll capture-scroll OPENS the RAF sentinel window
964
+ // when followGeometry is ALSO on (so it follows every frame of a scroll
965
+ // burst, not just the one synchronous emit).
966
+ it('A3) followScroll + followGeometry: a capture scroll opens the RAF sentinel (a frame becomes scheduled)', () => {
967
+ const publish = vi.fn<(p: Placement) => void>()
968
+ const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
969
+ mk(el, {
970
+ visible: true,
971
+ followScroll: true,
972
+ followGeometry: true,
973
+ publish,
974
+ })
975
+ // Idle: no sentinel frame yet.
976
+ expect(raf.request).not.toHaveBeenCalled()
977
+
978
+ dispatchCaptureScroll()
979
+
980
+ // The scroll opened the windowed sentinel → a frame is scheduled.
981
+ expect(raf.request).toHaveBeenCalled()
982
+ expect(raf.pending).toBeGreaterThanOrEqual(1)
983
+ })
984
+
985
+ // ── B. followGeometry — windowed RAF geometry sentinel (§2.D/§6) ─────
986
+
987
+ // B-idle: §6 — IDLE (no scroll / pointerdown / pulse) schedules NO rAF.
988
+ // The sentinel is windowed: static cost is exactly zero.
989
+ it('B-idle) followGeometry on but idle: NO rAF is ever scheduled (windowed = zero static cost)', () => {
990
+ const publish = vi.fn<(p: Placement) => void>()
991
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
992
+ mk(el, { visible: true, followGeometry: true, publish })
993
+
994
+ // Even an ordinary ResizeObserver tick (event-driven) must not open the
995
+ // sentinel — it publishes synchronously instead (see B-sync below).
996
+ setRect({ x: 1, y: 1, w: 100, h: 100 })
997
+ firstObserver().fire()
998
+
999
+ expect(raf.request).not.toHaveBeenCalled()
1000
+ })
1001
+
1002
+ // B-open: a splitter pointerdown (capture, matching [role="separator"])
1003
+ // opens the sentinel when followGeometry:true.
1004
+ it('B-open) a capture-phase pointerdown on a [role="separator"] opens the RAF sentinel', () => {
1005
+ const publish = vi.fn<(p: Placement) => void>()
1006
+ const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
1007
+ mk(el, { visible: true, followGeometry: true, publish })
1008
+ const splitter = buildSplitter()
1009
+ expect(raf.request).not.toHaveBeenCalled()
1010
+
1011
+ dispatchPointerdown(splitter)
1012
+
1013
+ expect(raf.request).toHaveBeenCalled()
1014
+ expect(raf.pending).toBeGreaterThanOrEqual(1)
1015
+ })
1016
+
1017
+ // B-open-nonseparator: a pointerdown NOT on a separator must NOT open it.
1018
+ it('B-open-nonseparator) a pointerdown on a non-separator element does NOT open the sentinel', () => {
1019
+ const publish = vi.fn<(p: Placement) => void>()
1020
+ const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
1021
+ mk(el, { visible: true, followGeometry: true, publish })
1022
+ const plain = document.createElement('div')
1023
+ document.body.appendChild(plain)
1024
+
1025
+ dispatchPointerdown(plain)
1026
+
1027
+ expect(raf.request).not.toHaveBeenCalled()
1028
+ })
1029
+
1030
+ // B-follow: once open, each frame whose measured rect CHANGED publishes the
1031
+ // new rect SYNCHRONOUSLY IN THAT rAF callback (not deferred to a nested
1032
+ // rAF). We assert via rect values + that publish happened during flushFrame
1033
+ // (i.e. exactly one publish per changed frame, not coalesced/deferred).
1034
+ it('B-follow) open sentinel + rect changes each frame → publishes the new rect in-frame, one per changed frame', () => {
1035
+ const publish = vi.fn<(p: Placement) => void>()
1036
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
1037
+ mk(el, { visible: true, followGeometry: true, publish })
1038
+ const splitter = buildSplitter()
1039
+
1040
+ dispatchPointerdown(splitter) // open the window
1041
+ publish.mockClear()
1042
+
1043
+ // Frame 1: rect moved → publish in-frame.
1044
+ setRect({ x: 10, y: 0, w: 100, h: 100 })
1045
+ raf.flushFrame()
1046
+ expect(publish).toHaveBeenCalledTimes(1)
1047
+ expect(publish).toHaveBeenLastCalledWith({
1048
+ visible: true,
1049
+ bounds: { x: 10, y: 0, width: 100, height: 100 },
1050
+ })
1051
+ // The publish happened DURING the rAF callback, not queued for another
1052
+ // frame: after the flush returned, the call count is already 1 (no nested
1053
+ // defer). The sentinel re-arms for the next frame (window still open).
1054
+ expect(raf.request.mock.calls.length).toBeGreaterThanOrEqual(2)
1055
+
1056
+ // Frame 2: moved again → another in-frame publish.
1057
+ setRect({ x: 20, y: 0, w: 100, h: 100 })
1058
+ raf.flushFrame()
1059
+ expect(publish).toHaveBeenCalledTimes(2)
1060
+ expect(publish).toHaveBeenLastCalledWith({
1061
+ visible: true,
1062
+ bounds: { x: 20, y: 0, width: 100, height: 100 },
1063
+ })
1064
+ })
1065
+
1066
+ // B-close: after the pointer is RELEASED, N=2 consecutive UNCHANGED frames
1067
+ // cancel the rAF (steady = stop) — no further frame scheduled. (§2.D: a
1068
+ // steady run while the pointer is still HELD is a mid-drag pause and must
1069
+ // NOT close — see follow-geometry-press-drag.fix.test.ts; close is gated on
1070
+ // pointerup, so this test now releases before going steady.)
1071
+ it('B-close) pointerup then N=2 consecutive unchanged frames → sentinel stops (cancelAnimationFrame / no further frame scheduled)', () => {
1072
+ const publish = vi.fn<(p: Placement) => void>()
1073
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
1074
+ mk(el, { visible: true, followGeometry: true, publish })
1075
+ const splitter = buildSplitter()
1076
+
1077
+ dispatchPointerdown(splitter) // open
1078
+ // One changing frame to prove it's live, then release and go steady.
1079
+ setRect({ x: 30, y: 0, w: 100, h: 100 })
1080
+ raf.flushFrame()
1081
+ expect(raf.pending).toBeGreaterThanOrEqual(1) // still polling
1082
+ dispatchPointerup(splitter) // drag over → steady-close now permitted
1083
+
1084
+ // Steady frame 1 (rect identical to last published) — not yet closed
1085
+ // (N=2 needs TWO consecutive identical frames).
1086
+ raf.flushFrame()
1087
+ // Steady frame 2 — now N=2 consecutive identical → close window.
1088
+ raf.flushFrame()
1089
+
1090
+ // The sentinel cancelled / stopped re-arming: no frame is pending and a
1091
+ // further flush runs nothing (no new publishes, no re-scheduled frame).
1092
+ const requestsBefore = raf.request.mock.calls.length
1093
+ raf.flushFrame()
1094
+ expect(raf.pending).toBe(0)
1095
+ expect(raf.request.mock.calls.length).toBe(requestsBefore)
1096
+ })
1097
+
1098
+ // ── F. pulse() — explicit window open (§2.F) ────────────────────────
1099
+
1100
+ // F-open: pulse() opens the sentinel and it follows subsequent rect changes.
1101
+ it('F-open) pulse() opens the sentinel; it follows rect changes on subsequent frames', () => {
1102
+ const publish = vi.fn<(p: Placement) => void>()
1103
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
1104
+ const handle = mk(el, { visible: true, followGeometry: true, publish })
1105
+ expect(raf.request).not.toHaveBeenCalled()
1106
+
1107
+ handle.pulse()
1108
+ expect(raf.request).toHaveBeenCalled()
1109
+ publish.mockClear()
1110
+
1111
+ // A transform/animation moved the rect (no DOM event) → sentinel catches it.
1112
+ setRect({ x: 7, y: 9, w: 100, h: 100 })
1113
+ raf.flushFrame()
1114
+ expect(publish).toHaveBeenLastCalledWith({
1115
+ visible: true,
1116
+ bounds: { x: 7, y: 9, width: 100, height: 100 },
1117
+ })
1118
+ })
1119
+
1120
+ // F-close: pulse()'d window auto-closes ("durationMs 后或判静止后自动关")
1121
+ // — by steady frames (N=2 identical) here. After close, no frame pending.
1122
+ it('F-close) pulse() window auto-closes after it goes steady (N=2 identical frames)', () => {
1123
+ const publish = vi.fn<(p: Placement) => void>()
1124
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
1125
+ const handle = mk(el, { visible: true, followGeometry: true, publish })
1126
+
1127
+ handle.pulse()
1128
+ // One change, then steady.
1129
+ setRect({ x: 4, y: 4, w: 100, h: 100 })
1130
+ raf.flushFrame()
1131
+ raf.flushFrame() // steady 1
1132
+ raf.flushFrame() // steady 2 → close
1133
+
1134
+ const requestsBefore = raf.request.mock.calls.length
1135
+ raf.flushFrame()
1136
+ expect(raf.pending).toBe(0)
1137
+ expect(raf.request.mock.calls.length).toBe(requestsBefore)
1138
+ })
1139
+
1140
+ // ── §7 goalpost: event-driven publishes stay SYNCHRONOUS even with the
1141
+ // sentinel enabled; the sentinel rAF publishes in-frame, not deferred. ──
1142
+
1143
+ // B-sync: a ResizeObserver tick still publishes SYNCHRONOUSLY (in the event
1144
+ // stack) WITHOUT scheduling a rAF, even with followGeometry enabled. This
1145
+ // is the §7 narrowing made concrete: event-driven ≠ RAF-deferred.
1146
+ it('§7) with followGeometry ENABLED, a ResizeObserver tick publishes synchronously and schedules NO rAF', () => {
1147
+ const publish = vi.fn<(p: Placement) => void>()
1148
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
1149
+ mk(el, { visible: true, followGeometry: true, publish })
1150
+ publish.mockClear()
1151
+
1152
+ setRect({ x: 3, y: 4, w: 100, h: 100 })
1153
+ firstObserver().fire()
1154
+
1155
+ // Published in the synchronous RO tick…
1156
+ expect(publish).toHaveBeenCalledTimes(1)
1157
+ expect(publish).toHaveBeenLastCalledWith({
1158
+ visible: true,
1159
+ bounds: { x: 3, y: 4, width: 100, height: 100 },
1160
+ })
1161
+ // …and the event-driven path did NOT route through a rAF (the §7 invariant
1162
+ // survives, narrowed: event-driven publishes are never RAF-deferred).
1163
+ expect(raf.request).not.toHaveBeenCalled()
1164
+ })
1165
+
1166
+ // Default-path "still no rAF": with followGeometry OFF / unset, NOTHING in
1167
+ // the increment-2 surface schedules a rAF — the original package-wide
1168
+ // "never RAF" guarantee holds for the default + event-driven paths.
1169
+ it('§7-default) followGeometry OFF (default): RO + resize + scroll-less lifecycle schedules NO rAF', () => {
1170
+ const publish = vi.fn<(p: Placement) => void>()
1171
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
1172
+ const handle = mk(el, { visible: true, publish }) // followGeometry unset
1173
+
1174
+ setRect({ x: 5, y: 6, w: 120, h: 130 })
1175
+ firstObserver().fire()
1176
+ window.dispatchEvent(new Event('resize'))
1177
+ handle.update({ visible: false, publish } as Parameters<
1178
+ typeof createPlacementAnchor
1179
+ >[1])
1180
+
1181
+ expect(raf.request).not.toHaveBeenCalled()
1182
+ expect(raf.cancel).not.toHaveBeenCalled()
1183
+ })
1184
+ })