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

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

function fakeClient(
call: Call,
state: Record<string, unknown> = {},
configs: Record<string, unknown> = {},
): DevframeRpcClient {
const rpc = {
call,
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({ value: () => state[name] ?? null, on: () => () => {} }),
};
return { connectionMeta: { configs }, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
}

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

function el(fixture: ComponentFixture<unknown>): HTMLElement {
return fixture.nativeElement as HTMLElement;
}

function buttons(fixture: ComponentFixture<unknown>, name: string): HTMLButtonElement[] {
return Array.from(el(fixture).querySelectorAll<HTMLButtonElement>('button')).filter(
(b) => (b.getAttribute('aria-label') ?? b.textContent ?? '').trim() === name,
);
}

function key(target: Element, name: string) {
target.dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true }));
}

function type(input: HTMLInputElement, value: string) {
input.value = value;
input.dispatchEvent(new Event('input'));
}

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

function mount(prop: EditableProp, call: Call, writes = true) {
const fixture = TestBed.createComponent(PropValue);
fixture.componentRef.setInput('prop', prop);
fixture.componentRef.setInput('componentId', 'c1');
fixture.componentRef.setInput('pageId', 'p1');
fixture.componentRef.setInput('rpc', fakeClient(call));
fixture.componentRef.setInput('writes', writes);
document.body.append(fixture.nativeElement);
return fixture;
}

describe('parseDraft', () => {
it('reads JSON and explains text without quotes', () => {
expect(parseDraft(' 42 ')).toEqual({ value: 42 });
expect(parseDraft('"Paris"')).toEqual({ value: 'Paris' });
expect(parseDraft('Paris')).toEqual({ error: expect.stringMatching(/double quotes/) });
expect(parseDraft('')).toEqual({ error: expect.stringMatching(/Type a JSON value/) });
});
});

describe('PropValue', () => {
it('edits a writable signal: Edit focuses a labelled field, Enter sends set-signal', async () => {
const call = vi.fn<Call>(async () => ({ ok: true, value: 5 }));
const fixture = mount({ name: 'count', prop: 'count', value: 1, edit: 'signal' }, call);
await settle(fixture);
buttons(fixture, 'Edit count')[0].click();
await settle(fixture);
const input = el(fixture).querySelector('input')!;
expect(document.activeElement).toBe(input);
expect(input.value).toBe('1');
const label = el(fixture).querySelector(`label[for="${input.id}"]`);
expect(label?.textContent).toContain('New value of count, as JSON');
expect(input.getAttribute('aria-describedby')).toContain('-hint');
type(input, '5');
key(input, 'Enter');
await settle(fixture);
expect(call).toHaveBeenCalledWith('request-component-edit', {
pageId: 'p1',
request: { id: 'c1', path: 'count', value: 5, kind: 'signal' },
});
expect(el(fixture).querySelector('input')).toBeNull();
expect(el(fixture).querySelector('[role="status"]')?.textContent).toBe('Set count.');
expect(document.activeElement).toBe(buttons(fixture, 'Edit count')[0]);
});

it('Escape cancels without writing and returns focus to Edit', async () => {
const call = vi.fn<Call>(async () => ({ ok: true }));
const fixture = mount({ name: 'price', prop: 'price', value: 120, edit: 'property' }, call);
await settle(fixture);
buttons(fixture, 'Edit price')[0].click();
await settle(fixture);
key(el(fixture).querySelector('input')!, 'Escape');
await settle(fixture);
expect(call).not.toHaveBeenCalled();
expect(el(fixture).querySelector('input')).toBeNull();
expect(document.activeElement).toBe(buttons(fixture, 'Edit price')[0]);
});

it('shows the refusal from the page and bad JSON as an alert tied to the field', async () => {
const call = vi.fn<Call>(async () => ({
error: '`price` is redacted, so it cannot be written.',
}));
const fixture = mount({ name: 'price', prop: 'price', value: 1, edit: 'property' }, call);
await settle(fixture);
buttons(fixture, 'Edit price')[0].click();
await settle(fixture);
const input = el(fixture).querySelector('input')!;
type(input, 'oops');
key(input, 'Enter');
await settle(fixture);
expect(call).not.toHaveBeenCalled();
const alert = el(fixture).querySelector('[role="alert"]')!;
expect(alert.textContent).toMatch(/Not valid JSON/);
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(input.getAttribute('aria-describedby')).toContain(alert.id);
type(input, '2');
buttons(fixture, 'Apply')[0].click();
await settle(fixture);
expect(el(fixture).querySelector('[role="alert"]')?.textContent).toContain(
'is redacted, so it cannot be written',
);
expect(el(fixture).querySelector('input')).not.toBeNull();
});

it('says why a read-only value cannot be edited, and offers no Edit', async () => {
const fixture = mount(
{
name: 'title',
prop: 'title',
value: 'Paris',
locked: 'Signal input. Its parent binding sets it, so it has no set().',
},
vi.fn<Call>(),
);
await settle(fixture);
expect(buttons(fixture, 'Edit title')).toHaveLength(0);
expect(el(fixture).textContent).toContain('Signal input. Its parent binding sets it');
});

it('stays read-only when writes are off', async () => {
const fixture = mount(
{ name: 'count', prop: 'count', value: 1, edit: 'signal' },
vi.fn<Call>(),
false,
);
await settle(fixture);
expect(buttons(fixture, 'Edit count')).toHaveLength(0);
});
});

describe('ComponentTree editing', () => {
const page = {
pageId: 'p1',
roots: [{ id: 'c1', name: 'Card', tag: 'app-card', children: [] }],
count: 1,
reportedAt: 1,
detail: {
id: 'c1',
name: 'Card',
tag: 'app-card',
path: 'app-card',
inputs: [{ name: 'title', prop: 'title', value: 'Paris', locked: 'Signal input.' }],
outputs: [],
properties: [{ name: 'count', prop: 'count', value: 1, kind: 'signal', edit: 'signal' }],
listeners: [],
directives: [],
dependencies: [],
},
};

async function open(configs: Record<string, unknown> = {}) {
const fixture = TestBed.createComponent(ComponentTree);
fixture.componentRef.setInput(
'rpc',
fakeClient(async () => [], { 'component-tree': { pages: { p1: page } } }, configs),
);
await settle(fixture);
// The page reports the detail of the instance it has selected, so the panel follows it.
expect(fixture.componentInstance.selectedId()).toBe('c1');
return fixture;
}

it('offers Edit for writable values of the selected component', async () => {
const fixture = await open();
expect(buttons(fixture, 'Edit count')).toHaveLength(1);
expect(buttons(fixture, 'Edit title')).toHaveLength(0);
expect(el(fixture).textContent).toContain('Signal input.');
});

it('offers no Edit when actions.components is off', async () => {
const fixture = await open({ pangular: { actions: { components: false } } });
expect(buttons(fixture, 'Edit count')).toHaveLength(0);
expect(el(fixture).textContent).not.toContain('Signal input.');
});
});
155 changes: 155 additions & 0 deletions app/src/__tests__/component-tree-for-blocks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { TestBed } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it } from 'vitest';
import { ComponentTree } from '../pages/component-tree';
import { StoreGlobal } from '../ui/store-global';

interface Call {
name: string;
payload: Record<string, unknown>;
}

function client(pages: Record<string, unknown>, calls: Call[] = []): DevframeRpcClient {
const rpc = {
call: (name: string, payload: Record<string, unknown>) => {
calls.push({ name, payload });
if (name === 'request-console-global') {
return Promise.resolve({ ok: true, name: 'temp1', message: 'Stored as temp1.' });
}
return Promise.resolve([]);
},
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({
value: () => (name === 'component-tree' ? { pages } : null),
on: () => () => {},
}),
};
return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
}

const basePage = {
pageId: 'p1',
roots: [{ id: 'c1', name: 'App', tag: 'app-root', children: [] }],
count: 1,
detail: null,
reportedAt: 1000,
};

async function render(page: Record<string, unknown>, calls: Call[] = []) {
const fixture = TestBed.createComponent(ComponentTree);
fixture.componentRef.setInput('rpc', client({ p1: page }, calls));
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
return { fixture, el: fixture.nativeElement as HTMLElement };
}

describe('@for blocks section', () => {
afterEach(() => {
document.body.innerHTML = '';
});

it('lists each block with its owner, item count, @empty and track kind', async () => {
const { el } = await render({
...basePage,
forBlocks: [
{
id: 'f1',
owner: { id: 'c1', name: 'App', tag: 'app-root' },
count: 2,
track: '$index',
hasEmptyBlock: false,
showingEmpty: false,
rootIds: [],
},
{
id: 'f2',
count: 0,
track: 'function',
hasEmptyBlock: true,
showingEmpty: true,
rootIds: [],
},
],
forBlocksTruncated: true,
});
const section = el.querySelector('section[aria-labelledby="ct-for-title"]')!;
expect(section.querySelector('h2')!.textContent).toContain('@for blocks');
expect(section.querySelector('.pill')!.textContent).toContain('2+');
expect(section.textContent).toContain('more @for blocks than the 2 listed');
const rows = Array.from(section.querySelectorAll<HTMLButtonElement>('.for-row'));
const text = (row: Element) =>
Array.from(row.children, (part) => part.textContent?.replace(/\s+/g, ' ').trim()).join(' ');
expect(rows.map(text)).toEqual([
'App 2 items track $index',
'Unknown component 0 items @empty showing track expression',
]);
expect(rows[1].getAttribute('aria-disabled')).toBe('true');
});

it('says when the page Angular is too old to report @for blocks', async () => {
const { el } = await render({ ...basePage, deferBlocks: [] });
expect(el.textContent).toContain('@for blocks are listed from Angular 22.0');
expect(el.querySelector('#ct-for-title')).toBeNull();
});

it('shows nothing when the page has no @for block', async () => {
const { el } = await render({ ...basePage, deferBlocks: [], forBlocks: [] });
expect(el.textContent).not.toContain('@for blocks');
});
});

describe('console handles in the component view', () => {
afterEach(() => {
document.body.innerHTML = '';
});

it('asks the page to set $ng0 when a component is selected', async () => {
const calls: Call[] = [];
const { el, fixture } = await render(basePage, calls);
el.querySelector<HTMLElement>('.row[data-id="c1"]')!.click();
await fixture.whenStable();
expect(calls.find((c) => c.name === 'select-component')?.payload).toEqual({
pageId: 'p1',
id: 'c1',
console: true,
});
});
});

describe('Store as global button', () => {
it('stores the target on its page and says which name it used', async () => {
const calls: Call[] = [];
const fixture = TestBed.createComponent(StoreGlobal);
fixture.componentRef.setInput('rpc', client({}, calls));
fixture.componentRef.setInput('pageId', 'p1');
fixture.componentRef.setInput('target', { kind: 'ngrx', storeId: 'store' });
fixture.componentRef.setInput('label', 'Store state');
await fixture.whenStable();
const el = fixture.nativeElement as HTMLElement;
const button = el.querySelector('button')!;
expect(button.textContent?.replace(/\s+/g, ' ').trim()).toBe('Store as global : Store state');
button.click();
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
expect(calls).toEqual([
{
name: 'request-console-global',
payload: { pageId: 'p1', target: { kind: 'ngrx', storeId: 'store' } },
},
]);
expect(el.querySelector('[role="status"]')!.textContent).toBe('Stored as temp1.');
});

it('is hidden in a saved report, which has no page', async () => {
const fixture = TestBed.createComponent(StoreGlobal);
fixture.componentRef.setInput('rpc', {
connectionMeta: { backend: 'static' },
} as unknown as DevframeRpcClient);
fixture.componentRef.setInput('target', { kind: 'component', id: 'c1' });
await fixture.whenStable();
expect((fixture.nativeElement as HTMLElement).querySelector('button')).toBeNull();
});
});
Loading
Loading