Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/utils/scroll-edge-state-broad-selection.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
51 changes: 51 additions & 0 deletions src/utils/scroll-edge-state-container-existence.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
85 changes: 85 additions & 0 deletions src/utils/scroll-edge-state-fixtures.ts
Original file line number Diff line number Diff line change
@@ -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<ScrollEdgeState> {
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> = {}): 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<SnapshotNode>): Promise<string | undefined> {
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<AppError> {
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;
}
}
70 changes: 70 additions & 0 deletions src/utils/scroll-edge-state-hidden-edge.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading