361 lines
11 KiB
TypeScript
361 lines
11 KiB
TypeScript
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('preserves metadata including turnIndex on createSurface', () => {
|
|
const manager = new A2UISurfaceManager();
|
|
|
|
manager.processMessage({
|
|
type: 'createSurface',
|
|
surfaceId: 'surface-1',
|
|
conversationId: 'conv-1',
|
|
metadata: { turnIndex: 3 },
|
|
});
|
|
|
|
const surface = manager.getSurface('surface-1');
|
|
expect(surface).toBeDefined();
|
|
expect(surface!.metadata).toEqual({ turnIndex: 3 });
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|