Skip to content
Open
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
102 changes: 101 additions & 1 deletion app/src/__tests__/store-inspector-focus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it } from 'vitest';
import { StoreInspector } from '../pages/store-inspector';
import type { NgrxPage, NgrxState } from '../pages/store-types';
import type { NgrxLogEntry, NgrxPage, NgrxState } from '../pages/store-types';

afterEach(() => {
TestBed.resetTestingModule();
Expand Down Expand Up @@ -69,6 +69,106 @@ async function restoreNewest(result: { ok: boolean; paused?: boolean; message: s
return fixture.nativeElement as HTMLElement;
}

function eventPage(events: NgrxLogEntry[]): NgrxPage {
return {
pageId: 'p1',
url: 'http://localhost/',
title: 'Shop',
stores: [],
classic: null,
log: events,
reportedAt: 1,
};
}

function eventEntry(seq: number, type = 'bookingCancelled'): NgrxLogEntry {
return {
seq,
source: 'event',
storeId: 'event',
type,
eventType: type,
payload: { id: seq },
timestamp: seq,
diff: [],
restorable: false,
};
}

function fakeEventClient(events: NgrxLogEntry[]): DevframeRpcClient {
const ep = eventPage(events);
return {
connectionMeta: {},
scope: () => ({
rpc: {
call: async (name: string) => (name === 'request-ngrx-action' ? { ok: true } : []),
sharedState: async () => ({
value: () => ({ pages: [ep] }) as NgrxState,
on: () => () => undefined,
}),
},
}),
} as unknown as DevframeRpcClient;
}

describe('StoreInspector events', () => {
it('shows events in a dedicated section and selects one on click', async () => {
const fixture = TestBed.createComponent(StoreInspector);
document.body.appendChild(fixture.nativeElement);
fixture.componentRef.setInput('rpc', fakeEventClient([eventEntry(1), eventEntry(2)]));
await fixture.whenStable();
fixture.detectChanges();

const host = fixture.nativeElement as HTMLElement;
const evtButtons = host.querySelectorAll<HTMLButtonElement>('.event-item');
expect(evtButtons.length).toBe(2);
expect(host.querySelector('.event-detail-focus')).toBeNull();

evtButtons[0].click();
await fixture.whenStable();
fixture.detectChanges();
expect(host.querySelector('.event-detail-focus')).not.toBeNull();
expect(evtButtons[0].getAttribute('aria-pressed')).toBe('true');
expect(evtButtons[1].getAttribute('aria-pressed')).toBe('false');
});

it('moves focus to the event detail panel when an event is clicked', async () => {
const fixture = TestBed.createComponent(StoreInspector);
document.body.appendChild(fixture.nativeElement);
fixture.componentRef.setInput('rpc', fakeEventClient([eventEntry(1)]));
await fixture.whenStable();
fixture.detectChanges();

const host = fixture.nativeElement as HTMLElement;
host.querySelector<HTMLButtonElement>('.event-item')!.click();
await fixture.whenStable();
fixture.detectChanges();
expect(document.activeElement).toBe(host.querySelector('.event-detail-focus'));
});

it('does not steal focus from the filter when typing after an event is selected', async () => {
const fixture = TestBed.createComponent(StoreInspector);
document.body.appendChild(fixture.nativeElement);
fixture.componentRef.setInput('rpc', fakeEventClient([eventEntry(1)]));
await fixture.whenStable();
fixture.detectChanges();

const host = fixture.nativeElement as HTMLElement;
host.querySelector<HTMLButtonElement>('.event-item')!.click();
await fixture.whenStable();
fixture.detectChanges();

// Now type in the filter — this causes a re-render but must not steal focus
const input = host.querySelector<HTMLInputElement>('input[type="search"]')!;
input.focus();
input.value = 'book';
input.dispatchEvent(new Event('input'));
await fixture.whenStable();
fixture.detectChanges();
expect(document.activeElement).toBe(input);
});
});

describe('StoreInspector restore focus', () => {
it('focuses the state when restoring the newest action does not pause the store', async () => {
const host = await restoreNewest({ ok: true, paused: false, message: 'Jumped.' });
Expand Down
92 changes: 91 additions & 1 deletion app/src/__tests__/store-inspector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it } from 'vitest';
import { StoreInspector } from '../pages/store-inspector';
import type { NgrxLogEntry, NgrxPage, NgrxStoreEntry } from '../pages/store-types';
import type {
NgrxLogEntry,
NgrxPage,
NgrxSignalStoreInfo,
NgrxStoreEntry,
} from '../pages/store-types';

type Call = (name: string, arg?: Record<string, unknown>) => Promise<unknown>;

Expand Down Expand Up @@ -190,3 +195,88 @@ describe('StoreInspector restore', () => {
expect(root(fixture).textContent).toContain('actionsBlocklist');
});
});

describe('StoreInspector signal store', () => {
function signalStore(overrides: Partial<NgrxSignalStoreInfo> = {}): NgrxSignalStoreInfo {
return {
id: 'ngrx-1',
kind: 'signal-store',
className: 'ProductStore',
scope: 'root',
stateKeys: ['count'],
state: { count: 0 },
computed: {},
methods: [],
references: [],
writable: true,
...overrides,
};
}

function signalPage(stores: NgrxSignalStoreInfo[], log: NgrxLogEntry[] = []): NgrxPage {
return { pageId: 'p1', url: '/', title: 'App', stores, classic: null, log, reportedAt: 0 };
}

async function mountSignal(stores: NgrxSignalStoreInfo[], log: NgrxLogEntry[] = []) {
const fixture = TestBed.createComponent(StoreInspector);
fixture.componentRef.setInput(
'rpc',
fakeClient(
(name) => (name === 'get-ngrx-store' ? Promise.resolve([]) : Promise.resolve({})),
signalPage(stores, log),
),
);
await settle(fixture);
return fixture;
}

it('shows entity count and ids for a withEntities() store', async () => {
const fixture = await mountSignal([
signalStore({
stateKeys: ['entityMap', 'ids'],
state: { entityMap: {}, ids: [] },
entities: [{ idsKey: 'ids', entityMapKey: 'entityMap', ids: ['a', 'b', 'c'], count: 3 }],
}),
]);
const host = root(fixture);
expect(host.textContent).toMatch(/3\s+entities/);
expect(host.querySelector('.entity-ids')).not.toBeNull();
const chips = Array.from(host.querySelectorAll<HTMLElement>('.entity-ids .chip'));
expect(chips.map((c) => c.textContent?.trim())).toEqual(['a', 'b', 'c']);
});

it('truncates ids past 30 and shows a +N more chip', async () => {
const ids = Array.from({ length: 32 }, (_, i) => String(i));
const fixture = await mountSignal([
signalStore({
stateKeys: ['entityMap', 'ids'],
state: { entityMap: {}, ids: [] },
entities: [{ idsKey: 'ids', entityMapKey: 'entityMap', ids, count: ids.length }],
}),
]);
const host = root(fixture);
const more = host.querySelector<HTMLElement>('.chip.more');
expect(more).not.toBeNull();
expect(more?.textContent?.trim()).toBe('+2 more');
});

it('shows avg and last duration for timed methods and omits them for untimed ones', async () => {
const fixture = await mountSignal([
signalStore({
methods: [
{ name: 'load', calls: 4, avgDurationMs: 10, lastDurationMs: 8 },
{ name: 'reset', calls: 1 },
],
}),
]);
const host = root(fixture);
const methods = host.querySelector('.methods');
expect(methods?.textContent).toContain('avg 10ms');
expect(methods?.textContent).toContain('last 8ms');
// reset has no duration numbers
const resetChip = Array.from(host.querySelectorAll<HTMLElement>('.chip.mono')).find((c) =>
c.textContent?.includes('reset'),
);
expect(resetChip?.textContent).not.toContain('ms');
});
});
Loading
Loading