diff --git a/renderers/angular/package-lock.json b/renderers/angular/package-lock.json index 7af8037d8..9f0839cc8 100644 --- a/renderers/angular/package-lock.json +++ b/renderers/angular/package-lock.json @@ -48,7 +48,7 @@ }, "../web_core": { "name": "@a2ui/web_core", - "version": "0.8.5", + "version": "0.8.6", "license": "Apache-2.0", "dependencies": { "@preact/signals-core": "^1.13.0", diff --git a/renderers/angular/src/public-api.ts b/renderers/angular/src/public-api.ts index 07f26a00d..7382d9636 100644 --- a/renderers/angular/src/public-api.ts +++ b/renderers/angular/src/public-api.ts @@ -1,11 +1,11 @@ -/* - * Copyright 2025 Google LLC +/** + * 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 * - * https://www.apache.org/licenses/LICENSE-2.0 + * 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, @@ -14,8 +14,4 @@ * limitations under the License. */ -export * from './lib/rendering/index'; -export * from './lib/data/index'; -export * from './lib/config'; -export * from './lib/catalog/default'; -export { Surface } from './lib/catalog/surface'; +export * from '../v0_8/public-api'; diff --git a/renderers/angular/v0_8/catalog/catalog.spec.ts b/renderers/angular/v0_8/catalog/catalog.spec.ts new file mode 100644 index 000000000..319b2d6c9 --- /dev/null +++ b/renderers/angular/v0_8/catalog/catalog.spec.ts @@ -0,0 +1,325 @@ +/* + * 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 + * + * https://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 { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, Input, computed, inputBinding } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { Row } from '../components/row'; +import { Column } from '../components/column'; +import { Text as TextComponent } from '../components/text'; +import { Button } from '../components/button'; +import { List } from '../components/list'; +import { TextField } from '../components/text-field'; + +import { DynamicComponent } from '../rendering/dynamic-component'; +import { Renderer } from '../rendering/renderer'; +import { Types } from '../types'; +import { MarkdownRenderer } from '../data/markdown'; + +import { Theme } from '../rendering/theming'; +import { MessageProcessor } from '../data/processor'; +import { Catalog } from '../rendering/catalog'; + +// Mock context will be handled by MessageProcessor mock +const mockContext = { + resolveData: (path: string) => { + if (path === '/data/text') return 'Dynamic Text'; + if (path === '/data/label') return 'Dynamic Label'; + return null; + }, +}; + +@Component({ + selector: 'test-host', + imports: [Row, Column, TextComponent, Button, List, TextField], + template: ` + @if (type === 'Row') { + + } @else if (type === 'Column') { + + } @else if (type === 'Text') { + + } @else if (type === 'Button') { + + } @else if (type === 'List') { + + } @else if (type === 'TextField') { + + } + `, +}) +class TestHostComponent { + @Input() type = 'Row'; + @Input() componentData: any; +} + +describe('Catalog Components', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let mockMessageProcessor: any; + let mockDataModel: any; + let mockSurfaceModel: any; + + beforeEach(async () => { + mockDataModel = { + get: jasmine.createSpy('get').and.callFake((path: string) => { + if (path === '/data/text') return 'Dynamic Text'; + if (path === '/data/label') return 'Dynamic Label'; + if (path === '/data/items') return ['Item 1', 'Item 2']; + return null; + }), + subscribe: jasmine.createSpy('subscribe').and.returnValue({ + unsubscribe: () => {}, + }), + }; + + mockSurfaceModel = { + dataModel: mockDataModel, + componentsModel: new Map([ + ['child1', { id: 'child1', type: 'Text', properties: { text: { literal: 'Child Text' } } }], + ['item1', { id: 'item1', type: 'Text', properties: { text: { literal: 'Item 1' } } }], + ['item2', { id: 'item2', type: 'Text', properties: { text: { literal: 'Item 2' } } }], + ]), + }; + + const surfaceSignal = () => mockSurfaceModel; + + mockMessageProcessor = { + model: { + getSurface: (id: string) => mockSurfaceModel, + }, + getDataModel: jasmine.createSpy('getDataModel').and.returnValue(mockDataModel), + getData: (node: any, path: string) => mockDataModel.get(path), + getSurfaceSignal: () => surfaceSignal, + sendAction: jasmine.createSpy('sendAction'), + getSurfaces: () => new Map([['test-surface', mockSurfaceModel]]), + }; + + await TestBed.configureTestingModule({ + imports: [TestHostComponent, Row, Column, TextComponent, Button, List], + providers: [ + { provide: MarkdownRenderer, useValue: { render: (s: string) => Promise.resolve(s) } }, + + { provide: MessageProcessor, useValue: mockMessageProcessor }, + { + provide: Theme, + useValue: { + components: { + Text: { all: {}, h1: { 'h1-class': true }, body: { 'body-class': true } }, + Row: { 'row-class': true }, + Column: { 'column-class': true }, + Button: { 'button-class': true }, + List: { 'list-class': true }, + TextField: { + container: { 'tf-container': true }, + label: { 'tf-label': true }, + element: { 'tf-element': true }, + }, + }, + additionalStyles: {}, + }, + }, + { provide: MessageProcessor, useValue: mockMessageProcessor }, + { + provide: Catalog, + useValue: { + Text: { + type: async () => TextComponent, + bindings: (node: any) => [ + inputBinding('text', () => node.properties.text), + inputBinding('usageHint', () => node.properties.usageHint || null), + ], + }, + Row: { type: async () => Row, bindings: () => [] }, + Column: { type: async () => Column, bindings: () => [] }, + Button: { type: async () => Button, bindings: () => [] }, + List: { type: async () => List, bindings: () => [] }, + TextField: { type: async () => TextField, bindings: () => [] }, + } as any, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + }); + + describe('Row', () => { + it('should map justify and align properties correctly', () => { + host.type = 'Row'; + host.componentData = { + id: 'row1', + type: 'Row', + properties: { + children: [], + justify: 'spaceBetween', + align: 'center', + }, + } as Types.RowNode; + fixture.detectChanges(); + + const rowEl = fixture.debugElement.query(By.css('a2ui-row')); + const section = rowEl.query(By.css('section')); + expect(section.classes['distribute-spaceBetween']).toBeTrue(); + expect(section.classes['align-center']).toBeTrue(); + }); + }); + + describe('Column', () => { + it('should map justify and align properties correctly', () => { + host.type = 'Column'; + host.componentData = { + id: 'col1', + type: 'Column', + properties: { + children: [], + justify: 'end', + align: 'start', + }, + } as Types.ColumnNode; + fixture.detectChanges(); + + const colEl = fixture.debugElement.query(By.css('a2ui-column')); + const section = colEl.query(By.css('section')); + expect(section.classes['distribute-end']).toBeTrue(); + expect(section.classes['align-start']).toBeTrue(); + }); + }); + + describe('Text', () => { + it('should resolve text content', async () => { + host.type = 'Text'; + host.componentData = { + id: 'txt1', + type: 'Text', + properties: { + text: { literal: 'Hello World' }, + usageHint: 'h1', + }, + } as Types.TextNode; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const textEl = fixture.debugElement.query(By.css('a2ui-text')); + expect(textEl.nativeElement.innerHTML).toContain('# Hello World'); + }); + }); + + describe('Button', () => { + it('should render child component', () => { + // Mock Renderer Service/Context because Button uses a2ui-renderer for child + // For this unit test, we might just check if it tries to resolve the child. + // But Button uses + // We need to provide a mock SurfaceModel to the Button via the Context? + // Actually DynamicComponent uses `inject(ElementRef)` etc. + // Let's keep it simple for now and verify existence. + host.type = 'Button'; + host.componentData = { + id: 'btn1', + type: 'Button', + properties: { + child: 'child1', + label: 'Legacy Label', + }, + } as any; + fixture.detectChanges(); + const btnEl = fixture.debugElement.query(By.css('button')); + expect(btnEl).toBeTruthy(); + }); + }); + + describe('List', () => { + it('should render items', async () => { + host.type = 'List'; + host.componentData = { + id: 'list1', + type: 'List', + properties: { + children: [ + { + id: '1', + type: 'Text', + properties: { text: { literal: 'Item 1' }, variant: { literal: 'body' } }, + }, + { + id: '2', + type: 'Text', + properties: { text: { literal: 'Item 2' }, variant: { literal: 'body' } }, + }, + ], + }, + } as any; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const listEl = fixture.debugElement.query(By.css('a2ui-list')); + const items = listEl.queryAll(By.css('a2ui-text')); // Assuming items render as Text + expect(items.length).toBe(2); + expect(items[0].nativeElement.textContent).toContain('Item 1'); + }); + }); + + describe('TextField', () => { + it('should render input with value', () => { + host.type = 'TextField'; + host.componentData = { + id: 'tf1', + type: 'TextField', + properties: { + label: { literal: 'My Input' }, + value: { path: '/data/text' }, + }, + } as any; + fixture.detectChanges(); + + const inputEl = fixture.debugElement.query(By.css('input')); + // Component might use [value] or ngModel + // Let's check native element value if bound + // If it uses Custom Input implementation, check that. + // TextField usually has a label and an input. + expect(inputEl.nativeElement.value).toBe('Dynamic Text'); + }); + }); +}); diff --git a/renderers/angular/src/lib/catalog/default.ts b/renderers/angular/v0_8/catalog/index.ts similarity index 61% rename from renderers/angular/src/lib/catalog/default.ts rename to renderers/angular/v0_8/catalog/index.ts index a794a323a..1cd6d1318 100644 --- a/renderers/angular/src/lib/catalog/default.ts +++ b/renderers/angular/v0_8/catalog/index.ts @@ -15,16 +15,13 @@ */ import { inputBinding } from '@angular/core'; -import * as Types from '@a2ui/web_core/types/types'; +import { Types } from '../types'; import { Catalog } from '../rendering/catalog'; -import { Row } from './row'; -import { Column } from './column'; -import { Text } from './text'; -export const DEFAULT_CATALOG: Catalog = { +export const CATALOG: Catalog = { Row: { - type: () => Row, - bindings: (node) => { + type: () => import('../components/row').then((r) => r.Row), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.RowNode).properties; return [ inputBinding('alignment', () => properties.alignment ?? 'stretch'), @@ -34,8 +31,8 @@ export const DEFAULT_CATALOG: Catalog = { }, Column: { - type: () => Column, - bindings: (node) => { + type: () => import('../components/column').then((r) => r.Column), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.ColumnNode).properties; return [ inputBinding('alignment', () => properties.alignment ?? 'stretch'), @@ -45,18 +42,18 @@ export const DEFAULT_CATALOG: Catalog = { }, List: { - type: () => import('./list').then((r) => r.List), - bindings: (node) => { + type: () => import('../components/list').then((r) => r.List), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.ListNode).properties; return [inputBinding('direction', () => properties.direction ?? 'vertical')]; }, }, - Card: () => import('./card').then((r) => r.Card), + Card: () => import('../components/card').then((r) => r.Card), Image: { - type: () => import('./image').then((r) => r.Image), - bindings: (node) => { + type: () => import('../components/image').then((r) => r.Image), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.ImageNode).properties; return [ inputBinding('url', () => properties.url), @@ -67,80 +64,77 @@ export const DEFAULT_CATALOG: Catalog = { }, Icon: { - type: () => import('./icon').then((r) => r.Icon), - bindings: (node) => { + type: () => import('../components/icon').then((r) => r.Icon), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.IconNode).properties; return [inputBinding('name', () => properties.name)]; }, }, Video: { - type: () => import('./video').then((r) => r.Video), - bindings: (node) => { + type: () => import('../components/video').then((r) => r.Video), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.VideoNode).properties; return [inputBinding('url', () => properties.url)]; }, }, AudioPlayer: { - type: () => import('./audio').then((r) => r.Audio), - bindings: (node) => { + type: () => import('../components/audio').then((r) => r.Audio), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.AudioPlayerNode).properties; return [inputBinding('url', () => properties.url)]; }, }, Text: { - type: () => Text, - bindings: (node) => { + type: () => import('../components/text').then((r) => r.Text), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.TextNode).properties; return [ inputBinding('text', () => properties.text), - inputBinding('usageHint', () => properties.usageHint || null), + inputBinding('usageHint', () => properties.usageHint), ]; }, }, Button: { - type: () => import('./button').then((r) => r.Button), - bindings: (node) => { + type: () => import('../components/button').then((r) => r.Button), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.ButtonNode).properties; - return [ - inputBinding('action', () => properties.action), - inputBinding('primary', () => properties.primary), - ]; + return [inputBinding('action', () => properties.action)]; }, }, - Divider: () => import('./divider').then((r) => r.Divider), + Divider: () => import('../components/divider').then((r) => r.Divider), MultipleChoice: { - type: () => import('./multiple-choice').then((r) => r.MultipleChoice), - bindings: (node) => { + type: () => import('../components/multiple-choice').then((r) => r.MultipleChoice), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.MultipleChoiceNode).properties; return [ inputBinding('options', () => properties.options || []), inputBinding('value', () => properties.selections), - inputBinding('description', () => 'Select an item'), // TODO: this should be defined in the properties + inputBinding('description', () => 'Select an item'), ]; }, }, TextField: { - type: () => import('./text-field').then((r) => r.TextField), - bindings: (node) => { + type: () => import('../components/text-field').then((r) => r.TextField), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.TextFieldNode).properties; return [ inputBinding('text', () => properties.text ?? null), inputBinding('label', () => properties.label), - inputBinding('textFieldType', () => properties.textFieldType), + inputBinding('inputType', () => properties.textFieldType), ]; }, }, DateTimeInput: { - type: () => import('./datetime-input').then((r) => r.DatetimeInput), - bindings: (node) => { + type: () => import('../components/datetime-input').then((r) => r.DatetimeInput), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.DateTimeInputNode).properties; return [ inputBinding('enableDate', () => properties.enableDate), @@ -151,8 +145,8 @@ export const DEFAULT_CATALOG: Catalog = { }, CheckBox: { - type: () => import('./checkbox').then((r) => r.Checkbox), - bindings: (node) => { + type: () => import('../components/checkbox').then((r) => r.Checkbox), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.CheckboxNode).properties; return [ inputBinding('label', () => properties.label), @@ -162,28 +156,30 @@ export const DEFAULT_CATALOG: Catalog = { }, Slider: { - type: () => import('./slider').then((r) => r.Slider), - bindings: (node) => { + type: () => import('../components/slider').then((r) => r.Slider), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.SliderNode).properties; return [ inputBinding('value', () => properties.value), inputBinding('minValue', () => properties.minValue), inputBinding('maxValue', () => properties.maxValue), - inputBinding('label', () => ''), // TODO: this should be defined in the properties + inputBinding('label', () => ''), ]; }, }, Tabs: { - type: () => import('./tabs').then((r) => r.Tabs), - bindings: (node) => { + type: () => import('../components/tabs').then((r) => r.Tabs), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.TabsNode).properties; return [inputBinding('tabs', () => properties.tabItems)]; }, }, Modal: { - type: () => import('./modal').then((r) => r.Modal), + type: () => import('../components/modal').then((r) => r.Modal), bindings: () => [], }, }; + +export const V0_8_CATALOG = CATALOG; diff --git a/renderers/angular/v0_8/components/audio.spec.ts b/renderers/angular/v0_8/components/audio.spec.ts new file mode 100644 index 000000000..9ddaf92ec --- /dev/null +++ b/renderers/angular/v0_8/components/audio.spec.ts @@ -0,0 +1,106 @@ +/* + * 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 + * + * https://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 { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Audio } from './audio'; +import { MessageProcessor } from '../data/processor'; +import { Theme } from '../rendering/theming'; +import { Catalog } from '../rendering/catalog'; +import { MarkdownRenderer } from '../data/markdown'; +import { PLATFORM_ID } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +describe('Audio', () => { + let component: Audio; + let fixture: ComponentFixture