Files
bDS/tests/engine/a2ui/surfaceManager.test.ts

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');
});
});
});