@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.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/measure-loop.d.ts +43 -0
- package/dist/measure-loop.d.ts.map +1 -0
- package/dist/measure-loop.js +49 -0
- package/dist/react.d.ts +38 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +145 -0
- package/dist/size-advertiser.d.ts +20 -0
- package/dist/size-advertiser.d.ts.map +1 -0
- package/dist/size-advertiser.js +82 -0
- package/dist/types.d.ts +111 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/view-anchor.d.ts +111 -0
- package/dist/view-anchor.d.ts.map +1 -0
- package/dist/view-anchor.js +396 -0
- package/docs/anchor-3d.html +602 -0
- package/docs/bidirectional-design.md +151 -0
- package/docs/mechanism.mdx +96 -0
- package/package.json +76 -0
- package/src/follow-geometry-press-drag.fix.test.ts +255 -0
- package/src/index.ts +39 -0
- package/src/measure-loop.ts +87 -0
- package/src/placement.test.ts +338 -0
- package/src/react.test.tsx +566 -0
- package/src/react.ts +171 -0
- package/src/size-advertiser.extras.test.ts +294 -0
- package/src/size-advertiser.test.ts +444 -0
- package/src/size-advertiser.ts +94 -0
- package/src/types.ts +123 -0
- package/src/view-anchor.test.ts +1184 -0
- package/src/view-anchor.ts +486 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal — the RAF-coalesced measure/dedupe/dispose engine behind the REVERSE
|
|
3
|
+
* primitive `createSizeAdvertiser`.
|
|
4
|
+
*
|
|
5
|
+
* The forward `createViewAnchor` deliberately does NOT use this: it publishes
|
|
6
|
+
* SYNCHRONOUSLY (a native overlay's `setBounds` already lands a cross-process
|
|
7
|
+
* frame late, and a RAF stacked a second frame of visible trailing). The
|
|
8
|
+
* reverse direction is different — it is a cross-process FEEDBACK loop
|
|
9
|
+
* (advertise → host resizes the view → content re-measures → re-advertise), so
|
|
10
|
+
* the RAF's one-publish-per-frame coalescing is a useful damper. The two
|
|
11
|
+
* directions thus have different optimal emit timing; this engine serves only
|
|
12
|
+
* the reverse.
|
|
13
|
+
*
|
|
14
|
+
* NOT exported from the package: it is pure mechanism with no knowledge of
|
|
15
|
+
* direction, the DOM, `ResizeObserver`, or the structure of the value `T` it
|
|
16
|
+
* carries. The wrapping primitive injects `produce` / `same` / `sink` and
|
|
17
|
+
* drives the lifecycle.
|
|
18
|
+
*
|
|
19
|
+
* - `schedule()` — coalesce a burst of triggers into ONE RAF; the frame
|
|
20
|
+
* body re-`produce()`s, dedupes against the last emit (`same`), and `sink`s.
|
|
21
|
+
* Bails if inactive or disposed (stale-RAF safe).
|
|
22
|
+
* - `emitNow(v)` — explicit synchronous emit (create / update path). Always
|
|
23
|
+
* fires, bypassing the dedupe check, and refreshes the dedupe baseline.
|
|
24
|
+
* - `setActive` — gate the observer stream; a queued frame bails on `!active`.
|
|
25
|
+
* - `cancel` — drop any in-flight RAF.
|
|
26
|
+
* - `dispose` — cancel + go inert; after dispose nothing emits again.
|
|
27
|
+
*/
|
|
28
|
+
export interface MeasureLoop<T> {
|
|
29
|
+
schedule(): void
|
|
30
|
+
emitNow(value: T): void
|
|
31
|
+
setActive(on: boolean): void
|
|
32
|
+
cancel(): void
|
|
33
|
+
dispose(): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createMeasureLoop<T>(cfg: {
|
|
37
|
+
/** Produce the value to emit in the RAF body. Return `null` to decline the
|
|
38
|
+
* frame entirely (no dedupe, no sink, baseline untouched) — e.g. a
|
|
39
|
+
* non-finite or unavailable measurement. */
|
|
40
|
+
produce: () => T | null
|
|
41
|
+
same: (a: T, b: T) => boolean
|
|
42
|
+
sink: (value: T) => void
|
|
43
|
+
}): MeasureLoop<T> {
|
|
44
|
+
const { produce, same, sink } = cfg
|
|
45
|
+
let rafId: number | null = null
|
|
46
|
+
let active = false
|
|
47
|
+
let disposed = false
|
|
48
|
+
let last: T | null = null
|
|
49
|
+
|
|
50
|
+
const cancel = (): void => {
|
|
51
|
+
if (rafId !== null) {
|
|
52
|
+
cancelAnimationFrame(rafId)
|
|
53
|
+
rafId = null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
schedule(): void {
|
|
59
|
+
if (disposed || !active || rafId !== null) return
|
|
60
|
+
rafId = requestAnimationFrame(() => {
|
|
61
|
+
rafId = null
|
|
62
|
+
if (disposed || !active) return
|
|
63
|
+
const value = produce()
|
|
64
|
+
if (value === null) return // producer declined this frame
|
|
65
|
+
// last-value dedupe: a frame whose produced value equals the last one
|
|
66
|
+
// we emitted costs nothing (no IPC / setBounds).
|
|
67
|
+
if (last !== null && same(value, last)) return
|
|
68
|
+
last = value
|
|
69
|
+
sink(value)
|
|
70
|
+
})
|
|
71
|
+
},
|
|
72
|
+
emitNow(value: T): void {
|
|
73
|
+
if (disposed) return
|
|
74
|
+
last = value
|
|
75
|
+
sink(value)
|
|
76
|
+
},
|
|
77
|
+
setActive(on: boolean): void {
|
|
78
|
+
active = on
|
|
79
|
+
},
|
|
80
|
+
cancel,
|
|
81
|
+
dispose(): void {
|
|
82
|
+
if (disposed) return
|
|
83
|
+
disposed = true
|
|
84
|
+
cancel()
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
4
|
+
// TDD — FAILING tests for the *not-yet-implemented* explicit `Placement`
|
|
5
|
+
// API. The current package encodes "hidden" as the magic geometric value
|
|
6
|
+
// `{x:0,y:0,width:0,height:0}` (the `present:false → ZERO bounds`
|
|
7
|
+
// convention in types.ts/view-anchor.ts). That leaks a compositor/host
|
|
8
|
+
// concept ("detach the view") into the geometry layer, and is ambiguous:
|
|
9
|
+
// a view that is genuinely 0-wide is indistinguishable from a hidden one.
|
|
10
|
+
//
|
|
11
|
+
// This file pins the REPLACEMENT contract:
|
|
12
|
+
//
|
|
13
|
+
// type Placement =
|
|
14
|
+
// | { visible: true; bounds: Bounds }
|
|
15
|
+
// | { visible: false }
|
|
16
|
+
//
|
|
17
|
+
// measure produces a `Placement`; the sink consumes a `Placement`.
|
|
18
|
+
// Visibility is EXPLICIT (a discriminant), never inferred from a 0.
|
|
19
|
+
//
|
|
20
|
+
// These tests must be RED today: the symbols they import/exercise
|
|
21
|
+
// (`measurePlacement`, `createPlacementAnchor`, the `Placement` type) do
|
|
22
|
+
// not exist yet. We import them dynamically so the failure surfaces as a
|
|
23
|
+
// runtime/assertion failure (a missing export is `undefined`, and calling
|
|
24
|
+
// it throws) rather than a whole-file compile error that vitest would skip.
|
|
25
|
+
// The implementer makes them GREEN by adding the explicit API — WITHOUT
|
|
26
|
+
// reintroducing any ZERO-bounds path for "hidden".
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
import type { Bounds } from './types.js'
|
|
30
|
+
|
|
31
|
+
// The explicit API is imported through this indirection so that a not-yet-
|
|
32
|
+
// existing export is observable as `undefined` at runtime (→ a failing
|
|
33
|
+
// assertion / TypeError), not a static "module has no exported member"
|
|
34
|
+
// compile error that would prevent the whole suite from running.
|
|
35
|
+
import * as viewAnchorModule from './view-anchor.js'
|
|
36
|
+
|
|
37
|
+
// The shape the explicit API must produce/consume. Mirrored locally (not
|
|
38
|
+
// imported) so these tests describe the *target* contract independently of
|
|
39
|
+
// whether the `Placement` type already exists in types.ts.
|
|
40
|
+
type ExpectedPlacement =
|
|
41
|
+
| { visible: true; bounds: Bounds }
|
|
42
|
+
| { visible: false }
|
|
43
|
+
|
|
44
|
+
// ── ResizeObserver stub (same style as view-anchor.test.ts) ──────────
|
|
45
|
+
|
|
46
|
+
class FakeResizeObserver {
|
|
47
|
+
static instances: FakeResizeObserver[] = []
|
|
48
|
+
observed: Element[] = []
|
|
49
|
+
disconnected = false
|
|
50
|
+
constructor(public cb: ResizeObserverCallback) {
|
|
51
|
+
FakeResizeObserver.instances.push(this)
|
|
52
|
+
}
|
|
53
|
+
observe(el: Element): void {
|
|
54
|
+
this.observed.push(el)
|
|
55
|
+
}
|
|
56
|
+
unobserve(): void {
|
|
57
|
+
/* unused */
|
|
58
|
+
}
|
|
59
|
+
disconnect(): void {
|
|
60
|
+
this.disconnected = true
|
|
61
|
+
}
|
|
62
|
+
fire(): void {
|
|
63
|
+
this.cb([], this)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let leakedResize: unknown[] = []
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
FakeResizeObserver.instances = []
|
|
71
|
+
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
|
|
72
|
+
|
|
73
|
+
const realAdd = window.addEventListener.bind(window)
|
|
74
|
+
const realRemove = window.removeEventListener.bind(window)
|
|
75
|
+
vi.spyOn(window, 'addEventListener').mockImplementation(
|
|
76
|
+
(type: string, ...rest: unknown[]) => {
|
|
77
|
+
if (type === 'resize') leakedResize.push(rest[0])
|
|
78
|
+
return (realAdd as unknown as (...a: unknown[]) => void)(type, ...rest)
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
vi.spyOn(window, 'removeEventListener').mockImplementation(
|
|
82
|
+
(type: string, ...rest: unknown[]) => {
|
|
83
|
+
if (type === 'resize')
|
|
84
|
+
leakedResize = leakedResize.filter((h) => h !== rest[0])
|
|
85
|
+
return (realRemove as unknown as (...a: unknown[]) => void)(type, ...rest)
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
leakedResize.forEach((h) =>
|
|
92
|
+
window.removeEventListener('resize', h as EventListener),
|
|
93
|
+
)
|
|
94
|
+
leakedResize = []
|
|
95
|
+
vi.restoreAllMocks()
|
|
96
|
+
vi.unstubAllGlobals()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// ── Element fixture (jsdom getBoundingClientRect returns zeros) ───────
|
|
100
|
+
|
|
101
|
+
function buildElement(rect: {
|
|
102
|
+
x: number
|
|
103
|
+
y: number
|
|
104
|
+
w: number
|
|
105
|
+
h: number
|
|
106
|
+
}): { el: HTMLElement; setRect: (next: typeof rect) => void } {
|
|
107
|
+
const el = document.createElement('div')
|
|
108
|
+
let current = rect
|
|
109
|
+
vi.spyOn(el, 'getBoundingClientRect').mockImplementation(
|
|
110
|
+
() =>
|
|
111
|
+
({
|
|
112
|
+
x: current.x,
|
|
113
|
+
y: current.y,
|
|
114
|
+
left: current.x,
|
|
115
|
+
top: current.y,
|
|
116
|
+
right: current.x + current.w,
|
|
117
|
+
bottom: current.y + current.h,
|
|
118
|
+
width: current.w,
|
|
119
|
+
height: current.h,
|
|
120
|
+
toJSON: () => ({}),
|
|
121
|
+
}) as DOMRect,
|
|
122
|
+
)
|
|
123
|
+
return {
|
|
124
|
+
el,
|
|
125
|
+
setRect(next) {
|
|
126
|
+
current = next
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Resolve the (not-yet-existing) explicit API at call time ─────────
|
|
132
|
+
//
|
|
133
|
+
// Each returns `undefined` until the implementer adds the export. The
|
|
134
|
+
// tests below call them, so an absent export throws ("not a function") —
|
|
135
|
+
// a RED behavioural failure, which is what TDD wants here.
|
|
136
|
+
|
|
137
|
+
interface MaybeModule {
|
|
138
|
+
// measurePlacement(target): Placement — pure measure, no IPC.
|
|
139
|
+
measurePlacement?: (target: HTMLElement) => ExpectedPlacement
|
|
140
|
+
// createPlacementAnchor(target, { visible, publish }): handle whose
|
|
141
|
+
// `publish` sink receives an explicit Placement (not bare Bounds).
|
|
142
|
+
createPlacementAnchor?: (
|
|
143
|
+
target: HTMLElement,
|
|
144
|
+
opts: {
|
|
145
|
+
visible: boolean
|
|
146
|
+
publish: (placement: ExpectedPlacement) => void
|
|
147
|
+
},
|
|
148
|
+
) => { update(opts: unknown): void; dispose(): void }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const mod = viewAnchorModule as MaybeModule
|
|
152
|
+
|
|
153
|
+
function measurePlacement(target: HTMLElement): ExpectedPlacement {
|
|
154
|
+
const fn = mod.measurePlacement
|
|
155
|
+
expect(
|
|
156
|
+
typeof fn,
|
|
157
|
+
'explicit API `measurePlacement` is not implemented yet (expected a function export from view-anchor)',
|
|
158
|
+
).toBe('function')
|
|
159
|
+
return fn!(target)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function createPlacementAnchor(
|
|
163
|
+
target: HTMLElement,
|
|
164
|
+
opts: {
|
|
165
|
+
visible: boolean
|
|
166
|
+
publish: (placement: ExpectedPlacement) => void
|
|
167
|
+
},
|
|
168
|
+
): { update(opts: unknown): void; dispose(): void } {
|
|
169
|
+
const fn = mod.createPlacementAnchor
|
|
170
|
+
expect(
|
|
171
|
+
typeof fn,
|
|
172
|
+
'explicit API `createPlacementAnchor` is not implemented yet (expected a function export from view-anchor)',
|
|
173
|
+
).toBe('function')
|
|
174
|
+
return fn!(target, opts)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A bare ZERO bounds — the magic value the OLD API uses for "hidden". The
|
|
178
|
+
// whole point of the explicit Placement is that "hidden" is NEVER this.
|
|
179
|
+
const ZERO_BOUNDS: Bounds = { x: 0, y: 0, width: 0, height: 0 }
|
|
180
|
+
|
|
181
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
182
|
+
// Behaviour 1 — a present/visible anchor measures to a visible Placement
|
|
183
|
+
// carrying the ACTUAL rect.
|
|
184
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
describe('Placement — visible anchor measures to { visible:true, bounds }', () => {
|
|
187
|
+
it('measurePlacement returns visible:true with the real (rounded/clamped) rect', () => {
|
|
188
|
+
const { el } = buildElement({ x: 10, y: 20, w: 300, h: 400 })
|
|
189
|
+
|
|
190
|
+
const p = measurePlacement(el)
|
|
191
|
+
|
|
192
|
+
expect(p.visible).toBe(true)
|
|
193
|
+
// Discriminated union: bounds only exists on the visible branch.
|
|
194
|
+
expect((p as { bounds: Bounds }).bounds).toEqual({
|
|
195
|
+
x: 10,
|
|
196
|
+
y: 20,
|
|
197
|
+
width: 300,
|
|
198
|
+
height: 400,
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
204
|
+
// Behaviour 2 — a hidden/absent anchor measures to { visible:false } and
|
|
205
|
+
// carries NO bounds at all. It must NOT be `{visible:true, bounds:ZERO}`
|
|
206
|
+
// and must NOT be a bare ZERO bounds.
|
|
207
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
describe('Placement — hidden anchor measures to { visible:false } (no bounds)', () => {
|
|
210
|
+
it('a hidden measure is { visible:false } and never a zero-bounds rect', () => {
|
|
211
|
+
// A "hidden" target. The explicit API decides hiddenness from the
|
|
212
|
+
// `visible:false` request, NOT from a measured 0 — so we pass the
|
|
213
|
+
// request through the anchor and capture what the sink receives.
|
|
214
|
+
const { el } = buildElement({ x: 0, y: 0, w: 0, h: 0 })
|
|
215
|
+
const publish = vi.fn<(p: ExpectedPlacement) => void>()
|
|
216
|
+
|
|
217
|
+
createPlacementAnchor(el, { visible: false, publish })
|
|
218
|
+
|
|
219
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
220
|
+
const p = publish.mock.calls[0]![0]
|
|
221
|
+
|
|
222
|
+
expect(p.visible).toBe(false)
|
|
223
|
+
// The hidden Placement must NOT carry a bounds field at all…
|
|
224
|
+
expect('bounds' in p).toBe(false)
|
|
225
|
+
// …and must NOT be (structurally) the magic ZERO bounds the old API used.
|
|
226
|
+
expect(p).not.toEqual({ visible: true, bounds: ZERO_BOUNDS })
|
|
227
|
+
expect(p).not.toEqual(ZERO_BOUNDS)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
232
|
+
// Behaviour 3 — the SINK receives an explicit Placement: a consumer must
|
|
233
|
+
// be able to decide "hidden" purely from `placement.visible`, WITHOUT
|
|
234
|
+
// ever inspecting `width === 0`.
|
|
235
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
describe('Placement — sink consumes explicit visibility, not width===0', () => {
|
|
238
|
+
it('the hidden Placement is recognisable via .visible with no geometry inspection', () => {
|
|
239
|
+
const { el } = buildElement({ x: 5, y: 6, w: 100, h: 100 })
|
|
240
|
+
const received: ExpectedPlacement[] = []
|
|
241
|
+
const handle = createPlacementAnchor(el, {
|
|
242
|
+
visible: true,
|
|
243
|
+
publish: (p) => received.push(p),
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// Flip to hidden via the documented update path.
|
|
247
|
+
handle.update({ visible: false, publish: (p: ExpectedPlacement) => received.push(p) })
|
|
248
|
+
|
|
249
|
+
const last = received.at(-1)!
|
|
250
|
+
// A consumer's hidden-check: discriminant only. This is the contract —
|
|
251
|
+
// no `width === 0` anywhere.
|
|
252
|
+
const isHidden = (p: ExpectedPlacement): boolean => p.visible === false
|
|
253
|
+
expect(isHidden(last)).toBe(true)
|
|
254
|
+
// Prove the consumer did NOT need geometry: the hidden Placement has no
|
|
255
|
+
// `bounds`, so any width-based check would throw / be undefined.
|
|
256
|
+
expect((last as { bounds?: Bounds }).bounds).toBeUndefined()
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
261
|
+
// Behaviour 4 — THE CORE BUG. A genuinely zero-SIZED but VISIBLE anchor
|
|
262
|
+
// (width===0, height===0, yet on-screen) must be DISTINGUISHABLE from a
|
|
263
|
+
// hidden one. Under the old ZERO-bounds convention both collapse to
|
|
264
|
+
// {0,0,0,0} and are indistinguishable. The explicit Placement resolves
|
|
265
|
+
// the ambiguity: real-but-zero → { visible:true, bounds:{...,width:0} };
|
|
266
|
+
// hidden → { visible:false }.
|
|
267
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
describe('Placement — real 0-size is distinguishable from hidden (kills the magic 0)', () => {
|
|
270
|
+
it('a visible 0×0 rect is { visible:true, bounds: zeros } — NOT { visible:false }', () => {
|
|
271
|
+
// A legitimately collapsed-but-present element (e.g. a panel mid-drag
|
|
272
|
+
// that measured to 0 width this frame). It is VISIBLE, just zero-sized.
|
|
273
|
+
const { el } = buildElement({ x: 42, y: 99, w: 0, h: 0 })
|
|
274
|
+
|
|
275
|
+
const p = measurePlacement(el)
|
|
276
|
+
|
|
277
|
+
// It is VISIBLE — the geometry happens to be zero, but presence is not
|
|
278
|
+
// inferred from the geometry.
|
|
279
|
+
expect(p.visible).toBe(true)
|
|
280
|
+
expect((p as { bounds: Bounds }).bounds).toEqual({
|
|
281
|
+
x: 42,
|
|
282
|
+
y: 99,
|
|
283
|
+
width: 0,
|
|
284
|
+
height: 0,
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('visible-0x0 and hidden are NOT structurally equal (the disambiguation)', () => {
|
|
289
|
+
const { el: visibleZeroEl } = buildElement({ x: 42, y: 99, w: 0, h: 0 })
|
|
290
|
+
const { el: hiddenEl } = buildElement({ x: 0, y: 0, w: 0, h: 0 })
|
|
291
|
+
|
|
292
|
+
const visibleZero = measurePlacement(visibleZeroEl)
|
|
293
|
+
|
|
294
|
+
const hiddenReceived = vi.fn<(p: ExpectedPlacement) => void>()
|
|
295
|
+
createPlacementAnchor(hiddenEl, { visible: false, publish: hiddenReceived })
|
|
296
|
+
const hidden = hiddenReceived.mock.calls[0]![0]
|
|
297
|
+
|
|
298
|
+
// The whole reason this refactor exists: these two states were the SAME
|
|
299
|
+
// value ({0,0,0,0}) under the old API. Now they must differ.
|
|
300
|
+
expect(visibleZero).not.toEqual(hidden)
|
|
301
|
+
expect(visibleZero.visible).toBe(true)
|
|
302
|
+
expect(hidden.visible).toBe(false)
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
307
|
+
// Behaviour 5 — REGRESSION FENCE: under the explicit API, "hidden" is
|
|
308
|
+
// NEVER expressed as a ZERO bounds. No code path that publishes a hidden
|
|
309
|
+
// Placement may ever hand the sink a `{x:0,y:0,width:0,height:0}` (the
|
|
310
|
+
// old magic value). This is the guard that the migration didn't leave a
|
|
311
|
+
// ZERO-based shortcut behind.
|
|
312
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
describe('Placement — hidden is NEVER a ZERO bounds (regression fence)', () => {
|
|
315
|
+
it('every hidden publish is { visible:false }, never a ZERO rect, across update flips', () => {
|
|
316
|
+
const { el, setRect } = buildElement({ x: 1, y: 2, w: 50, h: 60 })
|
|
317
|
+
const received: ExpectedPlacement[] = []
|
|
318
|
+
const publish = (p: ExpectedPlacement): void => {
|
|
319
|
+
received.push(p)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const handle = createPlacementAnchor(el, { visible: true, publish })
|
|
323
|
+
// visible → hidden → visible → hidden, exercising both directions.
|
|
324
|
+
handle.update({ visible: false, publish })
|
|
325
|
+
setRect({ x: 9, y: 9, w: 70, h: 80 })
|
|
326
|
+
handle.update({ visible: true, publish })
|
|
327
|
+
handle.update({ visible: false, publish })
|
|
328
|
+
|
|
329
|
+
const hiddenPublishes = received.filter((p) => p.visible === false)
|
|
330
|
+
expect(hiddenPublishes.length).toBeGreaterThanOrEqual(2)
|
|
331
|
+
for (const p of hiddenPublishes) {
|
|
332
|
+
// No hidden Placement may be (or contain) the magic ZERO bounds.
|
|
333
|
+
expect(p).not.toEqual(ZERO_BOUNDS)
|
|
334
|
+
expect(p).not.toEqual({ visible: true, bounds: ZERO_BOUNDS })
|
|
335
|
+
expect('bounds' in p).toBe(false)
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
})
|