diff --git a/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts b/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts index f612c5f8e..58fab557a 100644 --- a/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts +++ b/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts @@ -17,7 +17,7 @@ import { Injectable } from '@angular/core'; import { A2uiRendererService } from '@a2ui/angular/v0_9'; -import { A2uiClientAction, A2uiMessage } from '@a2ui/web_core/v0_9'; +import { A2uiClientAction, A2uiMessage, CreateSurfaceMessage } from '@a2ui/web_core/v0_9'; import { ActionDispatcher } from './action-dispatcher.service'; /** diff --git a/renderers/angular/a2ui_explorer/src/app/card.component.ts b/renderers/angular/a2ui_explorer/src/app/card.component.ts index d4089e5ee..61ff8887e 100644 --- a/renderers/angular/a2ui_explorer/src/app/card.component.ts +++ b/renderers/angular/a2ui_explorer/src/app/card.component.ts @@ -32,8 +32,8 @@ import { BoundProperty } from '@a2ui/angular/v0_9'; style="border: 1px solid #ccc; padding: 16px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 16px; background-color: white;" > diff --git a/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts b/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts index bc8d6915f..ec083a161 100644 --- a/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts +++ b/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts @@ -28,12 +28,12 @@ import { BoundProperty } from '@a2ui/angular/v0_9'; imports: [CommonModule], template: `
- +
@@ -57,6 +57,6 @@ export class CustomSliderComponent { handleInput(event: Event) { const val = Number((event.target as HTMLInputElement).value); - this.props['value']?.onUpdate(val); + this.props['value']?.set(val); } } diff --git a/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts b/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts index 9d5fd2bce..13402ea8d 100644 --- a/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts +++ b/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts @@ -30,6 +30,13 @@ import { createFunctionImplementation, FunctionImplementation } from '@a2ui/web_ }) export class DemoCatalog extends BasicCatalogBase { constructor() { + const cardApi: AngularComponentImplementation = { + name: 'Card', + schema: z.object({ + child: z.string().optional(), + }) as any, + component: CardComponent, + }; const customSliderApi: AngularComponentImplementation = { name: 'CustomSlider', schema: z.object({ @@ -41,14 +48,6 @@ export class DemoCatalog extends BasicCatalogBase { component: CustomSliderComponent, }; - const cardApi: AngularComponentImplementation = { - name: 'Card', - schema: z.object({ - child: z.string().optional(), - }) as any, - component: CardComponent, - }; - const capitalizeImplementation: FunctionImplementation = createFunctionImplementation( { name: 'capitalize', diff --git a/renderers/angular/a2ui_explorer/src/app/demo.component.ts b/renderers/angular/a2ui_explorer/src/app/demo.component.ts index b17f96764..13f9c6a51 100644 --- a/renderers/angular/a2ui_explorer/src/app/demo.component.ts +++ b/renderers/angular/a2ui_explorer/src/app/demo.component.ts @@ -19,9 +19,10 @@ import { CommonModule } from '@angular/common'; import { A2uiRendererService, A2UI_RENDERER_CONFIG } from '@a2ui/angular/v0_9'; import { AgentStubService } from './agent-stub.service'; import { SurfaceComponent } from '@a2ui/angular/v0_9'; -import { AngularCatalog } from '@a2ui/angular/v0_9'; +import { AngularCatalog, AngularComponentImplementation } from '@a2ui/angular/v0_9'; +import { createFunctionImplementation, FunctionImplementation } from '@a2ui/web_core/v0_9'; import { DemoCatalog } from './demo-catalog'; -import { A2uiClientAction, CreateSurfaceMessage } from '@a2ui/web_core/v0_9'; +import { A2uiClientAction, A2uiMessage, CreateSurfaceMessage } from '@a2ui/web_core/v0_9'; import { EXAMPLES } from './examples-bundle'; import { Example } from './types'; import { ActionDispatcher } from './action-dispatcher.service'; diff --git a/renderers/angular/src/v0_8/components/modal.ts b/renderers/angular/src/v0_8/components/modal.ts index 78e9aa659..10df7d6f1 100644 --- a/renderers/angular/src/v0_8/components/modal.ts +++ b/renderers/angular/src/v0_8/components/modal.ts @@ -25,11 +25,7 @@ import { Types } from '../types'; template: `
@if (entryPointChild()) { - + }
@@ -37,11 +33,7 @@ import { Types } from '../types';
@if (contentChild()) { - + }
diff --git a/renderers/angular/src/v0_9/catalog/basic/audio-player.component.ts b/renderers/angular/src/v0_9/catalog/basic/audio-player.component.ts index 8bdcb446a..19bbbe0e4 100644 --- a/renderers/angular/src/v0_9/catalog/basic/audio-player.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/audio-player.component.ts @@ -70,6 +70,6 @@ export class AudioPlayerComponent { componentId = input(); dataContextPath = input(); - description = computed(() => this.props()['description']?.value()); - url = computed(() => this.props()['url']?.value()); + description = computed(() => this.props()['description']?.()); + url = computed(() => this.props()['url']?.()); } diff --git a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts index 16c24b259..28a0a0ee1 100644 --- a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts @@ -20,6 +20,8 @@ import { ButtonComponent } from './button.component'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; import { By } from '@angular/platform-browser'; +import { BoundProperty } from '../../core/types'; +import { createBoundProperty, StubComponent, createMockA2uiRendererService, createMockComponentBinder } from '../../testing'; describe('ButtonComponent', () => { let component: ButtonComponent; @@ -35,40 +37,14 @@ describe('ButtonComponent', () => { ['child1', { id: 'child1', type: 'Text', properties: { text: 'Child Content' } }], ]), catalog: { - id: 'test-catalog', - components: new Map([ - [ - 'Text', - { - component: (() => { - @Component({ - standalone: true, - selector: 'dummy-text', - template: 'Dummy Text', - }) - class DummyText { - props = input(); - surfaceId = input(); - componentId = input(); - dataContextPath = input(); - } - return DummyText; - })(), - }, - ], - ]), + id: 'mock-catalog', + components: new Map([['Text', { type: 'Text', component: StubComponent }]]), }, }; - mockSurfaceGroup = { - getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface), - }; - - mockRendererService = { - surfaceGroup: mockSurfaceGroup, - }; - - const mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + mockRendererService = createMockA2uiRendererService(mockSurface); + mockSurfaceGroup = mockRendererService.surfaceGroup; + const mockBinder = createMockComponentBinder(); mockBinder.bind.and.returnValue({ text: { value: () => 'bound text' } }); await TestBed.configureTestingModule({ @@ -84,14 +60,11 @@ describe('ButtonComponent', () => { fixture.componentRef.setInput('surfaceId', 'surf1'); fixture.componentRef.setInput('componentId', 'comp1'); fixture.componentRef.setInput('props', { - variant: { value: signal('primary'), raw: 'primary', onUpdate: () => {} }, - child: { value: signal('child1'), raw: 'child1', onUpdate: () => {} }, - action: { - value: signal({ type: 'test-action', data: {} }), - raw: { type: 'test-action', data: {} }, - onUpdate: () => {}, - }, + variant: createBoundProperty('primary'), + child: createBoundProperty('child1'), + action: createBoundProperty({ type: 'test-action', data: {} }), }); + fixture.componentRef.setInput('componentId', 'test-btn'); }); it('should create', () => { @@ -108,11 +81,7 @@ describe('ButtonComponent', () => { it('should set button type to button for non-primary variant', () => { fixture.componentRef.setInput('props', { ...component.props(), - variant: { - value: signal('secondary'), - raw: 'secondary', - onUpdate: () => {}, - }, + variant: createBoundProperty('secondary'), }); fixture.detectChanges(); const button = fixture.debugElement.query(By.css('button')); @@ -131,7 +100,7 @@ describe('ButtonComponent', () => { button.triggerEventHandler('click', null); expect(mockSurfaceGroup.getSurface).toHaveBeenCalledWith('surf1'); - expect(mockSurface.dispatchAction).toHaveBeenCalledWith(jasmine.any(Object), 'comp1'); + expect(mockSurface.dispatchAction).toHaveBeenCalledWith(jasmine.any(Object), 'test-btn'); }); it('should show child component host if child prop is present', () => { @@ -144,7 +113,7 @@ describe('ButtonComponent', () => { it('should not show child component host if child prop is absent', () => { fixture.componentRef.setInput('props', { ...component.props(), - child: { value: signal(null), raw: null, onUpdate: () => {} }, + child: createBoundProperty(null), }); fixture.detectChanges(); const host = fixture.debugElement.query(By.css('a2ui-v09-component-host')); diff --git a/renderers/angular/src/v0_9/catalog/basic/button.component.ts b/renderers/angular/src/v0_9/catalog/basic/button.component.ts index d0a72589c..80fd6d69d 100644 --- a/renderers/angular/src/v0_9/catalog/basic/button.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/button.component.ts @@ -85,9 +85,9 @@ export class ButtonComponent { private rendererService = inject(A2uiRendererService); - variant = computed(() => this.props()['variant']?.value() ?? 'default'); - child = computed(() => this.props()['child']?.value()); - action = computed(() => this.props()['action']?.value()); + variant = computed(() => this.props()['variant']?.() ?? 'default'); + child = computed(() => this.props()['child']?.()); + action = computed(() => this.props()['action']?.()); handleClick() { const action = this.action(); diff --git a/renderers/angular/src/v0_9/catalog/basic/card.component.ts b/renderers/angular/src/v0_9/catalog/basic/card.component.ts index f53ede01b..32c2ff714 100644 --- a/renderers/angular/src/v0_9/catalog/basic/card.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/card.component.ts @@ -61,8 +61,8 @@ export class CardComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); - child = computed(() => this.props()['child']?.value()); + child = computed(() => this.props()['child']?.()); } diff --git a/renderers/angular/src/v0_9/catalog/basic/check-box.component.ts b/renderers/angular/src/v0_9/catalog/basic/check-box.component.ts index e972a2fc5..f32e68c44 100644 --- a/renderers/angular/src/v0_9/catalog/basic/check-box.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/check-box.component.ts @@ -70,16 +70,16 @@ export class CheckBoxComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); private rendererService = inject(A2uiRendererService); - value = computed(() => this.props()['value']?.value() === true); - label = computed(() => this.props()['label']?.value()); + value = computed(() => this.props()['value']?.() === true); + label = computed(() => this.props()['label']?.()); handleChange(event: Event) { const checked = (event.target as HTMLInputElement).checked; - this.props()['value']?.onUpdate(checked); + this.props()['value']?.set(checked); } } diff --git a/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts b/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts index 0b156aa21..119661377 100644 --- a/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts @@ -122,12 +122,10 @@ export class ChoicePickerComponent { private rendererService = inject(A2uiRendererService); - displayStyle = computed(() => this.props()['displayStyle']?.value()); - choices = computed( - () => this.props()['choices']?.value() || this.props()['options']?.value() || [], - ); - variant = computed(() => this.props()['variant']?.value()); - selectedValue = computed(() => this.props()['value']?.value()); + displayStyle = computed(() => this.props()['displayStyle']?.()); + choices = computed(() => this.props()['choices']?.() || this.props()['options']?.() || []); + variant = computed(() => this.props()['variant']?.()); + selectedValue = computed(() => this.props()['value']?.()); isMultiple(): boolean { return this.variant() === 'multipleSelection'; @@ -160,10 +158,10 @@ export class ChoicePickerComponent { } else { next = next.filter((v: any) => v !== value); } - this.props()['value']?.onUpdate(next); + this.props()['value']?.set(next); } else { if (active) { - this.props()['value']?.onUpdate(value); + this.props()['value']?.set(value); } } } diff --git a/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts index 777ed4786..89e490337 100644 --- a/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts @@ -15,23 +15,12 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, input, signal } from '@angular/core'; import { ColumnComponent } from './column.component'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; import { By } from '@angular/platform-browser'; +import { createBoundProperty, StubComponent, createMockA2uiRendererService, createMockComponentBinder } from '../../testing'; -@Component({ - standalone: true, - selector: 'dummy-child', - template: 'Dummy Child', -}) -class DummyChild { - props = input(); - surfaceId = input(); - componentId = input(); - dataContextPath = input(); -} describe('ColumnComponent', () => { let component: ColumnComponent; @@ -50,19 +39,14 @@ describe('ColumnComponent', () => { ]), catalog: { id: 'test-catalog', - components: new Map([['Child', { component: DummyChild }]]), + components: new Map([['Child', { component: StubComponent }]]), }, }; - mockSurfaceGroup = { - getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface), - }; - - mockRendererService = { - surfaceGroup: mockSurfaceGroup, - }; + mockRendererService = createMockA2uiRendererService(mockSurface); + mockSurfaceGroup = mockRendererService.surfaceGroup; - mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + mockBinder = createMockComponentBinder(); mockBinder.bind.and.returnValue({ text: { value: () => 'bound' } }); await TestBed.configureTestingModule({ @@ -77,13 +61,9 @@ describe('ColumnComponent', () => { component = fixture.componentInstance; fixture.componentRef.setInput('surfaceId', 'surf1'); fixture.componentRef.setInput('props', { - justify: { value: signal('start'), raw: 'start', onUpdate: () => {} }, - align: { value: signal('stretch'), raw: 'stretch', onUpdate: () => {} }, - children: { - value: signal(['child1', 'child2']), - raw: ['child1', 'child2'], - onUpdate: () => {}, - }, + justify: createBoundProperty('start'), + align: createBoundProperty('stretch'), + children: createBoundProperty(['child1', 'child2']), }); }); @@ -111,14 +91,10 @@ describe('ColumnComponent', () => { it('should render repeating children', () => { fixture.componentRef.setInput('props', { ...component.props(), - children: { - value: signal([{}, {}]), - raw: { - componentId: 'template1', - path: 'items', - }, - onUpdate: () => {}, - }, + children: createBoundProperty([{}, {}], { + componentId: 'template1', + path: 'items', + }), }); fixture.detectChanges(); diff --git a/renderers/angular/src/v0_9/catalog/basic/column.component.ts b/renderers/angular/src/v0_9/catalog/basic/column.component.ts index 2bae36f54..4f1e82e39 100644 --- a/renderers/angular/src/v0_9/catalog/basic/column.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/column.component.ts @@ -73,14 +73,14 @@ export class ColumnComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); - protected justify = computed(() => this.props()['justify']?.value()); - protected align = computed(() => this.props()['align']?.value()); + protected justify = computed(() => this.props()['justify']?.()); + protected align = computed(() => this.props()['align']?.()); protected children = computed(() => { - const raw = this.props()['children']?.value() || []; + const raw = this.props()['children']?.() || []; return Array.isArray(raw) ? raw : []; }); diff --git a/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts b/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts index 07d427d9d..1c79d916c 100644 --- a/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts @@ -15,7 +15,7 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, signal as angularSignal, input } from '@angular/core'; +import { Component, signal, input } from '@angular/core'; import { CheckBoxComponent } from './check-box.component'; import { ChoicePickerComponent } from './choice-picker.component'; import { SliderComponent } from './slider.component'; @@ -24,6 +24,7 @@ import { ListComponent } from './list.component'; import { TabsComponent } from './tabs.component'; import { ModalComponent } from './modal.component'; import { BoundProperty } from '../../core/types'; +import { createBoundProperty, StubComponent, createMockA2uiRendererService, createMockComponentBinder } from '../../testing'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; import { By } from '@angular/platform-browser'; @@ -33,65 +34,23 @@ describe('Complex Components', () => { let mockBinder: any; beforeEach(() => { - mockRendererService = { - surfaceGroup: { - getSurface: jasmine.createSpy('getSurface').and.returnValue({ - componentsModel: new Map([ - [ - 'child-1', - { id: 'child-1', type: 'Text', properties: { text: { value: 'Child 1' } } }, - ], - [ - 'child-2', - { id: 'child-2', type: 'Text', properties: { text: { value: 'Child 2' } } }, - ], - [ - 'content-1', - { id: 'content-1', type: 'Text', properties: { text: { value: 'Content 1' } } }, - ], - [ - 'content-2', - { id: 'content-2', type: 'Text', properties: { text: { value: 'Content 2' } } }, - ], - [ - 'trigger-btn', - { id: 'trigger-btn', type: 'Text', properties: { text: { value: 'Open' } } }, - ], - [ - 'modal-content', - { id: 'modal-content', type: 'Text', properties: { text: { value: 'Modal' } } }, - ], - ]), - catalog: { - id: 'mock-catalog', - components: new Map([['Text', { type: 'Text', component: DummyTextComponent }]]), - }, - }), + mockRendererService = createMockA2uiRendererService({ + componentsModel: new Map([ + ['child-1', { id: 'child-1', type: 'Text', properties: { text: { value: 'Child 1' } } }], + ['child-2', { id: 'child-2', type: 'Text', properties: { text: { value: 'Child 2' } } }], + ['content-1', { id: 'content-1', type: 'Text', properties: { text: { value: 'Content 1' } } }], + ['content-2', { id: 'content-2', type: 'Text', properties: { text: { value: 'Content 2' } } }], + ['trigger-btn', { id: 'trigger-btn', type: 'Text', properties: { text: { value: 'Open' } } }], + ['modal-content', { id: 'modal-content', type: 'Text', properties: { text: { value: 'Modal' } } }], + ]), + catalog: { + id: 'mock-catalog', + components: new Map([['Text', { type: 'Text', component: StubComponent }]]), }, - }; - mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + }); + mockBinder = createMockComponentBinder(); }); - @Component({ - selector: 'dummy-text', - template: '
{{text}}
', - standalone: true, - }) - class DummyTextComponent { - text?: string; - props = input(); - surfaceId = input(); - componentId = input(); - dataContextPath = input(); - } - - function createBoundProperty(val: any): BoundProperty { - return { - value: angularSignal(val), - raw: val, - onUpdate: jasmine.createSpy('onUpdate'), - }; - } describe('CheckBoxComponent', () => { let component: CheckBoxComponent; @@ -128,16 +87,16 @@ describe('Complex Components', () => { expect(fixture.nativeElement.textContent).toContain('Check me'); }); - it('should call onUpdate when toggled', () => { - const onUpdateSpy = jasmine.createSpy('onUpdate'); + it('should call set when toggled', () => { + const prop = createBoundProperty(false); fixture.componentRef.setInput('props', { label: createBoundProperty('Check me'), - value: { value: angularSignal(false), raw: false, onUpdate: onUpdateSpy }, + value: prop, }); fixture.detectChanges(); const input = fixture.nativeElement.querySelector('input'); input.click(); - expect(onUpdateSpy).toHaveBeenCalledWith(true); + expect(prop.set).toHaveBeenCalledWith(true); }); }); @@ -183,32 +142,32 @@ describe('Complex Components', () => { expect(options[0].textContent).toContain('Opt 1'); }); - it('should call onUpdate when option selected', () => { - const onUpdateSpy = jasmine.createSpy('onUpdate'); + it('should call set when option selected', () => { + const prop = createBoundProperty('1'); fixture.componentRef.setInput('props', { label: createBoundProperty('Pick one'), options: createBoundProperty([ { label: 'Opt 1', value: '1' }, { label: 'Opt 2', value: '2' }, ]), - value: { value: angularSignal('1'), raw: '1', onUpdate: onUpdateSpy }, + value: prop, variant: createBoundProperty('mutuallyExclusive'), displayStyle: createBoundProperty('checkbox'), }); fixture.detectChanges(); const inputs = fixture.nativeElement.querySelectorAll('input'); inputs[1].click(); - expect(onUpdateSpy).toHaveBeenCalledWith('2'); + expect(prop.set).toHaveBeenCalledWith('2'); }); it('should render chips and toggle selection', () => { - const onUpdateSpy = jasmine.createSpy('onUpdate'); + const prop = createBoundProperty(['c1']); fixture.componentRef.setInput('props', { choices: createBoundProperty([ { label: 'Chip 1', value: 'c1' }, { label: 'Chip 2', value: 'c2' }, ]), - value: { value: angularSignal(['c1']), raw: ['c1'], onUpdate: onUpdateSpy }, + value: prop, variant: createBoundProperty('multipleSelection'), displayStyle: createBoundProperty('chips'), }); @@ -219,10 +178,10 @@ describe('Complex Components', () => { expect(chips[1].classList.contains('active')).toBeFalse(); chips[1].click(); - expect(onUpdateSpy).toHaveBeenCalledWith(['c1', 'c2']); + expect(prop.set).toHaveBeenCalledWith(['c1', 'c2']); chips[0].click(); - expect(onUpdateSpy).toHaveBeenCalledWith([]); + expect(prop.set).toHaveBeenCalledWith(['c2']); }); }); @@ -263,16 +222,16 @@ describe('Complex Components', () => { expect(fixture.nativeElement.textContent).toContain('Brightness'); }); - it('should call onUpdate when slider value changes', () => { - const onUpdateSpy = jasmine.createSpy('onUpdate'); + it('should call set when slider value changes', () => { + const prop = createBoundProperty(50); fixture.componentRef.setInput('props', { - value: { value: angularSignal(50), raw: 50, onUpdate: onUpdateSpy }, + value: prop, }); fixture.detectChanges(); const input = fixture.nativeElement.querySelector('input'); input.value = '75'; input.dispatchEvent(new Event('input')); - expect(onUpdateSpy).toHaveBeenCalledWith(75); + expect(prop.set).toHaveBeenCalledWith(75); }); }); @@ -313,14 +272,10 @@ describe('Complex Components', () => { expect(input.value).toBe('2026-03-16'); }); - it('should call onUpdate when date or time changes', () => { - const onUpdateSpy = jasmine.createSpy('onUpdate'); + it('should call set when date or time changes', () => { + const prop = createBoundProperty('2026-03-16T10:00:00'); fixture.componentRef.setInput('props', { - value: { - value: angularSignal('2026-03-16T10:00:00'), - raw: '2026-03-16T10:00:00', - onUpdate: onUpdateSpy, - }, + value: prop, enableDate: createBoundProperty(true), enableTime: createBoundProperty(true), }); @@ -330,12 +285,12 @@ describe('Complex Components', () => { dateInput.value = '2026-03-17'; dateInput.dispatchEvent(new Event('change')); - expect(onUpdateSpy).toHaveBeenCalled(); + expect(prop.set).toHaveBeenCalled(); - onUpdateSpy.calls.reset(); + (prop.set as jasmine.Spy).calls.reset(); timeInput.value = '11:00'; timeInput.dispatchEvent(new Event('change')); - expect(onUpdateSpy).toHaveBeenCalled(); + expect(prop.set).toHaveBeenCalled(); }); }); diff --git a/renderers/angular/src/v0_9/catalog/basic/date-time-input.component.ts b/renderers/angular/src/v0_9/catalog/basic/date-time-input.component.ts index 3050c22fa..32298f71c 100644 --- a/renderers/angular/src/v0_9/catalog/basic/date-time-input.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/date-time-input.component.ts @@ -95,14 +95,14 @@ export class DateTimeInputComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); - label = computed(() => this.props()['label']?.value()); - enableDate = computed(() => this.props()['enableDate']?.value() ?? true); - enableTime = computed(() => this.props()['enableTime']?.value() ?? false); + label = computed(() => this.props()['label']?.()); + enableDate = computed(() => this.props()['enableDate']?.() ?? true); + enableTime = computed(() => this.props()['enableTime']?.() ?? false); - private rawValue = computed(() => this.props()['value']?.value() || ''); + private rawValue = computed(() => this.props()['value']?.() || ''); dateValue = computed(() => { const val = this.rawValue(); @@ -121,9 +121,9 @@ export class DateTimeInputComponent { const current = this.rawValue(); if (this.enableTime()) { const time = current.includes('T') ? current.split('T')[1] : '00:00:00'; - this.props()['value']?.onUpdate(`${date}T${time}`); + this.props()['value']?.set(`${date}T${time}`); } else { - this.props()['value']?.onUpdate(date); + this.props()['value']?.set(date); } } @@ -133,6 +133,6 @@ export class DateTimeInputComponent { const date = current.includes('T') ? current.split('T')[0] : current || new Date().toISOString().split('T')[0]; - this.props()['value']?.onUpdate(`${date}T${time}:00`); + this.props()['value']?.set(`${date}T${time}:00`); } } diff --git a/renderers/angular/src/v0_9/catalog/basic/divider.component.ts b/renderers/angular/src/v0_9/catalog/basic/divider.component.ts index 5e7f2b574..8c574f9cd 100644 --- a/renderers/angular/src/v0_9/catalog/basic/divider.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/divider.component.ts @@ -61,8 +61,8 @@ export class DividerComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); - axis = computed(() => this.props()['axis']?.value() ?? 'horizontal'); + axis = computed(() => this.props()['axis']?.() ?? 'horizontal'); } diff --git a/renderers/angular/src/v0_9/catalog/basic/icon.component.ts b/renderers/angular/src/v0_9/catalog/basic/icon.component.ts index e8d8e313a..1a5702773 100644 --- a/renderers/angular/src/v0_9/catalog/basic/icon.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/icon.component.ts @@ -78,8 +78,8 @@ export class IconComponent { componentId = input(); dataContextPath = input('/'); - color = computed(() => this.props()['color']?.value()); - iconNameRaw = computed(() => this.props()['name']?.value()); + color = computed(() => this.props()['color']?.()); + iconNameRaw = computed(() => this.props()['name']?.()); isPath = computed(() => { const name = this.iconNameRaw(); diff --git a/renderers/angular/src/v0_9/catalog/basic/image.component.ts b/renderers/angular/src/v0_9/catalog/basic/image.component.ts index e36ea2427..fe601bf24 100644 --- a/renderers/angular/src/v0_9/catalog/basic/image.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/image.component.ts @@ -67,8 +67,8 @@ export class ImageComponent { componentId = input(); dataContextPath = input('/'); - url = computed(() => this.props()['url']?.value()); - description = computed(() => this.props()['description']?.value() || ''); - fit = computed(() => this.props()['fit']?.value() || 'cover'); - variant = computed(() => this.props()['variant']?.value() || 'default'); + url = computed(() => this.props()['url']?.()); + description = computed(() => this.props()['description']?.() || ''); + fit = computed(() => this.props()['fit']?.() || 'cover'); + variant = computed(() => this.props()['variant']?.() || 'default'); } diff --git a/renderers/angular/src/v0_9/catalog/basic/list.component.ts b/renderers/angular/src/v0_9/catalog/basic/list.component.ts index 190cc10dc..6ef117814 100644 --- a/renderers/angular/src/v0_9/catalog/basic/list.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/list.component.ts @@ -111,13 +111,13 @@ export class ListComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); - listStyle = computed(() => this.props()['listStyle']?.value()); - orientation = computed(() => this.props()['orientation']?.value() || 'vertical'); + listStyle = computed(() => this.props()['listStyle']?.()); + orientation = computed(() => this.props()['orientation']?.() || 'vertical'); children = computed(() => { - const raw = this.props()['children']?.value(); + const raw = this.props()['children']?.(); return Array.isArray(raw) ? raw : []; }); diff --git a/renderers/angular/src/v0_9/catalog/basic/modal.component.ts b/renderers/angular/src/v0_9/catalog/basic/modal.component.ts index b53b7fc2f..dee8a4fba 100644 --- a/renderers/angular/src/v0_9/catalog/basic/modal.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/modal.component.ts @@ -115,13 +115,13 @@ export class ModalComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); isOpen = signal(false); - trigger = computed(() => this.props()['trigger']?.value()); - content = computed(() => this.props()['content']?.value()); + trigger = computed(() => this.props()['trigger']?.()); + content = computed(() => this.props()['content']?.()); openModal() { this.isOpen.set(true); diff --git a/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts index 88f0f98bd..7355ffa1e 100644 --- a/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts @@ -15,23 +15,12 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, input, signal } from '@angular/core'; import { RowComponent } from './row.component'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; import { By } from '@angular/platform-browser'; +import { createBoundProperty, StubComponent, createMockA2uiRendererService, createMockComponentBinder } from '../../testing'; -@Component({ - standalone: true, - selector: 'dummy-child', - template: 'Dummy Child', -}) -class DummyChild { - props = input(); - surfaceId = input(); - componentId = input(); - dataContextPath = input(); -} describe('RowComponent', () => { let component: RowComponent; @@ -50,19 +39,14 @@ describe('RowComponent', () => { ]), catalog: { id: 'test-catalog', - components: new Map([['Child', { component: DummyChild }]]), + components: new Map([['Child', { component: StubComponent }]]), }, }; - mockSurfaceGroup = { - getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface), - }; - - mockRendererService = { - surfaceGroup: mockSurfaceGroup, - }; + mockRendererService = createMockA2uiRendererService(mockSurface); + mockSurfaceGroup = mockRendererService.surfaceGroup; - mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + mockBinder = createMockComponentBinder(); mockBinder.bind.and.returnValue({ text: { value: () => 'bound' } }); await TestBed.configureTestingModule({ @@ -77,13 +61,9 @@ describe('RowComponent', () => { component = fixture.componentInstance; fixture.componentRef.setInput('surfaceId', 'surf1'); fixture.componentRef.setInput('props', { - justify: { value: signal('center'), raw: 'center', onUpdate: () => {} }, - align: { value: signal('baseline'), raw: 'baseline', onUpdate: () => {} }, - children: { - value: signal(['child1', 'child2']), - raw: ['child1', 'child2'], - onUpdate: () => {}, - }, + justify: createBoundProperty('center'), + align: createBoundProperty('baseline'), + children: createBoundProperty(['child1', 'child2']), }); }); @@ -111,14 +91,10 @@ describe('RowComponent', () => { it('should render repeating children', () => { fixture.componentRef.setInput('props', { ...component.props(), - children: { - value: signal([{}, {}]), // two items - raw: { - componentId: 'template1', - path: 'items', - }, - onUpdate: () => {}, - }, + children: createBoundProperty([{}, {}], { + componentId: 'template1', + path: 'items', + }), }); fixture.detectChanges(); diff --git a/renderers/angular/src/v0_9/catalog/basic/row.component.ts b/renderers/angular/src/v0_9/catalog/basic/row.component.ts index d4fc623ff..18a48422c 100644 --- a/renderers/angular/src/v0_9/catalog/basic/row.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/row.component.ts @@ -72,14 +72,14 @@ export class RowComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); - protected justify = computed(() => this.props()['justify']?.value()); - protected align = computed(() => this.props()['align']?.value()); + protected justify = computed(() => this.props()['justify']?.()); + protected align = computed(() => this.props()['align']?.()); protected children = computed(() => { - const raw = this.props()['children']?.value() || []; + const raw = this.props()['children']?.() || []; return Array.isArray(raw) ? raw : []; }); diff --git a/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts b/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts index 94e61883d..a7d93f798 100644 --- a/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts @@ -15,7 +15,7 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, signal as angularSignal, signal, input } from '@angular/core'; +import { Component, signal, input } from '@angular/core'; import { By } from '@angular/platform-browser'; import { DividerComponent } from './divider.component'; import { ImageComponent } from './image.component'; @@ -24,6 +24,7 @@ import { VideoComponent } from './video.component'; import { AudioPlayerComponent } from './audio-player.component'; import { CardComponent } from './card.component'; import { BoundProperty } from '../../core/types'; +import { createBoundProperty, StubComponent, createMockA2uiRendererService, createMockComponentBinder } from '../../testing'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; @@ -32,65 +33,23 @@ describe('Simple Components', () => { let mockBinder: any; beforeEach(() => { - mockRendererService = { - surfaceGroup: { - getSurface: jasmine.createSpy('getSurface').and.returnValue({ - componentsModel: new Map([ - [ - 'child-1', - { id: 'child-1', type: 'Text', properties: { text: { value: 'Child 1' } } }, - ], - [ - 'child-2', - { id: 'child-2', type: 'Text', properties: { text: { value: 'Child 2' } } }, - ], - [ - 'content-1', - { id: 'content-1', type: 'Text', properties: { text: { value: 'Content 1' } } }, - ], - [ - 'content-2', - { id: 'content-2', type: 'Text', properties: { text: { value: 'Content 2' } } }, - ], - [ - 'trigger-btn', - { id: 'trigger-btn', type: 'Text', properties: { text: { value: 'Open' } } }, - ], - [ - 'modal-content', - { id: 'modal-content', type: 'Text', properties: { text: { value: 'Modal' } } }, - ], - ]), - catalog: { - id: 'mock-catalog', - components: new Map([['Text', { type: 'Text', component: DummyTextComponent }]]), - }, - }), + mockRendererService = createMockA2uiRendererService({ + componentsModel: new Map([ + ['child-1', { id: 'child-1', type: 'Text', properties: { text: { value: 'Child 1' } } }], + ['child-2', { id: 'child-2', type: 'Text', properties: { text: { value: 'Child 2' } } }], + ['content-1', { id: 'content-1', type: 'Text', properties: { text: { value: 'Content 1' } } }], + ['content-2', { id: 'content-2', type: 'Text', properties: { text: { value: 'Content 2' } } }], + ['trigger-btn', { id: 'trigger-btn', type: 'Text', properties: { text: { value: 'Open' } } }], + ['modal-content', { id: 'modal-content', type: 'Text', properties: { text: { value: 'Modal' } } }], + ]), + catalog: { + id: 'mock-catalog', + components: new Map([['Text', { type: 'Text', component: StubComponent }]]), }, - }; - mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + }); + mockBinder = createMockComponentBinder(); }); - @Component({ - selector: 'dummy-text', - template: '
{{text}}
', - standalone: true, - }) - class DummyTextComponent { - text?: string; - props = input(); - surfaceId = input(); - componentId = input(); - dataContextPath = input(); - } - - function createBoundProperty(val: any): BoundProperty { - return { - value: angularSignal(val), - raw: val, - onUpdate: jasmine.createSpy('onUpdate'), - }; - } describe('DividerComponent', () => { let component: DividerComponent; diff --git a/renderers/angular/src/v0_9/catalog/basic/slider.component.ts b/renderers/angular/src/v0_9/catalog/basic/slider.component.ts index 0149e9758..3da1641d2 100644 --- a/renderers/angular/src/v0_9/catalog/basic/slider.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/slider.component.ts @@ -78,19 +78,19 @@ export class SliderComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); private rendererService = inject(A2uiRendererService); - label = computed(() => this.props()['label']?.value()); - value = computed(() => this.props()['value']?.value()); - min = computed(() => this.props()['min']?.value() ?? 0); - max = computed(() => this.props()['max']?.value() ?? 100); - step = computed(() => this.props()['step']?.value() ?? 1); + label = computed(() => this.props()['label']?.()); + value = computed(() => this.props()['value']?.()); + min = computed(() => this.props()['min']?.() ?? 0); + max = computed(() => this.props()['max']?.() ?? 100); + step = computed(() => this.props()['step']?.() ?? 1); handleInput(event: Event) { const val = Number((event.target as HTMLInputElement).value); - this.props()['value']?.onUpdate(val); + this.props()['value']?.set(val); } } diff --git a/renderers/angular/src/v0_9/catalog/basic/tabs.component.ts b/renderers/angular/src/v0_9/catalog/basic/tabs.component.ts index e2f506c62..79efd6d32 100644 --- a/renderers/angular/src/v0_9/catalog/basic/tabs.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/tabs.component.ts @@ -95,12 +95,12 @@ export class TabsComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); activeTabIndex = signal(0); - tabs = computed(() => this.props()['tabs']?.value() || []); + tabs = computed(() => this.props()['tabs']?.() || []); activeTab = computed(() => this.tabs()[this.activeTabIndex()]); setActiveTab(index: number) { diff --git a/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts index 37f831cb8..bfb0aa708 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts @@ -19,12 +19,16 @@ import { TextFieldComponent } from './text-field.component'; import { signal } from '@angular/core'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { By } from '@angular/platform-browser'; +import { BoundProperty } from '../../core/types'; +import { createBoundProperty } from '../../testing'; describe('TextFieldComponent', () => { let component: TextFieldComponent; let fixture: ComponentFixture; let mockRendererService: any; + + beforeEach(async () => { mockRendererService = {}; @@ -36,14 +40,10 @@ describe('TextFieldComponent', () => { fixture = TestBed.createComponent(TextFieldComponent); component = fixture.componentInstance; fixture.componentRef.setInput('props', { - label: { value: signal('Username'), raw: 'Username', onUpdate: () => {} }, - value: { - value: signal('testuser'), - raw: 'testuser', - onUpdate: jasmine.createSpy('onUpdate'), - }, - placeholder: { value: signal('Enter username'), raw: 'Enter username', onUpdate: () => {} }, - variant: { value: signal('text'), raw: 'text', onUpdate: () => {} }, + label: createBoundProperty('Username'), + value: createBoundProperty('testuser'), + placeholder: createBoundProperty('Enter username'), + variant: createBoundProperty('text'), }); }); @@ -61,7 +61,7 @@ describe('TextFieldComponent', () => { it('should not render label if not provided', () => { fixture.componentRef.setInput('props', { ...component.props(), - label: { value: signal(null), raw: null, onUpdate: () => {} }, + label: createBoundProperty(null), }); fixture.detectChanges(); const label = fixture.debugElement.query(By.css('label')); @@ -80,13 +80,13 @@ describe('TextFieldComponent', () => { fixture.componentRef.setInput('props', { ...component.props(), - variant: { value: signal('obscured'), raw: 'obscured', onUpdate: () => {} }, + variant: createBoundProperty('obscured'), }); expect(component.inputType()).toBe('password'); fixture.componentRef.setInput('props', { ...component.props(), - variant: { value: signal('number'), raw: 'number', onUpdate: () => {} }, + variant: createBoundProperty('number'), }); expect(component.inputType()).toBe('number'); }); @@ -97,6 +97,6 @@ describe('TextFieldComponent', () => { input.nativeElement.value = 'newuser'; input.triggerEventHandler('input', { target: input.nativeElement }); - expect(component.props()['value'].onUpdate).toHaveBeenCalledWith('newuser'); + expect((component.props()['value'] as any).set).toHaveBeenCalledWith('newuser'); }); }); diff --git a/renderers/angular/src/v0_9/catalog/basic/text-field.component.ts b/renderers/angular/src/v0_9/catalog/basic/text-field.component.ts index d061182a9..282075950 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text-field.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text-field.component.ts @@ -81,10 +81,10 @@ export class TextFieldComponent { private rendererService = inject(A2uiRendererService); - label = computed(() => this.props()['label']?.value()); - value = computed(() => this.props()['value']?.value() || ''); - placeholder = computed(() => this.props()['placeholder']?.value() || ''); - variant = computed(() => this.props()['variant']?.value()); + label = computed(() => this.props()['label']?.()); + value = computed(() => this.props()['value']?.() || ''); + placeholder = computed(() => this.props()['placeholder']?.() || ''); + variant = computed(() => this.props()['variant']?.()); inputType = computed(() => { switch (this.variant()) { @@ -101,6 +101,6 @@ export class TextFieldComponent { const value = (event.target as HTMLInputElement).value; // Update the data path. If anything is listening to this path, it will be // notified. - this.props()['value']?.onUpdate(value); + this.props()['value']?.set(value); } } diff --git a/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts index fc639d2f5..d8128c23d 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts @@ -18,12 +18,15 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TextComponent } from './text.component'; import { By } from '@angular/platform-browser'; import { signal } from '@angular/core'; +import { BoundProperty } from '../../core/types'; +import { createBoundProperty } from '../../testing'; describe('TextComponent', () => { let component: TextComponent; let fixture: ComponentFixture; beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [TextComponent], }).compileComponents(); @@ -31,9 +34,9 @@ describe('TextComponent', () => { fixture = TestBed.createComponent(TextComponent); component = fixture.componentInstance; fixture.componentRef.setInput('props', { - text: { value: signal('Hello World'), raw: 'Hello World', onUpdate: () => {} }, - weight: { value: signal('bold'), raw: 'bold', onUpdate: () => {} }, - style: { value: signal('italic'), raw: 'italic', onUpdate: () => {} }, + text: createBoundProperty('Hello World'), + weight: createBoundProperty('bold'), + style: createBoundProperty('italic'), }); }); diff --git a/renderers/angular/src/v0_9/catalog/basic/text.component.ts b/renderers/angular/src/v0_9/catalog/basic/text.component.ts index f34e91eb1..62cb09b8b 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text.component.ts @@ -47,7 +47,7 @@ export class TextComponent { componentId = input(); dataContextPath = input('/'); - weight = computed(() => this.props()['weight']?.value()); - style = computed(() => this.props()['style']?.value()); - text = computed(() => this.props()['text']?.value()); + weight = computed(() => this.props()['weight']?.()); + style = computed(() => this.props()['style']?.()); + text = computed(() => this.props()['text']?.()); } diff --git a/renderers/angular/src/v0_9/catalog/basic/video.component.ts b/renderers/angular/src/v0_9/catalog/basic/video.component.ts index 4d9a8f983..4836527c3 100644 --- a/renderers/angular/src/v0_9/catalog/basic/video.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/video.component.ts @@ -66,6 +66,6 @@ export class VideoComponent { componentId = input(); dataContextPath = input('/'); - url = computed(() => this.props()['url']?.value()); - posterUrl = computed(() => this.props()['posterUrl']?.value()); + url = computed(() => this.props()['url']?.()); + posterUrl = computed(() => this.props()['posterUrl']?.()); } diff --git a/renderers/angular/src/v0_9/catalog/types.ts b/renderers/angular/src/v0_9/catalog/types.ts index 4b385ce43..deee13cb7 100644 --- a/renderers/angular/src/v0_9/catalog/types.ts +++ b/renderers/angular/src/v0_9/catalog/types.ts @@ -15,7 +15,7 @@ */ import { Type } from '@angular/core'; -import { Catalog, ComponentApi } from '@a2ui/web_core/v0_9'; +import { Catalog, ComponentApi, FunctionImplementation } from '@a2ui/web_core/v0_9'; /** * Extends the generic {@link ComponentApi} to include Angular-specific component metadata. @@ -38,4 +38,13 @@ export interface AngularComponentImplementation extends ComponentApi { * definitions and by {@link ComponentHostComponent} to instantiate the * correct Angular components. */ -export class AngularCatalog extends Catalog {} +export class AngularCatalog extends Catalog { + constructor( + id: string, + components: AngularComponentImplementation[] | Record, + functions: FunctionImplementation[] = [], + ) { + const compArray = Array.isArray(components) ? components : Object.values(components); + super(id, compArray, functions); + } +} diff --git a/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts b/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts index 2e4e00458..cf2a189f6 100644 --- a/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts +++ b/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { Injectable, OnDestroy, InjectionToken, Inject } from '@angular/core'; +import { Injectable, OnDestroy, InjectionToken, Inject, Injector } from '@angular/core'; +import { AngularReactiveProvider } from './angular-reactive-provider'; import { MessageProcessor, SurfaceGroupModel, @@ -58,12 +59,18 @@ export class A2uiRendererService implements OnDestroy { private _messageProcessor: MessageProcessor; private _catalogs: AngularCatalog[] = []; - constructor(@Inject(A2UI_RENDERER_CONFIG) private config: RendererConfiguration) { + constructor( + @Inject(A2UI_RENDERER_CONFIG) private config: RendererConfiguration, + private injector: Injector, + ) { this._catalogs = this.config.catalogs; + const provider = new AngularReactiveProvider(this.injector); + this._messageProcessor = new MessageProcessor( this._catalogs, this.config.actionHandler as ActionHandler, + provider, ); } diff --git a/renderers/angular/src/v0_9/core/angular-reactive-provider.spec.ts b/renderers/angular/src/v0_9/core/angular-reactive-provider.spec.ts new file mode 100644 index 000000000..b2795e356 --- /dev/null +++ b/renderers/angular/src/v0_9/core/angular-reactive-provider.spec.ts @@ -0,0 +1,70 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { signal, computed, effect, untracked, Injector } from '@angular/core'; +import { AngularReactiveProvider } from './angular-reactive-provider'; +import { TestBed } from '@angular/core/testing'; + +describe('AngularReactiveProvider', () => { + let provider: AngularReactiveProvider; + let injector: Injector; + + beforeEach(() => { + TestBed.configureTestingModule({}); + injector = TestBed.inject(Injector); + provider = new AngularReactiveProvider(injector); + }); + + it('should create a signal and read/write value', () => { + const sig = provider.signal('initial'); + expect(sig()).toBe('initial'); + expect(sig.value).toBe('initial'); + + sig.value = 'updated'; + expect(sig()).toBe('updated'); + expect(sig.value).toBe('updated'); + + sig.set('direct'); + expect(sig()).toBe('direct'); + }); + + it('should create a computed signal', () => { + const s1 = provider.signal(1); + const s2 = provider.signal(2); + const sum = provider.computed(() => s1() + s2()); + + expect(sum()).toBe(3); + s1.value = 10; + expect(sum()).toBe(12); + }); + + it('should track changes in an effect', () => { + const sig = provider.signal('initial'); + let effectValue = ''; + + provider.effect(() => { + effectValue = sig.value; + }); + + // In Angular tests, effects are scheduled. flushEffects() runs them. + TestBed.flushEffects(); + expect(effectValue).toBe('initial'); + + sig.value = 'updated'; + TestBed.flushEffects(); + expect(effectValue).toBe('updated'); + }); +}); diff --git a/renderers/angular/src/v0_9/core/angular-reactive-provider.ts b/renderers/angular/src/v0_9/core/angular-reactive-provider.ts new file mode 100644 index 000000000..68454e21a --- /dev/null +++ b/renderers/angular/src/v0_9/core/angular-reactive-provider.ts @@ -0,0 +1,91 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the \"License\"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an \"AS IS\" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { signal, computed, effect, untracked, Injector, isSignal } from '@angular/core'; +import { GenericSignal, ReactiveProvider } from '@a2ui/web_core/v0_9'; + +/** + * Bridges Angular signals to the GenericSignal interface. + */ +function wrapAngularSignal(sig: any): GenericSignal { + const wrapper = () => sig(); + + const setter = (v: T) => { + if (typeof sig.set === 'function') { + sig.set(v); + } else { + console.warn('Cannot set value on a computed Angular signal.'); + } + }; + + Object.defineProperties(wrapper, { + value: { + get: () => sig(), + set: setter, + configurable: true, + }, + peek: { + value: () => untracked(() => sig()), + configurable: true, + }, + set: { + value: setter, + configurable: true, + }, + _isGenericSignal: { value: true, configurable: true }, + }); + + return wrapper as unknown as GenericSignal; +} + +/** + * A ReactiveProvider that uses Angular's native signals as its backend. + */ +export class AngularReactiveProvider implements ReactiveProvider { + constructor(private injector: Injector) {} + + signal(value: T): GenericSignal { + return wrapAngularSignal(signal(value)); + } + + computed(compute: () => T): GenericSignal { + return wrapAngularSignal(computed(compute)); + } + + effect(callback: () => void): () => void { + const effectRef = effect(callback, { injector: this.injector }); + return () => effectRef.destroy(); + } + + isSignal(v: any): v is GenericSignal { + return !!v && (v._isGenericSignal || isSignal(v)); + } + + toGenericSignal(v: any): GenericSignal { + if (v && v._isGenericSignal) { + return v as GenericSignal; + } + if (isSignal(v)) { + return wrapAngularSignal(v); + } + return this.signal(v); + } + + batch(callback: () => T): T { + // Angular handles batching through its scheduling system. + return callback(); + } +} diff --git a/renderers/angular/src/v0_9/core/component-binder.service.spec.ts b/renderers/angular/src/v0_9/core/component-binder.service.spec.ts index 483e4ea67..158024a3b 100644 --- a/renderers/angular/src/v0_9/core/component-binder.service.spec.ts +++ b/renderers/angular/src/v0_9/core/component-binder.service.spec.ts @@ -16,8 +16,7 @@ import { TestBed } from '@angular/core/testing'; import { DestroyRef } from '@angular/core'; -import { signal as preactSignal } from '@preact/signals-core'; -import { ComponentContext } from '@a2ui/web_core/v0_9'; +import { ComponentContext, PreactReactiveProvider } from '@a2ui/web_core/v0_9'; import { ComponentBinder } from './component-binder.service'; describe('ComponentBinder', () => { @@ -52,14 +51,12 @@ describe('ComponentBinder', () => { }, }; - const mockpSigText = preactSignal('Hello'); - const mockpSigVisible = preactSignal(true); - + const provider = new PreactReactiveProvider(); const mockDataContext = { resolveSignal: jasmine.createSpy('resolveSignal').and.callFake((val: any) => { - if (val === 'Hello') return mockpSigText; - if (val === true) return mockpSigVisible; - return preactSignal(val); + // For test purposes, bindings resolve to 'initial' + if (typeof val === 'object' && val?.path) return provider.toGenericSignal('initial'); + return provider.toGenericSignal(val); }), set: jasmine.createSpy('set'), }; @@ -73,8 +70,8 @@ describe('ComponentBinder', () => { expect(bound['text']).toBeDefined(); expect(bound['visible']).toBeDefined(); - expect(bound['text'].value()).toBe('Hello'); - expect(bound['visible'].value()).toBe(true); + expect(bound['text']()).toBe('Hello'); + expect(bound['visible']()).toBe(true); // Verify resolveSignal was called expect(mockDataContext.resolveSignal).toHaveBeenCalledWith('Hello'); @@ -88,9 +85,13 @@ describe('ComponentBinder', () => { }, }; - const mockpSig = preactSignal('initial'); + const provider = new PreactReactiveProvider(); const mockDataContext = { - resolveSignal: jasmine.createSpy('resolveSignal').and.returnValue(mockpSig), + resolveSignal: jasmine.createSpy('resolveSignal').and.callFake((val: any) => { + // For test purposes, bindings resolve to 'initial' + if (typeof val === 'object' && val?.path) return provider.toGenericSignal('initial'); + return provider.toGenericSignal(val); + }), set: jasmine.createSpy('set'), }; @@ -102,11 +103,11 @@ describe('ComponentBinder', () => { const bound = binder.bind(mockContext); expect(bound['value']).toBeDefined(); - expect(bound['value'].value()).toBe('initial'); - expect(bound['value'].onUpdate).toBeDefined(); + expect(bound['value']()).toBe('initial'); + expect(bound['value'].set).toBeDefined(); - // Call update - bound['value'].onUpdate('new-value'); + // Call set + bound['value'].set('new-value'); // Verify set was called on DataContext expect(mockDataContext.set).toHaveBeenCalledWith('/data/text', 'new-value'); @@ -119,9 +120,13 @@ describe('ComponentBinder', () => { }, }; - const mockpSig = preactSignal('Literal String'); + const provider = new PreactReactiveProvider(); const mockDataContext = { - resolveSignal: jasmine.createSpy('resolveSignal').and.returnValue(mockpSig), + resolveSignal: jasmine.createSpy('resolveSignal').and.callFake((val: any) => { + // For test purposes, bindings resolve to 'initial' + if (typeof val === 'object' && val?.path) return provider.toGenericSignal('initial'); + return provider.toGenericSignal(val); + }), set: jasmine.createSpy('set'), }; @@ -133,11 +138,11 @@ describe('ComponentBinder', () => { const bound = binder.bind(mockContext); expect(bound['text']).toBeDefined(); - expect(bound['text'].value()).toBe('Literal String'); - expect(bound['text'].onUpdate).toBeDefined(); // No-op for literals + expect(bound['text']()).toBe('Literal String'); + expect(bound['text'].set).toBeDefined(); // No-op for literals - // Call onUpdate on literal, should not crash or call set - bound['text'].onUpdate('new'); + // Call set on literal, should not crash or call set + bound['text'].set('new'); expect(mockDataContext.set).not.toHaveBeenCalled(); }); }); diff --git a/renderers/angular/src/v0_9/core/component-binder.service.ts b/renderers/angular/src/v0_9/core/component-binder.service.ts index a56fcb0d0..ec8f5de0b 100644 --- a/renderers/angular/src/v0_9/core/component-binder.service.ts +++ b/renderers/angular/src/v0_9/core/component-binder.service.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import { DestroyRef, Injectable, inject, NgZone } from '@angular/core'; +import { Injectable } from '@angular/core'; import { ComponentContext } from '@a2ui/web_core/v0_9'; -import { toAngularSignal } from './utils'; import { BoundProperty } from './types'; /** @@ -31,8 +30,7 @@ import { BoundProperty } from './types'; providedIn: 'root', }) export class ComponentBinder { - private destroyRef = inject(DestroyRef); - private ngZone = inject(NgZone); + constructor() {} /** * Binds all properties of a component to an object of Angular Signals. @@ -42,22 +40,42 @@ export class ComponentBinder { */ bind(context: ComponentContext): Record { const props = context.componentModel.properties; - const bound: Record = {}; + const bound: Record = {}; for (const key of Object.keys(props)) { const value = props[key]; - const preactSig = context.dataContext.resolveSignal(value); - const angSig = toAngularSignal(preactSig as any, this.destroyRef, this.ngZone); + const sig = context.dataContext.resolveSignal(value); + // Augment the signal into a BoundProperty const isBoundPath = value && typeof value === 'object' && 'path' in value; - bound[key] = { - value: angSig, - raw: value, - onUpdate: isBoundPath - ? (newValue: any) => context.dataContext.set(value.path, newValue) - : () => {}, // No-op for non-bound values + const boundProp = sig as any as BoundProperty; + + // Defensively define properties only if they don't already exist or are configurable + const defineSafe = (obj: any, key: string, descriptor: PropertyDescriptor) => { + try { + const existing = Object.getOwnPropertyDescriptor(obj, key); + if (!existing || existing.configurable) { + Object.defineProperty(obj, key, descriptor); + } + } catch (e) { + console.warn(`Failed to define "${key}" property on bound signal:`, e); + } }; + + defineSafe(boundProp, 'raw', { value: value, configurable: true }); + defineSafe(boundProp, 'value', { get: () => sig(), configurable: true }); + defineSafe(boundProp, 'name', { value: key, configurable: true }); + + // Only define 'set' if we have a path-bound property to handle + if (isBoundPath) { + defineSafe(boundProp, 'set', { + value: (newValue: any) => context.dataContext.set(value.path, newValue), + configurable: true, + }); + } + + bound[key] = boundProp; } return bound; diff --git a/renderers/angular/src/v0_9/core/component-host.component.ts b/renderers/angular/src/v0_9/core/component-host.component.ts index 4f5cb0fb6..7e85903d4 100644 --- a/renderers/angular/src/v0_9/core/component-host.component.ts +++ b/renderers/angular/src/v0_9/core/component-host.component.ts @@ -48,11 +48,11 @@ import { ComponentBinder } from './component-binder.service'; *ngComponentOutlet=" componentType; inputs: { - props: props, - surfaceId: surfaceId(), - componentId: componentId(), - dataContextPath: dataContextPath(), - } + props: props, + surfaceId: surfaceId(), + componentId: componentId(), + dataContextPath: dataContextPath(), + } " > } diff --git a/renderers/angular/src/v0_9/core/function_binding.spec.ts b/renderers/angular/src/v0_9/core/function_binding.spec.ts index b3b570c74..6279f0df7 100644 --- a/renderers/angular/src/v0_9/core/function_binding.spec.ts +++ b/renderers/angular/src/v0_9/core/function_binding.spec.ts @@ -14,17 +14,20 @@ * limitations under the License. */ -import { DataContext, SurfaceModel } from '@a2ui/web_core/v0_9'; +import { DataContext, SurfaceModel, ReactiveProvider, PreactReactiveProvider } from '@a2ui/web_core/v0_9'; import { DestroyRef } from '@angular/core'; import { BasicCatalogBase } from '../catalog/basic/basic-catalog'; -import { toAngularSignal } from './utils'; describe('Function Bindings', () => { let mockDestroyRef: jasmine.SpyObj; + let mockProvider: ReactiveProvider; + beforeEach(() => { mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']); mockDestroyRef.onDestroy.and.returnValue(() => {}); + + mockProvider = new PreactReactiveProvider(); }); describe('add', () => { @@ -32,7 +35,7 @@ describe('Function Bindings', () => { const catalog = new BasicCatalogBase(); // Create Surface Model and DataContext - const surface = new SurfaceModel('surface_1', catalog); + const surface = new SurfaceModel('surface_1', catalog, mockProvider); const dataModel = surface.dataModel; const context = new DataContext(surface, '/'); @@ -48,10 +51,7 @@ describe('Function Bindings', () => { }; // 1. Resolve Signal - const resSig = context.resolveSignal(callValue as any); - - // 2. Convert to Angular Signal - const angSig = toAngularSignal(resSig, mockDestroyRef); + const angSig = context.resolveSignal(callValue as any) as any; // 3. Initial state expect(isNaN(angSig())).toBe(true); @@ -73,7 +73,7 @@ describe('Function Bindings', () => { const catalog = new BasicCatalogBase(); // Create Surface Model and DataContext - const surface = new SurfaceModel('surface_1', catalog); + const surface = new SurfaceModel('surface_1', catalog, mockProvider); const dataModel = surface.dataModel; const context = new DataContext(surface, '/'); @@ -87,10 +87,7 @@ describe('Function Bindings', () => { }; // 1. Resolve Signal (Preact) - const resSig = context.resolveSignal(callValue as any); - - // 2. Convert to Angular Signal - const angSig = toAngularSignal(resSig, mockDestroyRef); + const angSig = context.resolveSignal(callValue as any) as any; // 3. Initial state (price is undefined, so should be '$') expect(angSig()).toBe('$'); @@ -107,7 +104,7 @@ describe('Function Bindings', () => { it('should handle multiple path interpolations correctly', () => { const catalog = new BasicCatalogBase(); - const surface = new SurfaceModel('surface_1', catalog); + const surface = new SurfaceModel('surface_1', catalog, mockProvider); const dataModel = surface.dataModel; const context = new DataContext(surface, '/'); @@ -119,8 +116,7 @@ describe('Function Bindings', () => { returnType: 'string', }; - const resSig = context.resolveSignal(callValue as any); - const angSig = toAngularSignal(resSig, mockDestroyRef); + const angSig = context.resolveSignal(callValue as any) as any; dataModel.set('/firstName', 'A2UI'); dataModel.set('/lastName', 'Renderer'); diff --git a/renderers/angular/src/v0_9/core/types.ts b/renderers/angular/src/v0_9/core/types.ts index 86c689836..0831fe8ca 100644 --- a/renderers/angular/src/v0_9/core/types.ts +++ b/renderers/angular/src/v0_9/core/types.ts @@ -14,39 +14,25 @@ * limitations under the License. */ -import { Signal } from '@angular/core'; +import { Signal, WritableSignal } from '@angular/core'; +import { GenericSignal } from '@a2ui/web_core/v0_9'; /** - * Represents a component property bound to an Angular Signal and update logic. + * A BoundProperty is a reactive signal that represents a component property + * from the A2UI component tree. * - * This interface is used by {@link ComponentBinder} to expose properties to - * Angular components. It contains the current value as a Signal, the raw - * property definition, and an update function. + * Components can read the current value by calling the property as a function: `props().key()`. + * If the property is bound to a data path, it also supports `.set(newValue)` to update the model. * * @template T The type of the property value. */ -export interface BoundProperty { - /** - * The reactive Angular Signal containing the current resolved value. - * - * This signal automatically updates whenever the underlying A2UI data - * model changes. - */ - readonly value: Signal; - - /** - * The raw value from the A2UI ComponentModel. - * - * This may be a literal value or a data binding path object. - */ +export type BoundProperty = (Signal | WritableSignal | GenericSignal) & { + /** The raw property definition from the component model (literal or binding). */ readonly raw: any; - - /** - * Callback to update the value in the A2UI DataContext. - * - * If the property is bound to a path in the data model, calling this - * will update that path. If the property is a literal value, this - * is typically a no-op. - */ - readonly onUpdate: (newValue: T) => void; -} + /** The attribute name from the component model. */ + readonly name: string; + /** Direct access to the current value (same as calling the signal function). */ + readonly value: T; + /** Updates the underlying data model if the property is path-bound. */ + readonly set: (value: T) => void; +}; diff --git a/renderers/angular/src/v0_9/core/utils.spec.ts b/renderers/angular/src/v0_9/core/utils.spec.ts index 1c6dd72c3..079fba918 100644 --- a/renderers/angular/src/v0_9/core/utils.spec.ts +++ b/renderers/angular/src/v0_9/core/utils.spec.ts @@ -13,88 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { DestroyRef } from '@angular/core'; -import { signal as preactSignal } from '@preact/signals-core'; -import { toAngularSignal, getNormalizedPath } from './utils'; - -describe('toAngularSignal', () => { - let mockDestroyRef: jasmine.SpyObj; - let onDestroyCallback: () => void; - - beforeEach(() => { - onDestroyCallback = () => {}; - mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']); - mockDestroyRef.onDestroy.and.callFake((callback: () => void) => { - onDestroyCallback = callback; - return () => {}; // Return unregister function - }); - }); - - it('should initialize with the current value of Preact signal', () => { - const pSig = preactSignal('initial'); - const angSig = toAngularSignal(pSig, mockDestroyRef); - - expect(angSig()).toBe('initial'); - }); - - it('should update Angular signal when Preact signal changes', () => { - const pSig = preactSignal('initial'); - const angSig = toAngularSignal(pSig, mockDestroyRef); - - expect(angSig()).toBe('initial'); - - pSig.value = 'updated'; - expect(angSig()).toBe('updated'); - }); - - it('should dispose Preact effect when DestroyRef triggers', () => { - const pSig = preactSignal('initial'); - const angSig = toAngularSignal(pSig, mockDestroyRef); - - expect(angSig()).toBe('initial'); - - // Trigger cleanup - onDestroyCallback(); - - pSig.value = 'updated'; - // Angular signal should NOT update after disposal - expect(angSig()).toBe('initial'); - }); - - it('should call unsubscribe on Preact signal if available on destroy', () => { - const pSig = preactSignal('initial') as any; - const unsubscribeSpy = jasmine.createSpy('unsubscribe'); - pSig.unsubscribe = unsubscribeSpy; - - toAngularSignal(pSig, mockDestroyRef); - - expect(unsubscribeSpy).not.toHaveBeenCalled(); - - // Trigger cleanup - onDestroyCallback(); - - expect(unsubscribeSpy).toHaveBeenCalled(); - }); - - it('should run update within NgZone if provided', () => { - const pSig = preactSignal('initial'); - const mockNgZone = jasmine.createSpyObj('NgZone', ['run']); - mockNgZone.run.and.callFake((fn: () => void) => fn()); - - const angSig = toAngularSignal(pSig, mockDestroyRef, mockNgZone); - - expect(angSig()).toBe('initial'); - expect(mockNgZone.run).toHaveBeenCalled(); - - mockNgZone.run.calls.reset(); - pSig.value = 'updated'; - - expect(angSig()).toBe('updated'); - expect(mockNgZone.run).toHaveBeenCalled(); - }); -}); - +import { getNormalizedPath } from './utils'; describe('getNormalizedPath', () => { it('should handle absolute paths', () => { expect(getNormalizedPath('/absolute', '/', 0)).toBe('/absolute/0'); diff --git a/renderers/angular/src/v0_9/core/utils.ts b/renderers/angular/src/v0_9/core/utils.ts index eccb4476a..622ea9e19 100644 --- a/renderers/angular/src/v0_9/core/utils.ts +++ b/renderers/angular/src/v0_9/core/utils.ts @@ -14,51 +14,8 @@ * limitations under the License. */ -import { DestroyRef, Signal, signal as angularSignal } from '@angular/core'; -import { Signal as PreactSignal, effect } from '@a2ui/web_core/v0_9'; - -/** - * Bridges a Preact Signal (from A2UI web_core) to a reactive Angular Signal. - * - * This utility handles the lifecycle mapping between Preact and Angular, - * ensuring that updates from the A2UI data model are propagated correctly - * to Angular's change detection, and resources are cleaned up when the - * component is destroyed. - * - * @param preactSignal The source Preact Signal. - * @param destroyRef Angular DestroyRef for lifecycle management. - * @param ngZone Optional NgZone to ensure updates run within the Angular zone - * (necessary for correct change detection in OnPush components). - * @returns A read-only Angular Signal. - */ import { NgZone } from '@angular/core'; -export function toAngularSignal( - preactSignal: PreactSignal, - destroyRef: DestroyRef, - ngZone?: NgZone, -): Signal { - const s = angularSignal(preactSignal.peek()); - - const dispose = effect(() => { - if (ngZone) { - ngZone.run(() => s.set(preactSignal.value)); - } else { - s.set(preactSignal.value); - } - }); - - destroyRef.onDestroy(() => { - dispose(); - // Some signals returned by DataContext.resolveSignal have a custom unsubscribe for AbortControllers - if ((preactSignal as any).unsubscribe) { - (preactSignal as any).unsubscribe(); - } - }); - - return s.asReadonly(); -} - /** * Normalizes a data model path by combining a relative path with a base context. * diff --git a/renderers/angular/src/v0_9/public-api.ts b/renderers/angular/src/v0_9/public-api.ts index 7956ec1b0..2091cc8e4 100644 --- a/renderers/angular/src/v0_9/public-api.ts +++ b/renderers/angular/src/v0_9/public-api.ts @@ -54,3 +54,6 @@ export * from './catalog/basic/check-box.component'; export * from './catalog/basic/choice-picker.component'; export * from './catalog/basic/slider.component'; export * from './catalog/basic/date-time-input.component'; + +// Testing Utilities +export * from './testing'; diff --git a/renderers/angular/src/v0_9/testing.ts b/renderers/angular/src/v0_9/testing.ts new file mode 100644 index 000000000..f57e2a855 --- /dev/null +++ b/renderers/angular/src/v0_9/testing.ts @@ -0,0 +1,108 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the \"License\"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an \"AS IS\" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injector, signal, Component, input } from '@angular/core'; +import { createTestDataContext as createBaseContext } from '@a2ui/web_core/v0_9'; +import { AngularReactiveProvider } from './core/angular-reactive-provider'; +import { BoundProperty } from './core/types'; + +/** + * Creates a DataContext populated with an AngularReactiveProvider. + * Useful for testing Angular components or services that require native signals. + * + * @param injector The Angular Injector to use for the reactive provider. + * @param surfaceId Optional ID for the surface. + * @param basePath Optional base path in the data model. + * @returns A DataContext configured with Angular native signals. + */ +export function createAngularTestDataContext( + injector: Injector, + surfaceId?: string, + basePath?: string +) { + return createBaseContext(new AngularReactiveProvider(injector), surfaceId, basePath); +} + +/** + * Creates a mock BoundProperty for testing. + * + * Includes standard properties like value, raw, set, peek, and update. + * Spies are created for set and update using jasmine.createSpy. + * + * @param val The initial value of the property. + * @returns A mock BoundProperty. + */ +export function createBoundProperty(val: T, rawValue?: any, name = 'test-prop'): BoundProperty { + const sig = signal(val); + const prop = () => sig(); + Object.defineProperties(prop, { + value: { get: () => sig(), configurable: true }, + peek: { value: () => sig(), configurable: true }, + set: { + value: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + configurable: true, + }, + raw: { value: rawValue !== undefined ? rawValue : val, configurable: true }, + name: { value: name, configurable: true }, + }); + return prop as unknown as BoundProperty; +} + +/** + * A stub component for testing that accepts all standard A2UI inputs. + */ +@Component({ + selector: 'a2ui-stub', + template: '', + standalone: true, +}) +export class StubComponent { + props = input(); + surfaceId = input(); + componentId = input(); + dataContextPath = input(); +} + +/** + * Creates a robust mock for the A2uiRendererService. + * + * @param surface Optional custom surface mock to return. + * @returns A mock object for A2uiRendererService. + */ +export function createMockA2uiRendererService(surface?: any) { + const mockSurface = surface || { + componentsModel: new Map(), + catalog: { + id: 'mock-catalog', + components: new Map([['Text', { type: 'Text', component: StubComponent }]]), + }, + }; + + return { + surfaceGroup: { + getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface), + }, + }; +} + +/** + * Creates a mock for the ComponentBinder service. + * + * @returns A mock object for ComponentBinder. + */ +export function createMockComponentBinder() { + return jasmine.createSpyObj('ComponentBinder', ['bind']); +} diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts index 176631842..3a63fdbc1 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts @@ -30,6 +30,8 @@ function invoke(name: string, args: Record, context: DataContext) { return testCatalog.invoker(name, args, context); } +import { PreactReactiveProvider } from "../../common/preact-provider.js"; + const createTestDataContext = ( model: DataModel, path: string, @@ -39,6 +41,7 @@ const createTestDataContext = ( dataModel: model, catalog: { invoker: functionInvoker }, dispatchError: () => {}, + provider: new PreactReactiveProvider(), } as any; return new DataContext(mockSurface, path); }; diff --git a/renderers/web_core/src/v0_9/catalog/types.ts b/renderers/web_core/src/v0_9/catalog/types.ts index 3700fc6ee..fbd2ec927 100644 --- a/renderers/web_core/src/v0_9/catalog/types.ts +++ b/renderers/web_core/src/v0_9/catalog/types.ts @@ -16,14 +16,14 @@ import { z } from "zod"; import { DataContext } from "../rendering/data-context.js"; -import { Signal } from "@preact/signals-core"; +import { GenericSignal } from "../common/reactive.js"; import { A2uiExpressionError } from "../errors.js"; /** - * Robust check for a Preact Signal that works across package boundaries. + * Robust check for a GenericSignal that works across package boundaries. */ -export function isSignal(val: any): val is Signal { - return val && typeof val === "object" && "value" in val && "peek" in val; +export function isSignal(val: any): val is GenericSignal { + return val && (typeof val === "object" || typeof val === "function") && "value" in val && "peek" in val; } export type A2uiReturnType = 'string' | 'number' | 'boolean' | 'array' | 'object' | 'any' | 'void'; @@ -54,7 +54,7 @@ export interface FunctionImplementation extends FunctionApi { args: Record, context: DataContext, abortSignal?: AbortSignal - ): unknown | Signal; + ): unknown | GenericSignal; } export function createFunctionImplementation< @@ -66,7 +66,7 @@ export function createFunctionImplementation< args: z.infer, context: DataContext, abortSignal?: AbortSignal - ) => InferA2uiReturnType | Signal> + ) => InferA2uiReturnType | GenericSignal> ): FunctionImplementation { return { name: api.name, diff --git a/renderers/web_core/src/v0_9/common/preact-provider.ts b/renderers/web_core/src/v0_9/common/preact-provider.ts new file mode 100644 index 000000000..eede0f331 --- /dev/null +++ b/renderers/web_core/src/v0_9/common/preact-provider.ts @@ -0,0 +1,88 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the \"License\"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an \"AS IS\" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { signal, computed, effect, batch, Signal } from '@preact/signals-core'; +import { GenericSignal, ReactiveProvider } from './reactive'; + +/** + * Bridges Preact signals to the GenericSignal interface. + */ +function wrapPreactSignal(sig: Signal): GenericSignal { + // A Preact signal already acts as a GenericSignal if we treat it right. + // It has a .value property and a .peek() method. + // We just need to make it callable as a function sig() for Angular compatibility. + const wrapper = () => sig.value; + Object.defineProperties(wrapper, { + value: { + get: () => sig.value, + set: (v: T) => { + sig.value = v; + }, + configurable: true, + }, + peek: { + value: () => sig.peek(), + configurable: true, + }, + set: { + value: (v: T) => { + sig.value = v; + }, + configurable: true, + }, + _isGenericSignal: { value: true, configurable: true }, + }); + return wrapper as any as GenericSignal; +} + +/** + * A ReactiveProvider that uses @preact/signals-core as its backend. + */ +export class PreactReactiveProvider implements ReactiveProvider { + signal(value: T): GenericSignal { + return wrapPreactSignal(signal(value)); + } + + computed(compute: () => T): GenericSignal { + return wrapPreactSignal(computed(compute)); + } + + effect(callback: () => void): () => void { + return effect(callback); + } + + isSignal(v: any): v is GenericSignal { + return ( + v && + (v._isGenericSignal || + (typeof v === 'object' && v.brand === Symbol.for('preact-signals'))) + ); + } + + toGenericSignal(v: any): GenericSignal { + if (v && v._isGenericSignal) { + return v as GenericSignal; + } + if (v && typeof v === 'object' && v.brand === Symbol.for('preact-signals')) { + return wrapPreactSignal(v); + } + return this.signal(v); + } + + batch(callback: () => T): T { + return batch(callback); + } +} diff --git a/renderers/web_core/src/v0_9/common/reactive.ts b/renderers/web_core/src/v0_9/common/reactive.ts new file mode 100644 index 000000000..51793d387 --- /dev/null +++ b/renderers/web_core/src/v0_9/common/reactive.ts @@ -0,0 +1,75 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the \"License\"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an \"AS IS\" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * A generalized interface for a reactive primitive (a \"signal\"). + * It represents a value that can be read, but it also has a .value property + * for direct access (like Preact signals). + */ +export interface GenericSignal { + /** The current value (accessor). Functions as a getter/setter. */ + value: T; + + /** + * Invoking the signal as a function returns its current value. + * This is compatible with both Preact and Angular signals. + */ + (): T; + + /** Reads the value without creating a reactive dependency. */ + peek(): T; + + /** Updates the value of the signal. */ + set(value: T): void; +} + +/** + * An abstraction for creating reactive primitives and observing changes. + * This allows web_core to be agnostic to the underlying signal implementation. + */ +export interface ReactiveProvider { + /** Creates a reactive signal. */ + signal(value: T): GenericSignal; + + /** Creates a computed signal based on other signals. */ + computed(compute: () => T): GenericSignal; + + /** + * Runs an effect that automatically tracks and re-executes when its signal + * dependencies change. + * @returns A cleanup function to stop the effect. + */ + effect(callback: () => void): () => void; + + /** + * Returns true if the value is a reactive signal (either generic or native). + */ + isSignal(v: any): v is GenericSignal; + + /** + * Coerces a value into a GenericSignal. + * - If it's already a GenericSignal from this provider, returns it. + * - If it's a native signal from this provider's backend, wraps it. + * - Otherwise, creates a new signal containing the literal value. + */ + toGenericSignal(value: T | GenericSignal): GenericSignal; + + /** + * Batches multiple signal updates so that effects and computations are + * triggered only once at the end of the batch. + */ + batch(callback: () => T): T; +} diff --git a/renderers/web_core/src/v0_9/index.ts b/renderers/web_core/src/v0_9/index.ts index d973eae52..f79148b92 100644 --- a/renderers/web_core/src/v0_9/index.ts +++ b/renderers/web_core/src/v0_9/index.ts @@ -24,18 +24,22 @@ export * from "./catalog/function_invoker.js"; export * from "./catalog/types.js"; export * from "./common/events.js"; +export * from "./common/reactive.js"; export * from "./processing/message-processor.js"; export * from "./rendering/component-context.js"; export * from "./rendering/data-context.js"; export * from "./rendering/generic-binder.js"; export * from "./schema/index.js"; +export * from "./schema/client-to-server.js"; export * from "./state/component-model.js"; export * from "./state/data-model.js"; export * from "./state/surface-components-model.js"; export * from "./state/surface-group-model.js"; export * from "./state/surface-model.js"; export * from "./errors.js"; +export * from "./testing.js"; +export { PreactReactiveProvider } from "./common/preact-provider.js"; export { effect, Signal } from "@preact/signals-core"; import A2uiMessageSchemaRaw from "./schemas/server_to_client.json" with { type: "json" }; diff --git a/renderers/web_core/src/v0_9/processing/message-processor.ts b/renderers/web_core/src/v0_9/processing/message-processor.ts index 2c9939fa9..f9371f1d3 100644 --- a/renderers/web_core/src/v0_9/processing/message-processor.ts +++ b/renderers/web_core/src/v0_9/processing/message-processor.ts @@ -19,6 +19,8 @@ import { Catalog, ComponentApi } from "../catalog/types.js"; import { SurfaceGroupModel } from "../state/surface-group-model.js"; import { ComponentModel } from "../state/component-model.js"; import { Subscription } from "../common/events.js"; +import { ReactiveProvider } from "../common/reactive.js"; +import { PreactReactiveProvider } from "../common/preact-provider.js"; import { A2uiMessage, @@ -42,12 +44,14 @@ export class MessageProcessor { * * @param catalogs A list of available catalogs. * @param actionHandler A global handler for actions from all surfaces. + * @param provider The reactive provider to use for signals and effects. */ constructor( private catalogs: Catalog[], private actionHandler?: ActionListener, + private provider: ReactiveProvider = new PreactReactiveProvider(), ) { - this.model = new SurfaceGroupModel(); + this.model = new SurfaceGroupModel(this.provider); if (this.actionHandler) { this.model.onAction.subscribe(this.actionHandler); } @@ -152,6 +156,7 @@ export class MessageProcessor { const surface = new SurfaceModel( surfaceId, catalog, + this.provider, theme, sendDataModel ?? false, ); diff --git a/renderers/web_core/src/v0_9/rendering/data-context.test.ts b/renderers/web_core/src/v0_9/rendering/data-context.test.ts index 814e4d845..fa78835b1 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.test.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.test.ts @@ -21,6 +21,7 @@ import { z } from "zod"; import { DataModel } from "../state/data-model.js"; import { DataContext } from "./data-context.js"; import { A2uiExpressionError } from "../errors.js"; +import { PreactReactiveProvider } from "../common/preact-provider.js"; const createTestDataContext = ( model: DataModel, @@ -32,6 +33,7 @@ const createTestDataContext = ( dataModel: model, catalog: { invoker: functionInvoker }, dispatchError, + provider: new PreactReactiveProvider(), } as any; return new DataContext(mockSurface, path); }; diff --git a/renderers/web_core/src/v0_9/rendering/data-context.ts b/renderers/web_core/src/v0_9/rendering/data-context.ts index 5e4b21c25..75410e946 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { signal, computed, Signal, effect } from "@preact/signals-core"; +import { GenericSignal } from "../common/reactive.js"; import { z } from "zod"; import { DataModel, DataSubscription } from "../state/data-model.js"; import type { @@ -115,7 +115,7 @@ export class DataContext { return undefined as any; } - return (isSignal(result) ? result.peek() : result) as V; + return (this.surface.provider.isSignal(result) ? (result as any).peek() : result) as V; } return value as V; @@ -142,7 +142,7 @@ export class DataContext { let isSync = true; let currentValue = sig.peek(); - const dispose = effect(() => { + const dispose = this.surface.provider.effect(() => { const val = sig.value; currentValue = val; if (!isSync) { @@ -174,22 +174,22 @@ export class DataContext { * @param value The DynamicValue to evaluate and observe. * @returns A Preact Signal containing the reactive result of the evaluation. */ - resolveSignal(value: DynamicValue): Signal { + resolveSignal(value: DynamicValue): GenericSignal { // 1. Literal if (typeof value !== "object" || value === null || Array.isArray(value)) { - return signal(value as V); + return this.surface.provider.toGenericSignal(value as V); } // 2. Path Check if ("path" in value) { const absolutePath = this.resolvePath((value as DataBinding).path); - return this.dataModel.getSignal(absolutePath) as Signal; + return this.dataModel.getSignal(absolutePath) as GenericSignal; } // 3. Function Call if ("call" in value) { const call = value as FunctionCall; - const argSignals: Record> = {}; + const argSignals: Record> = {}; for (const [key, argVal] of Object.entries(call.args)) { argSignals[key] = this.resolveSignal(argVal); @@ -202,25 +202,23 @@ export class DataContext { {}, abortController.signal, ); - const sig = result instanceof Signal ? result : signal(result); - (sig as any).unsubscribe = () => abortController.abort(); - return sig; + return this.surface.provider.toGenericSignal(result as V); } const keys = Object.keys(argSignals); - const resultSig = signal(undefined); + const resultSig = this.surface.provider.signal(undefined); let abortController: AbortController | undefined; let innerUnsubscribe: (() => void) | undefined; - const argsSig = computed(() => { + const argsSig = this.surface.provider.computed(() => { const argsRecord: Record = {}; for (let i = 0; i < keys.length; i++) { - argsRecord[keys[i]] = argSignals[keys[i]].value; + argsRecord[keys[i]] = argSignals[keys[i]](); } return argsRecord; }); - const stopper = effect(() => { + const stopper = this.surface.provider.effect(() => { try { const args = argsSig.value; if (abortController) abortController.abort(); @@ -236,9 +234,9 @@ export class DataContext { abortController.signal, ); - if (isSignal(res)) { - innerUnsubscribe = effect(() => { - resultSig.value = res.value; + if (this.surface.provider.isSignal(res)) { + innerUnsubscribe = this.surface.provider.effect(() => { + resultSig.value = (res as any).value; }); } else { resultSig.value = res; @@ -262,10 +260,10 @@ export class DataContext { } }; - return resultSig as unknown as Signal; + return resultSig as unknown as GenericSignal; } - return signal(value as unknown as V); + return this.surface.provider.toGenericSignal(value as unknown as V); } /** @@ -303,7 +301,7 @@ export class DataContext { name: string, args: Record, abortSignal?: AbortSignal, - ): Signal | V { + ): GenericSignal | V { try { return this.functionInvoker(name, args, this, abortSignal); } catch (e: any) { diff --git a/renderers/web_core/src/v0_9/state/data-model.ts b/renderers/web_core/src/v0_9/state/data-model.ts index 7455f2730..03aff487f 100644 --- a/renderers/web_core/src/v0_9/state/data-model.ts +++ b/renderers/web_core/src/v0_9/state/data-model.ts @@ -16,7 +16,8 @@ import { Subscription as BaseSubscription } from "../common/events.js"; import { A2uiDataError } from "../errors.js"; -import { signal, Signal, batch, effect } from "@preact/signals-core"; +import { ReactiveProvider, GenericSignal } from "../common/reactive.js"; +import { PreactReactiveProvider } from "../common/preact-provider.js"; /** * Represents a reactive connection to a specific path in the data model. @@ -38,15 +39,19 @@ function isNumeric(value: string): boolean { */ export class DataModel { private data: Record = {}; - private readonly signals: Map> = new Map(); + private readonly signals: Map> = new Map(); private readonly subscriptions: Set<() => void> = new Set(); // To track direct subscriptions for dispose /** * Creates a new data model. * * @param initialData The initial data for the model. Defaults to an empty object. + * @param provider The reactive provider to use. Defaults to Preact. */ - constructor(initialData: Record = {}) { + constructor( + initialData: Record = {}, + private readonly provider: ReactiveProvider = new PreactReactiveProvider(), + ) { this.data = initialData; } @@ -59,12 +64,12 @@ export class DataModel { * @param path The JSON pointer path to create or retrieve a signal for. * @returns A Preact Signal representing the value at the specified path. */ - getSignal(path: string): Signal { + getSignal(path: string): GenericSignal { const normalizedPath = this.normalizePath(path); if (!this.signals.has(normalizedPath)) { - this.signals.set(normalizedPath, signal(this.get(normalizedPath))); + this.signals.set(normalizedPath, this.provider.signal(this.get(normalizedPath))); } - return this.signals.get(normalizedPath) as Signal; + return this.signals.get(normalizedPath) as GenericSignal; } /** @@ -189,7 +194,7 @@ export class DataModel { let isSync = true; let currentValue = sig.peek(); - const dispose = effect(() => { + const dispose = this.provider.effect(() => { const val = sig.value; currentValue = val; if (!isSync) { @@ -236,7 +241,7 @@ export class DataModel { private notifySignals(path: string): void { const normalizedPath = this.normalizePath(path); - batch(() => { + this.provider.batch(() => { this.updateSignal(normalizedPath); // Notify Ancestors @@ -270,7 +275,7 @@ export class DataModel { } private notifyAllSignals(): void { - batch(() => { + this.provider.batch(() => { for (const path of this.signals.keys()) { this.updateSignal(path); } diff --git a/renderers/web_core/src/v0_9/state/surface-group-model.test.ts b/renderers/web_core/src/v0_9/state/surface-group-model.test.ts index 51b0e77fe..de5c5e226 100644 --- a/renderers/web_core/src/v0_9/state/surface-group-model.test.ts +++ b/renderers/web_core/src/v0_9/state/surface-group-model.test.ts @@ -30,22 +30,22 @@ describe("SurfaceGroupModel", () => { }); it("adds surface", () => { - const surface = new SurfaceModel("s1", catalog, {}); + const surface = new SurfaceModel("s1", catalog); model.addSurface(surface); assert.ok(model.getSurface("s1")); assert.strictEqual(model.getSurface("s1"), surface); }); it("ignores duplicate surface addition", () => { - const s1 = new SurfaceModel("s1", catalog, {}); - const s2 = new SurfaceModel("s1", catalog, {}); // Same ID + const s1 = new SurfaceModel("s1", catalog); + const s2 = new SurfaceModel("s1", catalog); // Same ID model.addSurface(s1); model.addSurface(s2); assert.strictEqual(model.getSurface("s1"), s1); // Should still be the first one }); it("deletes surface", () => { - const surface = new SurfaceModel("s1", catalog, {}); + const surface = new SurfaceModel("s1", catalog); model.addSurface(surface); assert.ok(model.getSurface("s1")); @@ -64,7 +64,7 @@ describe("SurfaceGroupModel", () => { deletedId = id; }); - const surface = new SurfaceModel("s1", catalog, {}); + const surface = new SurfaceModel("s1", catalog); model.addSurface(surface); assert.ok(created); assert.strictEqual(created?.id, "s1"); @@ -79,7 +79,7 @@ describe("SurfaceGroupModel", () => { receivedAction = action; }); - const surface = new SurfaceModel("s1", catalog, {}); + const surface = new SurfaceModel("s1", catalog); model.addSurface(surface); await surface.dispatchAction({ event: { name: "test" } }, "c1"); @@ -94,7 +94,7 @@ describe("SurfaceGroupModel", () => { callCount++; }); - const surface = new SurfaceModel("s1", catalog, {}); + const surface = new SurfaceModel("s1", catalog); model.addSurface(surface); model.deleteSurface("s1"); @@ -103,7 +103,7 @@ describe("SurfaceGroupModel", () => { }); it("exposes surfacesMap", () => { - const surface = new SurfaceModel("s1", catalog, {}); + const surface = new SurfaceModel("s1", catalog); model.addSurface(surface); const map = model.surfacesMap; assert.strictEqual(map.size, 1); @@ -111,8 +111,8 @@ describe("SurfaceGroupModel", () => { }); it("disposes correctly", () => { - const s1 = new SurfaceModel("s1", catalog, {}); - const s2 = new SurfaceModel("s2", catalog, {}); + const s1 = new SurfaceModel("s1", catalog); + const s2 = new SurfaceModel("s2", catalog); model.addSurface(s1); model.addSurface(s2); diff --git a/renderers/web_core/src/v0_9/state/surface-group-model.ts b/renderers/web_core/src/v0_9/state/surface-group-model.ts index 21cc91ad4..94f80c989 100644 --- a/renderers/web_core/src/v0_9/state/surface-group-model.ts +++ b/renderers/web_core/src/v0_9/state/surface-group-model.ts @@ -18,6 +18,8 @@ import { SurfaceModel } from "./surface-model.js"; import { ComponentApi } from "../catalog/types.js"; import { EventEmitter, EventSource, Subscription } from "../common/events.js"; import { A2uiClientAction } from "../schema/client-to-server.js"; +import { ReactiveProvider } from "../common/reactive.js"; +import { PreactReactiveProvider } from "../common/preact-provider.js"; /** * The root state model for the A2UI system. @@ -27,6 +29,10 @@ export class SurfaceGroupModel { private surfaces: Map> = new Map(); private surfaceUnsubscribers: Map = new Map(); + constructor( + public readonly provider: ReactiveProvider = new PreactReactiveProvider(), + ) {} + private readonly _onSurfaceCreated = new EventEmitter>(); private readonly _onSurfaceDeleted = new EventEmitter(); private readonly _onAction = new EventEmitter(); diff --git a/renderers/web_core/src/v0_9/state/surface-model.test.ts b/renderers/web_core/src/v0_9/state/surface-model.test.ts index 9359aac2d..c322bd33d 100644 --- a/renderers/web_core/src/v0_9/state/surface-model.test.ts +++ b/renderers/web_core/src/v0_9/state/surface-model.test.ts @@ -31,7 +31,7 @@ describe("SurfaceModel", () => { actions = []; errors = []; catalog = new Catalog("test-catalog", []); - surface = new SurfaceModel("surface-1", catalog, {}); + surface = new SurfaceModel("surface-1", catalog); surface.onAction.subscribe(async (action) => { actions.push(action); }); diff --git a/renderers/web_core/src/v0_9/state/surface-model.ts b/renderers/web_core/src/v0_9/state/surface-model.ts index a95b1af9a..73b906bf1 100644 --- a/renderers/web_core/src/v0_9/state/surface-model.ts +++ b/renderers/web_core/src/v0_9/state/surface-model.ts @@ -22,6 +22,8 @@ import { A2uiClientAction, A2uiClientActionSchema, } from "../schema/client-to-server.js"; +import { ReactiveProvider } from "../common/reactive.js"; +import { PreactReactiveProvider } from "../common/preact-provider.js"; /** A function that listens for actions emitted from a surface. */ export type ActionListener = (action: A2uiClientAction) => void | Promise; @@ -60,10 +62,11 @@ export class SurfaceModel { constructor( readonly id: string, readonly catalog: Catalog, + public readonly provider: ReactiveProvider = new PreactReactiveProvider(), readonly theme: any = {}, readonly sendDataModel: boolean = false, ) { - this.dataModel = new DataModel({}); + this.dataModel = new DataModel({}, provider); this.componentsModel = new SurfaceComponentsModel(); } diff --git a/renderers/web_core/src/v0_9/test/test-utils.ts b/renderers/web_core/src/v0_9/test/test-utils.ts index 384def9fd..07adfd0e9 100644 --- a/renderers/web_core/src/v0_9/test/test-utils.ts +++ b/renderers/web_core/src/v0_9/test/test-utils.ts @@ -21,7 +21,7 @@ import { ComponentModel } from "../state/component-model.js"; export class TestSurfaceModel extends SurfaceModel { constructor(actionHandler: any = async () => {}) { - super("test", new Catalog("test-catalog", []), {}); + super("test", new Catalog("test-catalog", [])); this.onAction.subscribe(actionHandler); } } diff --git a/renderers/web_core/src/v0_9/testing.ts b/renderers/web_core/src/v0_9/testing.ts new file mode 100644 index 000000000..1ffc9e12a --- /dev/null +++ b/renderers/web_core/src/v0_9/testing.ts @@ -0,0 +1,44 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the \"License\"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an \"AS IS\" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Catalog } from './catalog/types.js'; +import { BASIC_COMPONENTS } from './basic_catalog/components/basic_components.js'; +import { BASIC_FUNCTIONS } from './basic_catalog/functions/basic_functions.js'; +import { PreactReactiveProvider } from './common/preact-provider.js'; +import { ReactiveProvider } from './common/reactive.js'; +import { DataContext } from './rendering/data-context.js'; +import { SurfaceModel } from './state/surface-model.js'; + +/** + * A standard catalog containing all basic A2UI components and functions. + */ +export const BasicCatalog = new Catalog('basic', BASIC_COMPONENTS, BASIC_FUNCTIONS); + +/** + * Creates a {@link DataContext} with a given {@link ReactiveProvider} (defaults to {@link PreactReactiveProvider}) for testing purposes. + * + * @param provider Optional provider to use for the context. + * @param surfaceId Optional ID for the surface. + * @param basePath Optional base path in the data model. + * @returns A fresh DataContext prepopulated with a reactive provider. + */ +export function createTestDataContext(provider?: ReactiveProvider, surfaceId = 'test_surface', basePath = '/') { + const p = provider ?? new PreactReactiveProvider(); + const mockSurface = new SurfaceModel(surfaceId, BasicCatalog, p); + return new DataContext(mockSurface, basePath); +} + +export { PreactReactiveProvider as TestReactiveProvider };