wip: complete rework first round
This commit is contained in:
80
tests/engine/a2ui/catalog.test.ts
Normal file
80
tests/engine/a2ui/catalog.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getCatalogEntries,
|
||||
isSupportedComponentType,
|
||||
getCatalogEntry,
|
||||
getCatalogId,
|
||||
buildCatalogDescription,
|
||||
} from '../../../src/main/a2ui/catalog';
|
||||
import { BDS_CATALOG_ID } from '../../../src/main/a2ui/types';
|
||||
|
||||
describe('A2UI catalog', () => {
|
||||
it('returns all 17 catalog entries', () => {
|
||||
const entries = getCatalogEntries();
|
||||
expect(entries).toHaveLength(17);
|
||||
});
|
||||
|
||||
it('returns a copy of catalog entries to prevent mutation', () => {
|
||||
const entries1 = getCatalogEntries();
|
||||
const entries2 = getCatalogEntries();
|
||||
expect(entries1).not.toBe(entries2);
|
||||
expect(entries1).toEqual(entries2);
|
||||
});
|
||||
|
||||
it('recognises all supported component types', () => {
|
||||
const types = [
|
||||
'text', 'button', 'card', 'chart', 'table', 'form',
|
||||
'textField', 'checkBox', 'dateTimeInput', 'choicePicker',
|
||||
'image', 'tabs', 'metric', 'list', 'row', 'column', 'divider',
|
||||
];
|
||||
|
||||
for (const type of types) {
|
||||
expect(isSupportedComponentType(type)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsupported component types', () => {
|
||||
expect(isSupportedComponentType('video')).toBe(false);
|
||||
expect(isSupportedComponentType('slider')).toBe(false);
|
||||
expect(isSupportedComponentType('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns catalog entry by type', () => {
|
||||
const entry = getCatalogEntry('chart');
|
||||
expect(entry).toEqual({
|
||||
type: 'chart',
|
||||
description: 'Bar, line, or pie chart visualization',
|
||||
custom: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for unknown type', () => {
|
||||
expect(getCatalogEntry('unknown' as never)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the bDS catalog ID', () => {
|
||||
expect(getCatalogId()).toBe(BDS_CATALOG_ID);
|
||||
expect(getCatalogId()).toBe('bds-blogging-v1');
|
||||
});
|
||||
|
||||
it('builds a catalog description for LLM system prompt', () => {
|
||||
const description = buildCatalogDescription();
|
||||
expect(description).toContain('Supported UI component types:');
|
||||
expect(description).toContain('text: Text block with Markdown support');
|
||||
expect(description).toContain('chart: Bar, line, or pie chart visualization (custom)');
|
||||
expect(description).toContain('table: Data table with columns and rows (custom)');
|
||||
});
|
||||
|
||||
it('marks custom components correctly', () => {
|
||||
const entries = getCatalogEntries();
|
||||
const customEntries = entries.filter((e) => e.custom);
|
||||
const customTypes = customEntries.map((e) => e.type);
|
||||
|
||||
expect(customTypes).toContain('chart');
|
||||
expect(customTypes).toContain('table');
|
||||
expect(customTypes).toContain('metric');
|
||||
expect(customTypes).toContain('form');
|
||||
expect(customTypes).not.toContain('text');
|
||||
expect(customTypes).not.toContain('button');
|
||||
});
|
||||
});
|
||||
263
tests/engine/a2ui/generator.test.ts
Normal file
263
tests/engine/a2ui/generator.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isRenderTool,
|
||||
generateFromToolCall,
|
||||
generateChart,
|
||||
generateTable,
|
||||
generateForm,
|
||||
generateCard,
|
||||
generateMetric,
|
||||
generateList,
|
||||
generateTabs,
|
||||
} from '../../../src/main/a2ui/generator';
|
||||
import type { A2UIServerMessage } from '../../../src/main/a2ui/types';
|
||||
|
||||
describe('A2UI generator', () => {
|
||||
describe('isRenderTool', () => {
|
||||
it('returns true for all render tools', () => {
|
||||
expect(isRenderTool('render_chart')).toBe(true);
|
||||
expect(isRenderTool('render_table')).toBe(true);
|
||||
expect(isRenderTool('render_form')).toBe(true);
|
||||
expect(isRenderTool('render_card')).toBe(true);
|
||||
expect(isRenderTool('render_metric')).toBe(true);
|
||||
expect(isRenderTool('render_list')).toBe(true);
|
||||
expect(isRenderTool('render_tabs')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-render tools', () => {
|
||||
expect(isRenderTool('search_posts')).toBe(false);
|
||||
expect(isRenderTool('get_post')).toBe(false);
|
||||
expect(isRenderTool('render_unknown')).toBe(false);
|
||||
expect(isRenderTool('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateFromToolCall', () => {
|
||||
it('dispatches to chart generator', () => {
|
||||
const messages = generateFromToolCall('conv-1', 'render_chart', {
|
||||
chartType: 'bar',
|
||||
series: [{ label: 'A', value: 1 }],
|
||||
});
|
||||
|
||||
expect(messages).not.toBeNull();
|
||||
expect(messages!.length).toBeGreaterThanOrEqual(2);
|
||||
expect(messages![0].type).toBe('createSurface');
|
||||
});
|
||||
|
||||
it('returns null for unknown tool', () => {
|
||||
expect(generateFromToolCall('conv-1', 'search_posts', {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateChart', () => {
|
||||
it('creates surface with chart component and data binding', () => {
|
||||
const messages = generateChart('conv-1', {
|
||||
chartType: 'bar',
|
||||
title: 'Sales',
|
||||
series: [
|
||||
{ label: 'Jan', value: 10 },
|
||||
{ label: 'Feb', value: 20 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(3); // createSurface + updateComponents + updateDataModel
|
||||
|
||||
const createMsg = messages[0] as Extract<A2UIServerMessage, { type: 'createSurface' }>;
|
||||
expect(createMsg.type).toBe('createSurface');
|
||||
expect(createMsg.conversationId).toBe('conv-1');
|
||||
|
||||
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||
expect(updateMsg.type).toBe('updateComponents');
|
||||
expect(updateMsg.components).toHaveLength(1);
|
||||
expect(updateMsg.components[0].type).toBe('chart');
|
||||
expect(updateMsg.components[0].properties.chartType).toBe('bar');
|
||||
expect(updateMsg.components[0].dataBinding).toBe('/chartData');
|
||||
expect(updateMsg.rootIds).toHaveLength(1);
|
||||
|
||||
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
|
||||
expect(dataMsg.type).toBe('updateDataModel');
|
||||
expect(dataMsg.path).toBe('/chartData');
|
||||
expect(dataMsg.value).toEqual([
|
||||
{ label: 'Jan', value: 10 },
|
||||
{ label: 'Feb', value: 20 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTable', () => {
|
||||
it('creates surface with table component and row data', () => {
|
||||
const messages = generateTable('conv-1', {
|
||||
title: 'Posts',
|
||||
columns: ['Title', 'Status'],
|
||||
rows: [['Hello', 'published'], ['Draft', 'draft']],
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(3);
|
||||
|
||||
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||
expect(updateMsg.components[0].type).toBe('table');
|
||||
expect(updateMsg.components[0].properties.columns).toEqual(['Title', 'Status']);
|
||||
|
||||
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
|
||||
expect(dataMsg.path).toBe('/tableRows');
|
||||
expect(dataMsg.value).toEqual([['Hello', 'published'], ['Draft', 'draft']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCard', () => {
|
||||
it('creates surface with card component', () => {
|
||||
const messages = generateCard('conv-1', {
|
||||
title: 'My Card',
|
||||
body: 'Card body text',
|
||||
subtitle: 'Optional subtitle',
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(2); // No data model for card
|
||||
|
||||
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||
expect(updateMsg.components[0].type).toBe('card');
|
||||
expect(updateMsg.components[0].properties.title).toBe('My Card');
|
||||
expect(updateMsg.components[0].properties.body).toBe('Card body text');
|
||||
});
|
||||
|
||||
it('includes card actions when provided', () => {
|
||||
const messages = generateCard('conv-1', {
|
||||
title: 'Action Card',
|
||||
body: 'Has actions',
|
||||
actions: [{ label: 'Open', action: 'openPost', payload: { postId: 'p1' } }],
|
||||
});
|
||||
|
||||
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||
expect(updateMsg.components[0].actions).toEqual([
|
||||
{ eventType: 'click', action: 'openPost', payload: { postId: 'p1' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMetric', () => {
|
||||
it('creates surface with metric component', () => {
|
||||
const messages = generateMetric('conv-1', {
|
||||
label: 'Total Posts',
|
||||
value: '42',
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
|
||||
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||
expect(updateMsg.components[0].type).toBe('metric');
|
||||
expect(updateMsg.components[0].properties.label).toBe('Total Posts');
|
||||
expect(updateMsg.components[0].properties.value).toBe('42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateList', () => {
|
||||
it('creates surface with list component and item data', () => {
|
||||
const messages = generateList('conv-1', {
|
||||
title: 'Tags',
|
||||
items: ['react', 'typescript', 'electron'],
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(3);
|
||||
|
||||
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||
expect(updateMsg.components[0].type).toBe('list');
|
||||
|
||||
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
|
||||
expect(dataMsg.path).toBe('/listItems');
|
||||
expect(dataMsg.value).toEqual(['react', 'typescript', 'electron']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateForm', () => {
|
||||
it('creates surface with form, field components, and submit button', () => {
|
||||
const messages = generateForm('conv-1', {
|
||||
title: 'Edit Post',
|
||||
submitLabel: 'Save',
|
||||
fields: [
|
||||
{ key: 'title', label: 'Title', inputType: 'text', defaultValue: 'Hello' },
|
||||
{ key: 'draft', label: 'Draft', inputType: 'checkbox', defaultValue: true },
|
||||
],
|
||||
});
|
||||
|
||||
// createSurface + updateComponents + 2 updateDataModel (one per default value)
|
||||
expect(messages).toHaveLength(4);
|
||||
|
||||
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||
// form + 2 fields + 1 submit button = 4
|
||||
expect(updateMsg.components).toHaveLength(4);
|
||||
|
||||
const formComponent = updateMsg.components.find((c) => c.type === 'form');
|
||||
expect(formComponent).toBeDefined();
|
||||
expect(formComponent!.children).toHaveLength(3); // 2 fields + submit button
|
||||
|
||||
const textField = updateMsg.components.find((c) => c.type === 'textField');
|
||||
expect(textField).toBeDefined();
|
||||
expect(textField!.dataBinding).toBe('/formData/title');
|
||||
|
||||
const checkBox = updateMsg.components.find((c) => c.type === 'checkBox');
|
||||
expect(checkBox).toBeDefined();
|
||||
expect(checkBox!.dataBinding).toBe('/formData/draft');
|
||||
|
||||
const submitButton = updateMsg.components.find((c) => c.type === 'button');
|
||||
expect(submitButton).toBeDefined();
|
||||
expect(submitButton!.properties.label).toBe('Save');
|
||||
});
|
||||
|
||||
it('maps select inputType to choicePicker', () => {
|
||||
const messages = generateForm('conv-1', {
|
||||
submitLabel: 'Go',
|
||||
fields: [
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
inputType: 'select',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||
const picker = updateMsg.components.find((c) => c.type === 'choicePicker');
|
||||
expect(picker).toBeDefined();
|
||||
});
|
||||
|
||||
it('maps date inputType to dateTimeInput', () => {
|
||||
const messages = generateForm('conv-1', {
|
||||
submitLabel: 'Set',
|
||||
fields: [{ key: 'date', label: 'Date', inputType: 'date' }],
|
||||
});
|
||||
|
||||
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||
const dateInput = updateMsg.components.find((c) => c.type === 'dateTimeInput');
|
||||
expect(dateInput).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTabs', () => {
|
||||
it('creates surface with tabs and child components', () => {
|
||||
const messages = generateTabs('conv-1', {
|
||||
tabs: [
|
||||
{
|
||||
label: 'Overview',
|
||||
content: [{ type: 'text', text: 'Tab content' }],
|
||||
},
|
||||
{
|
||||
label: 'Details',
|
||||
content: [{ type: 'metric', label: 'Count', value: '5' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(2); // createSurface + updateComponents
|
||||
|
||||
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||
const tabsComponent = updateMsg.components.find((c) => c.type === 'tabs');
|
||||
expect(tabsComponent).toBeDefined();
|
||||
expect(tabsComponent!.children).toHaveLength(2);
|
||||
expect(tabsComponent!.properties.tabLabels).toEqual(['Overview', 'Details']);
|
||||
});
|
||||
});
|
||||
});
|
||||
345
tests/engine/a2ui/surfaceManager.test.ts
Normal file
345
tests/engine/a2ui/surfaceManager.test.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
A2UISurfaceManager,
|
||||
getValueAtPointer,
|
||||
setValueAtPointer,
|
||||
} from '../../../src/renderer/a2ui/A2UISurfaceManager';
|
||||
import type { A2UIServerMessage, A2UIComponent } from '../../../src/main/a2ui/types';
|
||||
|
||||
describe('A2UISurfaceManager', () => {
|
||||
function createTestComponent(overrides: Partial<A2UIComponent> = {}): A2UIComponent {
|
||||
return {
|
||||
id: 'comp-1',
|
||||
type: 'text',
|
||||
properties: { text: 'Hello' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createSurface', () => {
|
||||
it('creates a new surface with empty state', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
|
||||
manager.processMessage({
|
||||
type: 'createSurface',
|
||||
surfaceId: 'surface-1',
|
||||
conversationId: 'conv-1',
|
||||
});
|
||||
|
||||
const surface = manager.getSurface('surface-1');
|
||||
expect(surface).toBeDefined();
|
||||
expect(surface!.conversationId).toBe('conv-1');
|
||||
expect(surface!.components.size).toBe(0);
|
||||
expect(surface!.rootIds).toEqual([]);
|
||||
expect(surface!.dataModel).toEqual({});
|
||||
});
|
||||
|
||||
it('notifies listeners on surface creation', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
const listener = vi.fn();
|
||||
manager.onChange(listener);
|
||||
|
||||
manager.processMessage({
|
||||
type: 'createSurface',
|
||||
surfaceId: 'surface-1',
|
||||
conversationId: 'conv-1',
|
||||
});
|
||||
|
||||
expect(listener).toHaveBeenCalledWith('surface-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateComponents', () => {
|
||||
it('adds components to an existing surface', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
|
||||
manager.processMessage({
|
||||
type: 'createSurface',
|
||||
surfaceId: 'surface-1',
|
||||
conversationId: 'conv-1',
|
||||
});
|
||||
|
||||
const component = createTestComponent();
|
||||
manager.processMessage({
|
||||
type: 'updateComponents',
|
||||
surfaceId: 'surface-1',
|
||||
components: [component],
|
||||
rootIds: ['comp-1'],
|
||||
});
|
||||
|
||||
const surface = manager.getSurface('surface-1');
|
||||
expect(surface!.components.size).toBe(1);
|
||||
expect(surface!.components.get('comp-1')).toEqual(component);
|
||||
expect(surface!.rootIds).toEqual(['comp-1']);
|
||||
});
|
||||
|
||||
it('ignores updateComponents for non-existent surfaces', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
const listener = vi.fn();
|
||||
manager.onChange(listener);
|
||||
|
||||
manager.processMessage({
|
||||
type: 'updateComponents',
|
||||
surfaceId: 'nonexistent',
|
||||
components: [createTestComponent()],
|
||||
rootIds: ['comp-1'],
|
||||
});
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDataModel', () => {
|
||||
it('sets a value in the data model', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
|
||||
manager.processMessage({
|
||||
type: 'createSurface',
|
||||
surfaceId: 'surface-1',
|
||||
conversationId: 'conv-1',
|
||||
});
|
||||
|
||||
manager.processMessage({
|
||||
type: 'updateDataModel',
|
||||
surfaceId: 'surface-1',
|
||||
path: '/chartData',
|
||||
value: [{ label: 'A', value: 1 }],
|
||||
});
|
||||
|
||||
const dataModel = manager.getDataModel('surface-1');
|
||||
expect(dataModel).toEqual({ chartData: [{ label: 'A', value: 1 }] });
|
||||
});
|
||||
|
||||
it('ignores updateDataModel for non-existent surfaces', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
|
||||
manager.processMessage({
|
||||
type: 'updateDataModel',
|
||||
surfaceId: 'nonexistent',
|
||||
path: '/foo',
|
||||
value: 'bar',
|
||||
});
|
||||
|
||||
expect(manager.getDataModel('nonexistent')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSurface', () => {
|
||||
it('removes a surface', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
|
||||
manager.processMessage({
|
||||
type: 'createSurface',
|
||||
surfaceId: 'surface-1',
|
||||
conversationId: 'conv-1',
|
||||
});
|
||||
|
||||
expect(manager.getSurface('surface-1')).toBeDefined();
|
||||
|
||||
manager.processMessage({
|
||||
type: 'deleteSurface',
|
||||
surfaceId: 'surface-1',
|
||||
});
|
||||
|
||||
expect(manager.getSurface('surface-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSurfaceIds', () => {
|
||||
it('returns surface IDs for a specific conversation', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
|
||||
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||
manager.processMessage({ type: 'createSurface', surfaceId: 's2', conversationId: 'conv-1' });
|
||||
manager.processMessage({ type: 'createSurface', surfaceId: 's3', conversationId: 'conv-2' });
|
||||
|
||||
expect(manager.getSurfaceIds('conv-1')).toEqual(['s1', 's2']);
|
||||
expect(manager.getSurfaceIds('conv-2')).toEqual(['s3']);
|
||||
expect(manager.getSurfaceIds('conv-3')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTree', () => {
|
||||
it('resolves a flat component buffer into a nested tree', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
|
||||
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||
manager.processMessage({
|
||||
type: 'updateComponents',
|
||||
surfaceId: 's1',
|
||||
components: [
|
||||
{ id: 'root', type: 'column', properties: {}, children: ['child-1', 'child-2'] },
|
||||
{ id: 'child-1', type: 'text', properties: { text: 'Hello' } },
|
||||
{ id: 'child-2', type: 'button', properties: { label: 'Click' } },
|
||||
],
|
||||
rootIds: ['root'],
|
||||
});
|
||||
|
||||
const tree = manager.resolveTree('s1');
|
||||
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].type).toBe('column');
|
||||
expect(tree[0].children).toHaveLength(2);
|
||||
expect(tree[0].children[0].type).toBe('text');
|
||||
expect(tree[0].children[0].properties.text).toBe('Hello');
|
||||
expect(tree[0].children[1].type).toBe('button');
|
||||
});
|
||||
|
||||
it('resolves data bindings to bound values', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
|
||||
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||
manager.processMessage({
|
||||
type: 'updateComponents',
|
||||
surfaceId: 's1',
|
||||
components: [
|
||||
{ id: 'chart-1', type: 'chart', properties: { chartType: 'bar' }, dataBinding: '/data' },
|
||||
],
|
||||
rootIds: ['chart-1'],
|
||||
});
|
||||
manager.processMessage({
|
||||
type: 'updateDataModel',
|
||||
surfaceId: 's1',
|
||||
path: '/data',
|
||||
value: [1, 2, 3],
|
||||
});
|
||||
|
||||
const tree = manager.resolveTree('s1');
|
||||
expect(tree[0].boundValue).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('returns empty array for non-existent surface', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
expect(manager.resolveTree('nonexistent')).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters out unresolvable child references', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
|
||||
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||
manager.processMessage({
|
||||
type: 'updateComponents',
|
||||
surfaceId: 's1',
|
||||
components: [
|
||||
{ id: 'root', type: 'column', properties: {}, children: ['child-1', 'missing'] },
|
||||
{ id: 'child-1', type: 'text', properties: { text: 'Hello' } },
|
||||
],
|
||||
rootIds: ['root'],
|
||||
});
|
||||
|
||||
const tree = manager.resolveTree('s1');
|
||||
expect(tree[0].children).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLocalData', () => {
|
||||
it('updates data model and notifies listeners', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
const listener = vi.fn();
|
||||
|
||||
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||
manager.onChange(listener);
|
||||
|
||||
manager.updateLocalData('s1', '/formData/name', 'John');
|
||||
|
||||
expect(manager.getDataModel('s1')).toEqual({ formData: { name: 'John' } });
|
||||
expect(listener).toHaveBeenCalledWith('s1');
|
||||
});
|
||||
|
||||
it('ignores updates for non-existent surfaces', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
const listener = vi.fn();
|
||||
manager.onChange(listener);
|
||||
|
||||
manager.updateLocalData('nonexistent', '/foo', 'bar');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearConversation', () => {
|
||||
it('removes all surfaces for a conversation', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
|
||||
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||
manager.processMessage({ type: 'createSurface', surfaceId: 's2', conversationId: 'conv-1' });
|
||||
manager.processMessage({ type: 'createSurface', surfaceId: 's3', conversationId: 'conv-2' });
|
||||
|
||||
manager.clearConversation('conv-1');
|
||||
|
||||
expect(manager.getSurfaceIds('conv-1')).toEqual([]);
|
||||
expect(manager.getSurfaceIds('conv-2')).toEqual(['s3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChange', () => {
|
||||
it('returns an unsubscribe function', () => {
|
||||
const manager = new A2UISurfaceManager();
|
||||
const listener = vi.fn();
|
||||
|
||||
const unsubscribe = manager.onChange(listener);
|
||||
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
manager.processMessage({ type: 'createSurface', surfaceId: 's2', conversationId: 'conv-1' });
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON Pointer utilities', () => {
|
||||
describe('getValueAtPointer', () => {
|
||||
it('returns the root object for empty or "/" pointer', () => {
|
||||
const obj = { foo: 'bar' };
|
||||
expect(getValueAtPointer(obj, '')).toBe(obj);
|
||||
expect(getValueAtPointer(obj, '/')).toBe(obj);
|
||||
});
|
||||
|
||||
it('gets a top-level value', () => {
|
||||
expect(getValueAtPointer({ name: 'Alice' }, '/name')).toBe('Alice');
|
||||
});
|
||||
|
||||
it('gets a nested value', () => {
|
||||
const obj = { a: { b: { c: 42 } } };
|
||||
expect(getValueAtPointer(obj, '/a/b/c')).toBe(42);
|
||||
});
|
||||
|
||||
it('returns undefined for missing paths', () => {
|
||||
expect(getValueAtPointer({ a: 1 }, '/b')).toBeUndefined();
|
||||
expect(getValueAtPointer({ a: 1 }, '/a/b/c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles escaped pointer characters', () => {
|
||||
const obj = { 'a/b': { '~c': 'value' } };
|
||||
expect(getValueAtPointer(obj, '/a~1b/~0c')).toBe('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setValueAtPointer', () => {
|
||||
it('sets a top-level value', () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setValueAtPointer(obj, '/name', 'Alice');
|
||||
expect(obj.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('creates intermediate objects for nested paths', () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setValueAtPointer(obj, '/a/b/c', 42);
|
||||
expect(obj).toEqual({ a: { b: { c: 42 } } });
|
||||
});
|
||||
|
||||
it('does nothing for empty or root pointer', () => {
|
||||
const obj = { foo: 'bar' };
|
||||
setValueAtPointer(obj, '', 'new');
|
||||
setValueAtPointer(obj, '/', 'new');
|
||||
expect(obj).toEqual({ foo: 'bar' });
|
||||
});
|
||||
|
||||
it('handles escaped pointer characters', () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setValueAtPointer(obj, '/a~1b', 'value');
|
||||
expect(obj['a/b']).toBe('value');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user