diff --git a/src/utils/scroll-edge-state-broad-selection.test.ts b/src/utils/scroll-edge-state-broad-selection.test.ts new file mode 100644 index 0000000000..119bbc21d9 --- /dev/null +++ b/src/utils/scroll-edge-state-broad-selection.test.ts @@ -0,0 +1,62 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { capture, scrollNode, windowRoot } from './scroll-edge-state-fixtures.ts'; + +// --------------------------------------------------------------------------- +// selectScrollContainer: broad selection (no target), multiple scrollables +// --------------------------------------------------------------------------- + +test('selectScrollContainer (broad): among containers with a hidden edge, the largest one wins', async () => { + const nodes = [ + windowRoot(), + scrollNode(1, { + identifier: 'small-hidden', + hiddenContentBelow: true, + rect: { x: 0, y: 0, width: 100, height: 100 }, + }), + scrollNode(2, { + identifier: 'large-hidden', + hiddenContentBelow: true, + rect: { x: 0, y: 200, width: 300, height: 300 }, + }), + ]; + const state = await capture(nodes, 'bottom'); + assert.equal(state.scope, 'large-hidden'); +}); + +test('selectScrollContainer (broad): with no hidden edge anywhere, the LARGEST visible-in-viewport container wins, not just the first visible one', async () => { + // Declaration order deliberately disagrees with area order (visible-small is + // declared first) so a sort-less "first visible" implementation would pick + // the wrong one; offscreen-huge is bigger still but must be excluded by the + // visibility filter entirely. + const nodes = [ + windowRoot(), + scrollNode(1, { identifier: 'visible-small', rect: { x: 0, y: 0, width: 50, height: 50 } }), + scrollNode(2, { identifier: 'visible-large', rect: { x: 0, y: 0, width: 200, height: 200 } }), + scrollNode(3, { + identifier: 'offscreen-huge', + rect: { x: -5000, y: 0, width: 1000, height: 1000 }, + }), + ]; + const state = await capture(nodes, 'bottom'); + assert.equal(state.canScroll, false); + assert.equal(state.scope, 'visible-large'); +}); + +test('selectScrollContainer (broad): when nothing is visible, the LARGEST scrollable overall is chosen regardless of declaration order', async () => { + // offscreen-small is declared first, offscreen-large second — a sort-less + // "first" fallback would wrongly pick offscreen-small. + const nodes = [ + windowRoot(), + scrollNode(1, { + identifier: 'offscreen-small', + rect: { x: -2000, y: 0, width: 100, height: 100 }, + }), + scrollNode(2, { + identifier: 'offscreen-large', + rect: { x: -1000, y: 0, width: 300, height: 300 }, + }), + ]; + const state = await capture(nodes, 'bottom'); + assert.equal(state.scope, 'offscreen-large'); +}); diff --git a/src/utils/scroll-edge-state-container-existence.test.ts b/src/utils/scroll-edge-state-container-existence.test.ts new file mode 100644 index 0000000000..65afe11564 --- /dev/null +++ b/src/utils/scroll-edge-state-container-existence.test.ts @@ -0,0 +1,51 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { capture, scrollNode, windowRoot } from './scroll-edge-state-fixtures.ts'; + +// --------------------------------------------------------------------------- +// analyzeScrollEdgeState (private) exercised through captureScrollEdgeState: +// empty snapshot / no scrollable container +// --------------------------------------------------------------------------- + +test('captureScrollEdgeState: empty node list reports emptySnapshot and cannot scroll', async () => { + const state = await capture([]); + assert.deepEqual(state, { canScroll: false, emptySnapshot: true }); +}); + +test('captureScrollEdgeState: no scrollable node anywhere yields a null container', async () => { + const nodes = [ + windowRoot(), + { + ref: 'e2', + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Tap me', + rect: { x: 20, y: 40, width: 100, height: 40 }, + }, + ]; + const state = await capture(nodes); + assert.equal(state.canScroll, false); + assert.equal(state.emptySnapshot, false); + assert.equal(state.scope, undefined); +}); + +test('captureScrollEdgeState: a scrollable node with a zero-width rect does not count as a container', async () => { + const nodes = [ + windowRoot(), + scrollNode(1, { hiddenContentBelow: true, rect: { x: 0, y: 100, width: 0, height: 600 } }), + ]; + const state = await capture(nodes); + assert.equal(state.canScroll, false); + assert.equal(state.scope, undefined); +}); + +test('captureScrollEdgeState: a scrollable node with a zero-height rect does not count as a container', async () => { + const nodes = [ + windowRoot(), + scrollNode(1, { hiddenContentBelow: true, rect: { x: 0, y: 100, width: 400, height: 0 } }), + ]; + const state = await capture(nodes); + assert.equal(state.canScroll, false); + assert.equal(state.scope, undefined); +}); diff --git a/src/utils/scroll-edge-state-fixtures.ts b/src/utils/scroll-edge-state-fixtures.ts new file mode 100644 index 0000000000..b29cca18ed --- /dev/null +++ b/src/utils/scroll-edge-state-fixtures.ts @@ -0,0 +1,85 @@ +import { + captureScrollEdgeState, + type ScrollEdgeState, + type ScrollEdgeTarget, +} from './scroll-edge-state.ts'; +import { AppError } from '../kernel/errors.ts'; +import type { RawSnapshotNode, SnapshotNode } from '../kernel/snapshot.ts'; + +export function windowRoot(): SnapshotNode { + return { + ref: 'e1', + index: 0, + depth: 0, + type: 'Window', + rect: { x: 0, y: 0, width: 400, height: 800 }, + }; +} + +export async function capture( + nodes: readonly (RawSnapshotNode | SnapshotNode)[], + edge: 'top' | 'bottom' = 'bottom', + target?: ScrollEdgeTarget, +): Promise { + return captureScrollEdgeState({ + edge, + target, + captureNodes: async () => nodes, + }); +} + +/** A `ScrollView` node with the common defaults (top-level, full-width rect) this suite reuses. */ +export function scrollNode(index: number, overrides: Partial = {}): SnapshotNode { + return { + ref: `e${index + 1}`, + index, + parentIndex: 0, + type: 'ScrollView', + rect: { x: 0, y: 100, width: 400, height: 600 }, + ...overrides, + }; +} + +export async function scopeFor(node: Partial): Promise { + const nodes = [windowRoot(), scrollNode(1, { hiddenContentBelow: true, ...node })]; + return (await capture(nodes, 'bottom')).scope; +} + +export function scrollSnapshot(hiddenContentBelow: boolean): SnapshotNode[] { + return [ + { + index: 1, + ref: 'e1', + type: 'ScrollView', + label: 'Messages', + hiddenContentBelow: hiddenContentBelow ? true : undefined, + rect: { x: 0, y: 100, width: 400, height: 600 }, + }, + { + index: 2, + ref: 'e2', + parentIndex: 1, + type: 'Button', + label: hiddenContentBelow ? 'Middle message' : 'Latest message', + rect: { x: 0, y: 640, width: 400, height: 56 }, + }, + ]; +} + +export async function captureThrows(scope: string | undefined): Promise { + try { + await captureScrollEdgeState({ + edge: 'bottom', + scope, + captureNodes: async () => { + throw new Error('boom'); + }, + }); + throw new Error('expected captureScrollEdgeState to reject'); + } catch (error) { + if (error instanceof AppError) { + return error; + } + throw error; + } +} diff --git a/src/utils/scroll-edge-state-hidden-edge.test.ts b/src/utils/scroll-edge-state-hidden-edge.test.ts new file mode 100644 index 0000000000..cb12011fc6 --- /dev/null +++ b/src/utils/scroll-edge-state-hidden-edge.test.ts @@ -0,0 +1,70 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { capture, scrollNode, windowRoot } from './scroll-edge-state-fixtures.ts'; + +// --------------------------------------------------------------------------- +// hasHiddenContentAtEdge: node-level flags, both edges +// --------------------------------------------------------------------------- + +test('captureScrollEdgeState: single container with hidden content below reports canScroll for bottom edge', async () => { + const nodes = [windowRoot(), scrollNode(1, { hiddenContentBelow: true })]; + const state = await capture(nodes, 'bottom'); + assert.equal(state.canScroll, true); +}); + +test('captureScrollEdgeState: single container with hidden content above reports canScroll for top edge', async () => { + const nodes = [windowRoot(), scrollNode(1, { hiddenContentAbove: true })]; + const state = await capture(nodes, 'top'); + assert.equal(state.canScroll, true); +}); + +test('captureScrollEdgeState: hidden content below does not satisfy a top-edge query', async () => { + const nodes = [windowRoot(), scrollNode(1, { hiddenContentBelow: true })]; + const state = await capture(nodes, 'top'); + assert.equal(state.canScroll, false); + // scope is still populated even though this edge cannot scroll — the container was found. +}); + +test('captureScrollEdgeState: container with neither hidden edge cannot scroll either direction', async () => { + const nodes = [windowRoot(), scrollNode(1)]; + assert.equal((await capture(nodes, 'bottom')).canScroll, false); + assert.equal((await capture(nodes, 'top')).canScroll, false); +}); + +test('captureScrollEdgeState: hidden-content hint derived from an off-screen child (no node-level flag) also enables canScroll', async () => { + // Container itself carries no hiddenContentBelow flag; an off-screen child below it + // drives deriveMobileSnapshotHiddenContentHints to synthesize the hint. + const nodes = [ + windowRoot(), + scrollNode(1, { label: 'Feed' }), + { + ref: 'e3', + index: 2, + parentIndex: 1, + type: 'StaticText', + label: 'Below the fold', + rect: { x: 20, y: 750, width: 300, height: 40 }, + }, + ]; + const state = await capture(nodes, 'bottom'); + assert.equal(state.canScroll, true); +}); + +test('captureScrollEdgeState: hidden-content hint derived from an off-screen child (top edge) also enables canScroll', async () => { + // Mirror of the bottom-edge hint test above: the container itself carries no + // hiddenContentAbove flag; an off-screen child ABOVE it drives the hint instead. + const nodes = [ + windowRoot(), + scrollNode(1, { label: 'Feed' }), + { + ref: 'e3', + index: 2, + parentIndex: 1, + type: 'StaticText', + label: 'Above the fold', + rect: { x: 20, y: 20, width: 300, height: 40 }, + }, + ]; + const state = await capture(nodes, 'top'); + assert.equal(state.canScroll, true); +}); diff --git a/src/utils/scroll-edge-state-pass-orchestration.test.ts b/src/utils/scroll-edge-state-pass-orchestration.test.ts new file mode 100644 index 0000000000..b74fbe4988 --- /dev/null +++ b/src/utils/scroll-edge-state-pass-orchestration.test.ts @@ -0,0 +1,311 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + captureScrollEdgeState, + formatScrollEdgeMessage, + runScrollEdgePasses, +} from './scroll-edge-state.ts'; +import { AppError } from '../kernel/errors.ts'; +import { captureThrows, scrollSnapshot, windowRoot } from './scroll-edge-state-fixtures.ts'; + +// --------------------------------------------------------------------------- +// formatScrollEdgeMessage — pure formatter, 5 mutually exclusive branches +// --------------------------------------------------------------------------- + +test('formatScrollEdgeMessage: edge reached with zero passes reports already-at-edge (bottom)', () => { + assert.equal( + formatScrollEdgeMessage('down', 'bottom', 0, undefined, undefined), + 'Already at bottom; no hidden content below detected', + ); +}); + +test('formatScrollEdgeMessage: edge reached with zero passes reports already-at-edge (top)', () => { + assert.equal( + formatScrollEdgeMessage('up', 'top', 0, undefined, undefined), + 'Already at top; no hidden content above detected', + ); +}); + +test('formatScrollEdgeMessage: edge reached after N passes', () => { + assert.equal( + formatScrollEdgeMessage('down', 'bottom', 4, undefined, undefined), + 'Scrolled to bottom with 4 down passes', + ); +}); + +test('formatScrollEdgeMessage: no edge, pixel amount given', () => { + assert.equal( + formatScrollEdgeMessage('down', undefined, 0, undefined, 250), + 'Scrolled down by 250px', + ); +}); + +test('formatScrollEdgeMessage: no edge, no pixels, symbolic amount given', () => { + assert.equal(formatScrollEdgeMessage('up', undefined, 0, 3, undefined), 'Scrolled up by 3'); +}); + +test('formatScrollEdgeMessage: no edge, no pixels, no amount falls back to bare direction', () => { + assert.equal( + formatScrollEdgeMessage('left', undefined, 0, undefined, undefined), + 'Scrolled left', + ); +}); + +test('formatScrollEdgeMessage: pixels takes priority over amount when both are set', () => { + assert.equal(formatScrollEdgeMessage('down', undefined, 0, 3, 250), 'Scrolled down by 250px'); +}); + +// --------------------------------------------------------------------------- +// captureScrollEdgeState: retry-without-scope on an empty scoped capture +// --------------------------------------------------------------------------- + +test('captureScrollEdgeState: an empty scoped capture retries exactly once, without the scope', async () => { + const calls: (string | undefined)[] = []; + const nodes = [ + windowRoot(), + { + ref: 'e2', + index: 1, + parentIndex: 0, + type: 'ScrollView', + hiddenContentBelow: true, + rect: { x: 0, y: 100, width: 400, height: 600 }, + }, + ]; + + const state = await captureScrollEdgeState({ + edge: 'bottom', + scope: 'stale-scope', + captureNodes: async (scope) => { + calls.push(scope); + return scope ? [] : nodes; + }, + }); + + assert.deepEqual(calls, ['stale-scope', undefined]); + assert.equal(state.canScroll, true); + assert.equal(state.emptySnapshot, false); +}); + +test('captureScrollEdgeState: an empty capture with no scope does not retry', async () => { + let callCount = 0; + const state = await captureScrollEdgeState({ + edge: 'bottom', + captureNodes: async () => { + callCount += 1; + return []; + }, + }); + assert.equal(callCount, 1); + assert.equal(state.emptySnapshot, true); +}); + +// --------------------------------------------------------------------------- +// captureScrollEdgeState: error wrapping +// --------------------------------------------------------------------------- + +test('captureScrollEdgeState: a captureNodes failure is wrapped in a COMMAND_FAILED AppError, scoped variant', async () => { + const original = new Error('runner timed out'); + await assert.rejects( + captureScrollEdgeState({ + edge: 'bottom', + scope: 'feed', + captureNodes: async () => { + throw original; + }, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.message, 'Failed to verify scroll bottom state for scoped container'); + assert.deepEqual(error.details, { + scope: 'feed', + hint: 'scroll bottom could not verify the scoped scroll container. Run snapshot -i for the current screen and retry with a visible scroll target.', + }); + assert.equal(error.cause, original); + return true; + }, + ); +}); + +test('captureScrollEdgeState: a captureNodes failure is wrapped in a COMMAND_FAILED AppError, unscoped variant (bottom edge)', async () => { + const original = new Error('runner timed out'); + await assert.rejects( + captureScrollEdgeState({ + edge: 'bottom', + captureNodes: async () => { + throw original; + }, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.message, 'Failed to verify scroll bottom state'); + assert.deepEqual(error.details, { + hint: 'scroll bottom needs a snapshot showing hidden content below before it will move.', + }); + assert.equal(error.cause, original); + return true; + }, + ); +}); + +test('captureScrollEdgeState: a captureNodes failure is wrapped in a COMMAND_FAILED AppError, unscoped variant (top edge)', async () => { + const original = new Error('runner timed out'); + await assert.rejects( + captureScrollEdgeState({ + edge: 'top', + captureNodes: async () => { + throw original; + }, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.message, 'Failed to verify scroll top state'); + assert.deepEqual(error.details, { + hint: 'scroll top needs a snapshot showing hidden content above before it will move.', + }); + assert.equal(error.cause, original); + return true; + }, + ); +}); + +test('captureScrollEdgeState: the scoped and unscoped error messages are distinct', async () => { + const scopedError = await captureThrows('feed'); + const unscopedError = await captureThrows(undefined); + assert.notEqual(scopedError.message, unscopedError.message); +}); + +// --------------------------------------------------------------------------- +// runScrollEdgePasses +// --------------------------------------------------------------------------- + +test('runScrollEdgePasses: zero passes when the initial capture already cannot scroll', async () => { + let scrollCalls = 0; + let captureCalls = 0; + const result = await runScrollEdgePasses({ + edge: 'bottom', + captureState: async () => { + captureCalls += 1; + return { canScroll: false, emptySnapshot: false }; + }, + scroll: async () => { + scrollCalls += 1; + return 'scrolled'; + }, + }); + assert.deepEqual(result, { passes: 0, result: undefined }); + assert.equal(scrollCalls, 0); + // No scope was reported, so the pre-loop rescope must NOT fire a second capture. + assert.equal(captureCalls, 1); +}); + +test('runScrollEdgePasses: threads the discovered scope into every subsequent capture, and stops once canScroll flips false', async () => { + const scopeCalls: (string | undefined)[] = []; + let scrollCalls = 0; + let captureCalls = 0; + + const result = await runScrollEdgePasses<{ index: number }>({ + edge: 'bottom', + captureState: async (scope) => { + scopeCalls.push(scope); + captureCalls += 1; + // capture #1: initial unscoped probe discovers the scope. + // capture #2: immediate rescope before the loop starts. + // captures #3..#5: one per completed pass; the 5th reports canScroll: false. + const canScroll = captureCalls < 5; + return { canScroll, emptySnapshot: false, scope: 'feed' }; + }, + scroll: async () => { + scrollCalls += 1; + return { index: scrollCalls }; + }, + }); + + assert.equal(scrollCalls, 3); + assert.equal(result.passes, 3); + assert.deepEqual(result.result, { index: 3 }); + assert.deepEqual(scopeCalls, [undefined, 'feed', 'feed', 'feed', 'feed']); +}); + +test('runScrollEdgePasses: a scope reported alongside canScroll:false still triggers the pre-loop rescope, but the loop never runs', async () => { + // The pre-loop rescope only checks state.scope, not state.canScroll — so it fires + // even though the very first (unscoped) capture already reports canScroll: false. + const scopeCalls: (string | undefined)[] = []; + let scrollCalls = 0; + + const result = await runScrollEdgePasses({ + edge: 'bottom', + captureState: async (scope) => { + scopeCalls.push(scope); + return { + canScroll: false, + emptySnapshot: false, + scope: 'feed', + }; + }, + scroll: async () => { + scrollCalls += 1; + return 'x'; + }, + }); + + assert.deepEqual(scopeCalls, [undefined, 'feed']); + assert.equal(scrollCalls, 0); + assert.equal(result.passes, 0); +}); + +test('runScrollEdgePasses: throws a COMMAND_FAILED AppError once the pass limit is reached while canScroll stays true', async () => { + let scrollCalls = 0; + await assert.rejects( + runScrollEdgePasses({ + edge: 'bottom', + captureState: async () => ({ + canScroll: true, + emptySnapshot: false, + }), + scroll: async () => { + scrollCalls += 1; + return undefined; + }, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal( + error.message, + 'scroll bottom reached the safety limit before the snapshot showed the edge', + ); + assert.deepEqual(error.details, { + hint: 'The scoped scroll container still reports hidden content. Use a smaller manual scroll + snapshot loop to inspect the current state.', + }); + return true; + }, + ); + assert.equal(scrollCalls, 40); +}); + +test('unique container scope is retained across edge pass captures', async () => { + const scopes: Array = []; + const snapshots = [scrollSnapshot(true), scrollSnapshot(true), scrollSnapshot(false)]; + let captureIndex = 0; + + const result = await runScrollEdgePasses({ + edge: 'bottom', + captureState: async (scope) => + await captureScrollEdgeState({ + edge: 'bottom', + scope, + captureNodes: async (capturedScope) => { + scopes.push(capturedScope); + return snapshots[Math.min(captureIndex++, snapshots.length - 1)] ?? []; + }, + }), + scroll: async () => ({ scrolled: true }), + }); + + assert.equal(result.passes, 1); + assert.deepEqual(scopes, [undefined, 'Messages', 'Messages']); +}); diff --git a/src/utils/scroll-edge-state-scope.test.ts b/src/utils/scroll-edge-state-scope.test.ts new file mode 100644 index 0000000000..e19bf5fd55 --- /dev/null +++ b/src/utils/scroll-edge-state-scope.test.ts @@ -0,0 +1,194 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { captureScrollEdgeState } from './scroll-edge-state.ts'; +import type { SnapshotNode } from '../kernel/snapshot.ts'; +import { capture, scopeFor, scrollNode, windowRoot } from './scroll-edge-state-fixtures.ts'; + +// --------------------------------------------------------------------------- +// buildScrollContainerScope / isUsefulScope +// --------------------------------------------------------------------------- + +test('buildScrollContainerScope: identifier is preferred over label', async () => { + assert.equal(await scopeFor({ identifier: 'feed', label: 'Feed list' }), 'feed'); +}); + +test('buildScrollContainerScope: label is used when identifier is empty', async () => { + assert.equal(await scopeFor({ identifier: '', label: 'Feed list' }), 'Feed list'); +}); + +test('buildScrollContainerScope: label is used when identifier is a boolean-like string', async () => { + assert.equal(await scopeFor({ identifier: 'TRUE', label: 'Feed list' }), 'Feed list'); +}); + +test('buildScrollContainerScope: label is used when identifier is pure digits', async () => { + assert.equal(await scopeFor({ identifier: '12345', label: 'Feed list' }), 'Feed list'); +}); + +test('buildScrollContainerScope: label is used when identifier is a percentage', async () => { + assert.equal(await scopeFor({ identifier: '45%', label: 'Feed list' }), 'Feed list'); +}); + +test('buildScrollContainerScope: label is used when identifier exceeds 80 characters', async () => { + assert.equal(await scopeFor({ identifier: 'x'.repeat(81), label: 'Feed list' }), 'Feed list'); +}); + +test('buildScrollContainerScope: value is never used as a fallback scope, even when identifier and label both fail', async () => { + assert.equal(await scopeFor({ identifier: '123', label: '50%', value: 'Messages' }), undefined); +}); + +test('buildScrollContainerScope: undefined when identifier and label are both unusable', async () => { + assert.equal(await scopeFor({ identifier: 'false', label: '100%', value: '999' }), undefined); +}); + +test('buildScrollContainerScope: undefined when none of the fields are set', async () => { + assert.equal(await scopeFor({}), undefined); +}); + +test('buildScrollContainerScope: a normal label with parentheses and digits is a useful scope', async () => { + assert.equal(await scopeFor({ label: 'Recents (12)' }), 'Recents (12)'); +}); + +test('buildScrollContainerScope: a label that is exactly 80 characters is still useful', async () => { + const eighty = 'a'.repeat(80); + assert.equal(await scopeFor({ identifier: '', label: eighty }), eighty); +}); + +test('buildScrollContainerScope: whitespace-only identifier is trimmed to empty and falls through to label', async () => { + // Untrimmed, two spaces would have length > 0 and match none of the reject + // patterns, so it would incorrectly pass isUsefulScope as-is. + assert.equal(await scopeFor({ identifier: ' ', label: 'Feed list' }), 'Feed list'); +}); + +test('buildScrollContainerScope: surrounding whitespace on an otherwise-useful identifier is trimmed off', async () => { + assert.equal(await scopeFor({ identifier: ' feed ' }), 'feed'); +}); + +test('isUsefulScope: the true/false and digit/percentage checks require a full-string match, not a prefix or suffix match', async () => { + // Each value below deliberately starts or ends with a reject pattern's + // literal text while not equaling it exactly, so it must be ACCEPTED as a + // useful scope. A reject regex missing its ^ or $ anchor would wrongly + // match these as prefixes/suffixes and reject them instead. + assert.equal(await scopeFor({ identifier: 'trueish' }), 'trueish'); + assert.equal(await scopeFor({ identifier: 'istrue' }), 'istrue'); + assert.equal(await scopeFor({ identifier: '123abc' }), '123abc'); + assert.equal(await scopeFor({ identifier: 'abc123' }), 'abc123'); + assert.equal(await scopeFor({ identifier: '50%off' }), '50%off'); + assert.equal(await scopeFor({ identifier: 'off50%' }), 'off50%'); +}); + +test('isUniqueScopeValue: an unrelated sibling with its own distinct, non-matching label does not make the scope ambiguous', async () => { + // 'Toolbar' is a real, truthy label — but it does not CONTAIN 'feed list', so + // a correct substring match excludes it. A weakened check that only asks + // "does this node have any truthy identifier/label/value" (dropping the + // substring comparison entirely) would wrongly count it as a second match + // and reject 'Feed list' as ambiguous. + const nodes: SnapshotNode[] = [ + windowRoot(), + scrollNode(1, { label: 'Feed list', hiddenContentBelow: true }), + { + ref: 'e3', + index: 2, + parentIndex: 0, + type: 'Toolbar', + label: 'Toolbar', + rect: { x: 0, y: 0, width: 400, height: 100 }, + }, + ]; + const state = await capture(nodes, 'bottom'); + assert.equal(state.scope, 'Feed list'); +}); + +// --------------------------------------------------------------------------- +// isUniqueScopeValue: uniqueness across colliding sibling values +// --------------------------------------------------------------------------- + +test('duplicate scroll-container labels do not scope edge verification to a child', async () => { + const scopes: Array = []; + const nodes: SnapshotNode[] = [ + { + index: 1, + ref: 'e1', + type: 'ScrollView', + label: 'Automation lab', + hiddenContentAbove: true, + rect: { x: 18, y: 178, width: 366, height: 662 }, + }, + { + index: 2, + ref: 'e2', + parentIndex: 1, + type: 'StaticText', + label: 'Automation lab', + rect: { x: 18, y: -344, width: 311, height: 36 }, + }, + ]; + + const state = await captureScrollEdgeState({ + edge: 'top', + captureNodes: async (scope) => { + scopes.push(scope); + return nodes; + }, + }); + + assert.equal(state.canScroll, true); + assert.equal(state.scope, undefined); + assert.deepEqual(scopes, [undefined]); +}); + +for (const collision of ['Automation lab details', 'AUTOMATION LAB']) { + test(`native-style scope collision does not select ${JSON.stringify(collision)}`, async () => { + const nodes: SnapshotNode[] = [ + { + index: 1, + ref: 'e1', + type: 'ScrollView', + label: 'Automation lab', + hiddenContentAbove: true, + rect: { x: 18, y: 178, width: 366, height: 662 }, + }, + { + index: 2, + ref: 'e2', + parentIndex: 1, + type: 'StaticText', + label: collision, + rect: { x: 18, y: -344, width: 311, height: 36 }, + }, + ]; + + const state = await captureScrollEdgeState({ + edge: 'top', + captureNodes: async () => nodes, + }); + + assert.equal(state.scope, undefined); + }); +} + +test('value collision does not scope edge verification to an ambiguous subtree', async () => { + const nodes: SnapshotNode[] = [ + { + index: 1, + ref: 'e1', + type: 'ScrollView', + label: 'Automation lab', + hiddenContentAbove: true, + rect: { x: 18, y: 178, width: 366, height: 662 }, + }, + { + index: 2, + ref: 'e2', + type: 'Other', + value: 'Automation lab ready', + rect: { x: 0, y: 0, width: 402, height: 874 }, + }, + ]; + + const state = await captureScrollEdgeState({ + edge: 'top', + captureNodes: async () => nodes, + }); + + assert.equal(state.scope, undefined); +}); diff --git a/src/utils/scroll-edge-state-target-resolution.test.ts b/src/utils/scroll-edge-state-target-resolution.test.ts new file mode 100644 index 0000000000..e91a107f30 --- /dev/null +++ b/src/utils/scroll-edge-state-target-resolution.test.ts @@ -0,0 +1,199 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import type { SnapshotNode } from '../kernel/snapshot.ts'; +import { capture, scrollNode, windowRoot } from './scroll-edge-state-fixtures.ts'; + +// --------------------------------------------------------------------------- +// selectScrollContainer: target.nodeIndex resolution +// --------------------------------------------------------------------------- + +test('selectScrollContainer: target.nodeIndex pointing directly at a scrollable node selects it over a broader hidden-edge distractor', async () => { + const nodes = [ + windowRoot(), + scrollNode(1, { + identifier: 'target-container', + rect: { x: 0, y: 0, width: 100, height: 100 }, + }), + // A distractor the broad (no-target) search would prefer, since it has a + // hidden edge and a much larger area — proves the direct nodeIndex hit + // short-circuits selection rather than coincidentally agreeing with it. + scrollNode(2, { + identifier: 'distractor', + hiddenContentBelow: true, + rect: { x: 150, y: 150, width: 300, height: 300 }, + }), + ]; + const state = await capture(nodes, 'bottom', { nodeIndex: 1 }); + assert.equal(state.scope, 'target-container'); +}); + +test('selectScrollContainer: target.nodeIndex pointing at a child resolves to its scrollable ancestor, not a broader distractor', async () => { + const nodes = [ + windowRoot(), + scrollNode(1, { + identifier: 'ancestor-container', + rect: { x: 0, y: 0, width: 100, height: 100 }, + }), + { + ref: 'e3', + index: 2, + parentIndex: 1, + type: 'Button', + label: 'Row', + rect: { x: 20, y: 20, width: 60, height: 20 }, + }, + scrollNode(3, { + identifier: 'distractor', + hiddenContentBelow: true, + rect: { x: 150, y: 150, width: 300, height: 300 }, + }), + ]; + const state = await capture(nodes, 'bottom', { nodeIndex: 2 }); + assert.equal(state.scope, 'ancestor-container'); +}); + +test('selectScrollContainer: target.nodeIndex resolves through a two-level (grandchild) chain to its scrollable ancestor', async () => { + const nodes = [ + windowRoot(), + scrollNode(1, { + identifier: 'ancestor-container', + rect: { x: 0, y: 0, width: 100, height: 100 }, + }), + { + ref: 'e3', + index: 2, + parentIndex: 1, + type: 'Button', + label: 'Row', + rect: { x: 20, y: 20, width: 60, height: 20 }, + }, + { + ref: 'e4', + index: 3, + parentIndex: 2, + type: 'Text', + label: 'Row label', + rect: { x: 22, y: 22, width: 30, height: 10 }, + }, + ]; + const state = await capture(nodes, 'bottom', { nodeIndex: 3 }); + assert.equal(state.scope, 'ancestor-container'); +}); + +test('selectScrollContainer: target.nodeIndex with no scrollable ancestor falls back to the broad scrollable search', async () => { + const nodes = [ + windowRoot(), + { + ref: 'e2', + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Unrelated target', + rect: { x: 20, y: 40, width: 100, height: 40 }, + }, + scrollNode(2, { identifier: 'other-feed', hiddenContentBelow: true }), + ]; + const state = await capture(nodes, 'bottom', { nodeIndex: 1 }); + assert.equal(state.canScroll, true); + assert.equal(state.scope, 'other-feed'); +}); + +test('selectScrollContainer: an unknown target.nodeIndex is ignored rather than throwing', async () => { + const nodes = [windowRoot(), scrollNode(1, { identifier: 'feed', hiddenContentBelow: true })]; + const state = await capture(nodes, 'bottom', { nodeIndex: 999 }); + assert.equal(state.canScroll, true); + assert.equal(state.scope, 'feed'); +}); + +// --------------------------------------------------------------------------- +// selectScrollContainer: target.point resolution (specific selection) +// --------------------------------------------------------------------------- + +test('selectScrollContainer: target.point inside nested scrollables with no hidden edge prefers the smallest container, ignoring a non-containing distractor', async () => { + // 'outer' is declared before 'inner' (so a naive first-match without sorting + // would wrongly pick 'outer'), and 'far-away' has a hidden edge but does NOT + // contain the point (so a broken point filter that let it through would win + // on hidden-edge preference instead of the correct smallest-containing pick). + const nodes = [ + windowRoot(), + scrollNode(1, { identifier: 'outer', rect: { x: 0, y: 0, width: 400, height: 800 } }), + scrollNode(2, { + parentIndex: 1, + identifier: 'inner', + rect: { x: 50, y: 50, width: 100, height: 100 }, + }), + scrollNode(3, { + identifier: 'far-away', + hiddenContentBelow: true, + rect: { x: 900, y: 900, width: 50, height: 50 }, + }), + ]; + const state = await capture(nodes, 'bottom', { point: { x: 75, y: 75 } }); + assert.equal(state.scope, 'inner'); +}); + +test('selectScrollContainer: target.point inside nested scrollables prefers the one with a hidden edge over the smaller one', async () => { + const nodes = [ + windowRoot(), + scrollNode(1, { + identifier: 'outer', + hiddenContentBelow: true, + rect: { x: 0, y: 0, width: 400, height: 800 }, + }), + scrollNode(2, { + parentIndex: 1, + identifier: 'inner', + rect: { x: 50, y: 50, width: 100, height: 100 }, + }), + ]; + const state = await capture(nodes, 'bottom', { point: { x: 75, y: 75 } }); + assert.equal(state.canScroll, true); + assert.equal(state.scope, 'outer'); +}); + +test('selectScrollContainer: target.point outside every scrollable rect falls back to the broad search', async () => { + const nodes = [ + windowRoot(), + scrollNode(1, { identifier: 'outer', rect: { x: 0, y: 0, width: 100, height: 100 } }), + scrollNode(2, { + identifier: 'far-away', + hiddenContentBelow: true, + rect: { x: 200, y: 200, width: 100, height: 100 }, + }), + ]; + const state = await capture(nodes, 'bottom', { point: { x: 999, y: 999 } }); + assert.equal(state.canScroll, true); + assert.equal(state.scope, 'far-away'); +}); + +// --------------------------------------------------------------------------- +// containsPoint boundary (inclusive edges, and-of-four rather than or-of-any) +// --------------------------------------------------------------------------- + +test('containsPoint: boundary is inclusive on every edge, and requires all four bounds together (not any pair)', async () => { + const nodes: SnapshotNode[] = [ + windowRoot(), + scrollNode(1, { identifier: 'point-match', rect: { x: 0, y: 0, width: 100, height: 100 } }), + // Far away, but wins the broad (no-point-match) fallback via its hidden + // edge — so if containsPoint wrongly matches, scope stays 'point-match'; + // if it correctly rejects, scope must become this distractor instead. + scrollNode(2, { + identifier: 'broad-winner', + hiddenContentBelow: true, + rect: { x: 1000, y: 1000, width: 300, height: 300 }, + }), + ]; + const scopeAt = async (point: { x: number; y: number }) => + (await capture(nodes, 'bottom', { point })).scope; + + // Inclusive corners: sitting exactly on the boundary still counts as inside. + assert.equal(await scopeAt({ x: 0, y: 0 }), 'point-match'); + assert.equal(await scopeAt({ x: 100, y: 100 }), 'point-match'); + + // Failing exactly one of the four bounds must exclude the container outright, + // not merely satisfy some other bound via a broken OR. + assert.equal(await scopeAt({ x: -1, y: 50 }), 'broad-winner'); + assert.equal(await scopeAt({ x: 101, y: 50 }), 'broad-winner'); + assert.equal(await scopeAt({ x: 50, y: -1 }), 'broad-winner'); + assert.equal(await scopeAt({ x: 50, y: 101 }), 'broad-winner'); +}); diff --git a/src/utils/scroll-edge-state.test.ts b/src/utils/scroll-edge-state.test.ts deleted file mode 100644 index dc264a7a33..0000000000 --- a/src/utils/scroll-edge-state.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; - -import { captureScrollEdgeState, runScrollEdgePasses } from './scroll-edge-state.ts'; -import type { SnapshotNode } from '../kernel/snapshot.ts'; - -test('duplicate scroll-container labels do not scope edge verification to a child', async () => { - const scopes: Array = []; - const nodes: SnapshotNode[] = [ - { - index: 1, - ref: 'e1', - type: 'ScrollView', - label: 'Automation lab', - hiddenContentAbove: true, - rect: { x: 18, y: 178, width: 366, height: 662 }, - }, - { - index: 2, - ref: 'e2', - parentIndex: 1, - type: 'StaticText', - label: 'Automation lab', - rect: { x: 18, y: -344, width: 311, height: 36 }, - }, - ]; - - const state = await captureScrollEdgeState({ - edge: 'top', - captureNodes: async (scope) => { - scopes.push(scope); - return nodes; - }, - }); - - assert.equal(state.canScroll, true); - assert.equal(state.scope, undefined); - assert.deepEqual(scopes, [undefined]); -}); - -for (const collision of ['Automation lab details', 'AUTOMATION LAB']) { - test(`native-style scope collision does not select ${JSON.stringify(collision)}`, async () => { - const nodes: SnapshotNode[] = [ - { - index: 1, - ref: 'e1', - type: 'ScrollView', - label: 'Automation lab', - hiddenContentAbove: true, - rect: { x: 18, y: 178, width: 366, height: 662 }, - }, - { - index: 2, - ref: 'e2', - parentIndex: 1, - type: 'StaticText', - label: collision, - rect: { x: 18, y: -344, width: 311, height: 36 }, - }, - ]; - - const state = await captureScrollEdgeState({ - edge: 'top', - captureNodes: async () => nodes, - }); - - assert.equal(state.scope, undefined); - }); -} - -test('value collision does not scope edge verification to an ambiguous subtree', async () => { - const nodes: SnapshotNode[] = [ - { - index: 1, - ref: 'e1', - type: 'ScrollView', - label: 'Automation lab', - hiddenContentAbove: true, - rect: { x: 18, y: 178, width: 366, height: 662 }, - }, - { - index: 2, - ref: 'e2', - type: 'Other', - value: 'Automation lab ready', - rect: { x: 0, y: 0, width: 402, height: 874 }, - }, - ]; - - const state = await captureScrollEdgeState({ - edge: 'top', - captureNodes: async () => nodes, - }); - - assert.equal(state.scope, undefined); -}); - -test('unique container scope is retained across edge pass captures', async () => { - const scopes: Array = []; - const snapshots = [scrollSnapshot(true), scrollSnapshot(true), scrollSnapshot(false)]; - let captureIndex = 0; - - const result = await runScrollEdgePasses({ - edge: 'bottom', - captureState: async (scope) => - await captureScrollEdgeState({ - edge: 'bottom', - scope, - captureNodes: async (capturedScope) => { - scopes.push(capturedScope); - return snapshots[Math.min(captureIndex++, snapshots.length - 1)] ?? []; - }, - }), - scroll: async () => ({ scrolled: true }), - }); - - assert.equal(result.passes, 1); - assert.deepEqual(scopes, [undefined, 'Messages', 'Messages']); -}); - -function scrollSnapshot(hiddenContentBelow: boolean): SnapshotNode[] { - return [ - { - index: 1, - ref: 'e1', - type: 'ScrollView', - label: 'Messages', - hiddenContentBelow: hiddenContentBelow ? true : undefined, - rect: { x: 0, y: 100, width: 400, height: 600 }, - }, - { - index: 2, - ref: 'e2', - parentIndex: 1, - type: 'Button', - label: hiddenContentBelow ? 'Middle message' : 'Latest message', - rect: { x: 0, y: 640, width: 400, height: 56 }, - }, - ]; -} diff --git a/src/utils/scroll-edge-state.ts b/src/utils/scroll-edge-state.ts index a1416e4128..7468b924db 100644 --- a/src/utils/scroll-edge-state.ts +++ b/src/utils/scroll-edge-state.ts @@ -17,7 +17,6 @@ export type ScrollEdge = 'top' | 'bottom'; export type ScrollEdgeState = { canScroll: boolean; emptySnapshot: boolean; - signature: string; scope?: string; }; @@ -28,31 +27,25 @@ export type ScrollEdgeTarget = { const SCROLL_EDGE_PASS_LIMIT = 40; -const SCROLL_SIGNATURE_RECT_PRECISION = 1; - function analyzeScrollEdgeState( - inputNodes: readonly (RawSnapshotNode | SnapshotNode)[] | undefined, + inputNodes: readonly (RawSnapshotNode | SnapshotNode)[], edge: ScrollEdge, target: ScrollEdgeTarget = {}, ): ScrollEdgeState { - const nodes = ensureSnapshotNodes(inputNodes ?? []); + const nodes = ensureSnapshotNodes(inputNodes); if (nodes.length === 0) { return { canScroll: false, emptySnapshot: true, - signature: '', }; } const hiddenHints = deriveMobileSnapshotHiddenContentHints(nodes); const container = selectScrollContainer(nodes, hiddenHints, edge, target); - const signatureNodes = container ? collectSubtreeNodes(nodes, container.index) : nodes; - const signature = buildScrollStateSignature(signatureNodes); if (!container) { return { canScroll: false, emptySnapshot: false, - signature, }; } @@ -60,7 +53,6 @@ function analyzeScrollEdgeState( return { canScroll, emptySnapshot: false, - signature, scope: buildScrollContainerScope(container, nodes), }; } @@ -225,26 +217,6 @@ function findNearestScrollableAncestor( return null; } -function collectSubtreeNodes(nodes: SnapshotNode[], rootIndex: number): SnapshotNode[] { - const byIndex = new Map(nodes.map((node) => [node.index, node])); - return nodes.filter((node) => node.index === rootIndex || hasAncestor(node, rootIndex, byIndex)); -} - -function hasAncestor( - node: SnapshotNode, - ancestorIndex: number, - byIndex: Map, -): boolean { - let current = node.parentIndex === undefined ? undefined : byIndex.get(node.parentIndex); - while (current) { - if (current.index === ancestorIndex) { - return true; - } - current = current.parentIndex === undefined ? undefined : byIndex.get(current.parentIndex); - } - return false; -} - function hasHiddenContentAtEdge( node: SnapshotNode, hint: HiddenContentHint | undefined, @@ -261,7 +233,8 @@ function buildScrollContainerScope( nodes: readonly SnapshotNode[], ): string | undefined { return [node.identifier, node.label] - .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) .find((value) => isUsefulScope(value) && isUniqueScopeValue(value, node, nodes)); } @@ -290,28 +263,6 @@ function isUsefulScope(value: string): boolean { ); } -function buildScrollStateSignature(nodes: SnapshotNode[]): string { - return nodes - .map((node) => { - const rectSignature = node.rect - ? ['x', 'y', 'width', 'height'] - .map((key) => - roundSignatureNumber(node.rect?.[key as keyof NonNullable]), - ) - .join(',') - : ''; - return [ - String(node.index ?? ''), - String(node.parentIndex ?? ''), - String(node.type ?? ''), - String(node.label ?? ''), - String(node.value ?? ''), - rectSignature, - ].join('|'); - }) - .join('\n'); -} - function compareSpecificScrollContainer(a: SnapshotNode, b: SnapshotNode): number { return rectArea(a.rect) - rectArea(b.rect); } @@ -336,9 +287,3 @@ function containsPoint(rect: NonNullable, point: Point): b function isUsableRect(rect: SnapshotNode['rect']): rect is NonNullable { return Boolean(rect && rect.width > 0 && rect.height > 0); } - -function roundSignatureNumber(value: unknown): string { - return typeof value === 'number' && Number.isFinite(value) - ? value.toFixed(SCROLL_SIGNATURE_RECT_PRECISION) - : ''; -}