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
117 changes: 117 additions & 0 deletions app/src/__tests__/component-tree-window.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { describe, expect, it } from 'vitest';
import { ComponentTree } from '../pages/component-tree';

// A tree of 2,000 instances renders only the rows near view, and stays
// navigable by keyboard and described to screen readers.

const COUNT = 2000;

// jsdom has no CSS namespace; the page escapes ids with it.
(globalThis as { CSS?: { escape(value: string): string } }).CSS ??= {
escape: (value: string) => value,
};

function bigTree() {
const items = Array.from({ length: COUNT - 1 }, (_, i) => ({
id: `c${i}`,
name: `Item${i}`,
tag: 'app-item',
children: [],
}));
return [{ id: 'app', name: 'App', tag: 'app-root', children: items }];
}

function client(roots: unknown[]): DevframeRpcClient {
const state: Record<string, unknown> = {
'component-tree': {
pages: {
a: { pageId: 'a', title: 'a', url: '/', roots, count: COUNT, detail: null, reportedAt: 1 },
},
},
};
const rpc = {
call: () => Promise.resolve([]),
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({ value: () => state[name] ?? null, on: () => () => {} }),
};
return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
}

async function settle(fixture: ComponentFixture<unknown>) {
for (let i = 0; i < 4; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
}

async function setup(roots: unknown[]) {
const fixture = TestBed.createComponent(ComponentTree);
fixture.componentRef.setInput('rpc', client(roots));
document.body.appendChild(fixture.nativeElement);
await settle(fixture);
return fixture;
}

const rows = (fixture: ComponentFixture<ComponentTree>) =>
Array.from((fixture.nativeElement as HTMLElement).querySelectorAll<HTMLElement>('.row'));

describe('ComponentTree with a long tree', () => {
it('renders the rows near view and space for the rest', async () => {
const fixture = await setup(bigTree());
const shown = rows(fixture);
expect(shown.length).toBeGreaterThan(10);
expect(shown.length).toBeLessThan(200);
const tree = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('.tree')!;
expect(tree.classList).toContain('windowed');
expect(parseFloat(tree.style.paddingBottom)).toBeGreaterThan(30_000);
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'Rows render as you scroll',
);
fixture.destroy();
});

it('tells screen readers where each row sits among its siblings', async () => {
const fixture = await setup(bigTree());
const item = rows(fixture).find((row) => row.dataset['id'] === 'c4')!;
expect(item.getAttribute('aria-level')).toBe('2');
expect(item.getAttribute('aria-posinset')).toBe('5');
expect(item.getAttribute('aria-setsize')).toBe(String(COUNT - 1));
// One Tab stop, on a rendered row.
expect(rows(fixture).filter((row) => row.tabIndex === 0)).toHaveLength(1);
fixture.destroy();
});

it('moves to the last row with End, rendering it first', async () => {
const fixture = await setup(bigTree());
const first = rows(fixture)[0]!;
first.focus();
first.dispatchEvent(
new KeyboardEvent('keydown', { key: 'End', bubbles: true, cancelable: true }),
);
await settle(fixture);
const last = rows(fixture).find((row) => row.dataset['id'] === `c${COUNT - 2}`);
expect(last).toBeTruthy();
expect(document.activeElement).toBe(last);
expect(last!.tabIndex).toBe(0);
expect(rows(fixture).length).toBeLessThan(200);
fixture.destroy();
});

it('renders a short tree whole', async () => {
const fixture = await setup([
{
id: 'app',
name: 'App',
tag: 'app-root',
children: [{ id: 'x', name: 'X', tag: 'app-x', children: [] }],
},
]);
expect(rows(fixture)).toHaveLength(2);
const tree = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('.tree')!;
expect(tree.classList).not.toContain('windowed');
fixture.destroy();
});
});
86 changes: 86 additions & 0 deletions app/src/__tests__/long-lists-window.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { TestBed } from '@angular/core/testing';
import { afterEach, describe, expect, it } from 'vitest';
import { NetworkInspector } from '../pages/network-inspector';

// Long lists render only the rows near view: the HTTP call table here; the
// component tree has its own test.

const COUNT = 2000;

const call = (i: number) => ({
id: `c${i}`,
url: `/api/items/${i}`,
method: 'GET',
status: 200,
durationMs: 12,
side: 'client' as const,
cacheHit: false,
faulted: false,
at: 1000 + i,
});

async function settle(fixture: { whenStable(): Promise<unknown> }) {
for (let i = 0; i < 4; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
}

async function network(count: number) {
const fixture = TestBed.createComponent(NetworkInspector);
document.body.appendChild(fixture.nativeElement);
fixture.componentInstance.pages.set([
{
pageId: 'p1',
url: '/shop',
title: 'Shop',
hydration: null,
reportedAt: 1100,
calls: Array.from({ length: count }, (_, i) => call(i)),
},
]);
await settle(fixture);
return fixture;
}

afterEach(() => TestBed.resetTestingModule());

describe('a long HTTP call table', () => {
it('renders the rows near view and keeps the row count for screen readers', async () => {
const fixture = await network(COUNT);
const host = fixture.nativeElement as HTMLElement;
const rows = host.querySelectorAll('tbody tr[data-vindex]');
expect(rows.length).toBeGreaterThan(10);
expect(rows.length).toBeLessThan(200);
expect(host.querySelector('table')!.getAttribute('aria-rowcount')).toBe(String(COUNT + 1));
expect(rows[3]!.getAttribute('aria-rowindex')).toBe('5');
const spacer = host.querySelector<HTMLElement>('tbody tr.spacer:last-child td')!;
expect(parseFloat(spacer.style.height)).toBeGreaterThan(50_000);
expect(host.querySelector('.filters .total')!.textContent!.trim()).toBe(`${COUNT} of ${COUNT}`);
fixture.destroy();
});

it('goes to the last call with End', async () => {
const fixture = await network(COUNT);
const host = fixture.nativeElement as HTMLElement;
const first = host.querySelector<HTMLElement>('button[data-call-id]')!;
first.focus();
first.dispatchEvent(
new KeyboardEvent('keydown', { key: 'End', bubbles: true, cancelable: true }),
);
await settle(fixture);
const focused = document.activeElement as HTMLElement;
expect(focused.closest('tr')!.getAttribute('aria-rowindex')).toBe(String(COUNT + 1));
// One Tab stop in the table.
expect(host.querySelectorAll('button[data-call-id][tabindex="0"]')).toHaveLength(1);
fixture.destroy();
});

it('renders a short table whole, without spacers', async () => {
const fixture = await network(20);
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelectorAll('tbody tr[data-vindex]')).toHaveLength(20);
expect(host.querySelector('tbody tr.spacer')).toBeNull();
fixture.destroy();
});
});
90 changes: 90 additions & 0 deletions app/src/__tests__/panel-bench.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { TestBed } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { describe, expect, it } from 'vitest';
import { ComponentTree } from '../pages/component-tree';

// How long the component tree takes to render 2,000 instances, and how many
// rows it puts in the DOM. Slow, so it runs only on demand:
//
// PANGULAR_BENCH=1 pnpm vitest run --config app/vitest.config.ts app/src/__tests__/panel-bench.test.ts

const COUNT = 2000;

/** Node's process, which the panel's types leave out. */
const node = (
globalThis as {
process?: { env: Record<string, string | undefined>; stdout: { write(text: string): void } };
}
).process;

function roots() {
const sections = Array.from({ length: 40 }, (_, s) => ({
id: `s${s}`,
name: 'Section',
tag: 'app-section',
children: [
...Array.from({ length: 24 }, (_, r) => ({
id: `s${s}r${r}`,
name: 'Row',
tag: 'app-row',
children: [{ id: `s${s}r${r}c`, name: 'Cell', tag: 'app-cell', children: [] as unknown[] }],
})),
// One more row in all but the last section: 2,000 instances in all.
...(s < 39 ? [{ id: `s${s}x`, name: 'Row', tag: 'app-row', children: [] }] : []),
],
}));
return [{ id: 'app', name: 'App', tag: 'app-root', children: sections }];
}

function client(tree: unknown[]): DevframeRpcClient {
const state: Record<string, unknown> = {
'component-tree': {
pages: {
a: {
pageId: 'a',
title: 'a',
url: '/',
roots: tree,
count: COUNT,
detail: null,
reportedAt: 1,
},
},
},
};
const rpc = {
call: () => Promise.resolve([]),
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({ value: () => state[name] ?? null, on: () => () => {} }),
};
return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
}

describe.runIf(node?.env['PANGULAR_BENCH'])('panel benchmark', () => {
it('renders a 2,000 component tree', async () => {
const tree = roots();
const runs: number[] = [];
let rows = 0;
let nodes = 0;
for (let i = 0; i < 5; i++) {
const started = performance.now();
const fixture = TestBed.createComponent(ComponentTree);
fixture.componentRef.setInput('rpc', client(tree));
document.body.appendChild(fixture.nativeElement);
for (let k = 0; k < 4; k++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
runs.push(performance.now() - started);
const host = fixture.nativeElement as HTMLElement;
rows = host.querySelectorAll('.row').length;
nodes = host.querySelectorAll('*').length;
fixture.destroy();
}
runs.sort((a, b) => a - b);
const result = { rows, domNodes: nodes, medianRenderMs: Math.round(runs[2]!) };
node?.stdout.write(`[bench] ${JSON.stringify(result)}\n`);
expect(rows).toBeGreaterThan(0);
}, 120_000);
});
65 changes: 65 additions & 0 deletions app/src/__tests__/virtual-window.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { ApplicationRef, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { afterEach, describe, expect, it } from 'vitest';
import { VirtualWindow } from '../ui/virtual-window';

// A windowed list that gets shorter while scrolled near its end, such as a
// filter taking 2000 rows down to 301.

const ROW = 20;

function setup() {
const count = signal(2000);
const win = TestBed.runInInjectionContext(
() => new VirtualWindow({ count, estimate: ROW, fixed: true }),
);
const host = document.createElement('div');
host.style.overflowY = 'auto';
let scrollTop = 0;
Object.defineProperty(host, 'scrollTop', {
get: () => scrollTop,
set: (v: number) => (scrollTop = v),
});
Object.defineProperty(host, 'clientHeight', { get: () => 600 });
document.body.appendChild(host);
win.attach(host);
const scrollTo = (top: number) => {
scrollTop = top;
host.dispatchEvent(new Event('scroll'));
};
const render = () => TestBed.inject(ApplicationRef).tick();
return { count, win, host, scrollTo, setScroll: (top: number) => (scrollTop = top), render };
}

afterEach(() => {
TestBed.resetTestingModule();
document.body.innerHTML = '';
});

describe('virtual window', () => {
it('still renders rows when the list shrinks below the scroll position', () => {
const { count, win, scrollTo } = setup();
scrollTo(1990 * ROW);
expect(win.range().end).toBe(2000);
count.set(301);
const { start, end } = win.range();
expect(end).toBe(301);
expect(start).toBeLessThan(301);
// The rows that fill the viewport at the end of the list, plus the overscan.
expect(end - start).toBeGreaterThanOrEqual(600 / ROW);
expect(win.padTop() + (end - start) * ROW + win.padBottom()).toBe(301 * ROW);
});

it('reads the scroll position again when the row count changes', () => {
const { count, win, scrollTo, setScroll, render } = setup();
scrollTo(1990 * ROW);
render();
count.set(301);
// The browser clamps the scroll position to the shorter list, with no scroll event.
setScroll(301 * ROW - 600);
render();
// The list grows back before the next scroll: the window follows where the list really is.
count.set(2000);
expect(win.range().start).toBe((301 * ROW - 600) / ROW - 12);
});
});
Loading
Loading