// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR // SPDX-License-Identifier: AGPL-3.0-or-later // Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { environment } from '@environment'; import { DEFAULT_NODE_CAPABILITIES } from '@models/flow'; import { BlocksService } from '@services/blocks/blocks'; import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog'; import { FieldRetriever } from '@services/retriever/field-retriever'; import { LlmProviderService } from '@services/llm-provider/llm-provider'; import { EditorStateHolder } from '@stores/flow-editor'; import { vi } from 'vitest'; import { of, throwError } from 'rxjs'; import { GenericNodeComponent } from './generic-node'; describe('GenericNodeComponent', () => { let component: GenericNodeComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [GenericNodeComponent], providers: [ provideHttpClient(), provideHttpClientTesting(), { provide: NodeSettingsDialogService, useValue: { open: vi.fn().mockResolvedValue(null) } }, { provide: LlmProviderService, useValue: { listCapabilities: vi.fn(() => of([ { name: 'Gemini', requiresCredential: true, requiresEndpoint: false, supportedParameters: ['MAX_TOKENS', 'TEMPERATURE', 'TOP_K', 'TOP_P'] } ])) } }, { provide: EditorStateHolder, useValue: { currentFlow: vi.fn().mockReturnValue(null), activeFlowData: vi.fn().mockReturnValue(null), flowValidationErrors: vi.fn().mockReturnValue([]), isBlockSelected: vi.fn().mockReturnValue(false), isValidationNodeHighlighted: vi.fn().mockReturnValue(false), updateData: vi.fn(), stopDraggingSelectedBlocks: vi.fn() } }, { provide: FieldRetriever, useValue: { retrieveSchema: vi.fn(), retrieveStructuredData: vi.fn(), retrieveText: vi.fn(), retrieveValues: vi.fn(() => of([])), isFieldOpen: vi.fn(() => of(false)) } }, { provide: BlocksService, useValue: { peekBlockType: vi.fn().mockReturnValue(null), getBlockType: vi.fn().mockResolvedValue(null), biasAnnotationsDescriptor: vi.fn().mockReturnValue(null), updateBlock: vi.fn() } } ] }) .compileComponents(); fixture = TestBed.createComponent(GenericNodeComponent); component = fixture.componentInstance; fixture.componentRef.setInput('data', { id: 'node-1', inputs: {}, outputs: {}, selected: false, data: { id: 'node-1', typeName: '', name: 'Node 1', inputs: [], outputs: [], specificConfiguration: { name: 'Node 1' } } }); fixture.componentRef.setInput('emit', vi.fn()); fixture.componentRef.setInput('rendered', vi.fn()); fixture.detectChanges(); await fixture.whenStable(); }); describe('clearing an optional number', () => { /** Opens the modal editor on a numeric path, as clicking the pen does. */ function openNumericEditor(path: string, current: unknown) { const config = (component as any).ensureBlockConfiguration(); if (current !== undefined) { (component as any).setByPathForTest?.(config, path, current); } (component as any).localEditorPath = path; (component as any).localEditorType = 'number'; (component as any).localEditorLabel = path; (component as any).localEditorOpen = true; (component as any).localEditorUseInput = false; (component as any).localEditorMaxLength = null; return config; } it('removes the key instead of saving 0', () => { // Number('') is 0, so an emptied box used to persist a real 0. On a temperature that is the // worst possible confusion: 0 is a valid setting, so there was no way back to the default. const config = openNumericEditor('llmDescriptor.parameters.temperature', undefined); config['llmDescriptor'] = { provider: 'p', model: 'm', parameters: { temperature: 0.7 } }; (component as any).localEditorValue = ''; (component as any).saveSimpleParamEditor(); expect(config['llmDescriptor'].parameters?.temperature).toBeUndefined(); }); it('saves a typed 0, because it is a value and not an absence', () => { const config = openNumericEditor('llmDescriptor.parameters.temperature', undefined); config['llmDescriptor'] = { provider: 'p', model: 'm', parameters: {} }; (component as any).localEditorValue = '0'; (component as any).saveSimpleParamEditor(); expect(config['llmDescriptor'].parameters.temperature).toBe(0); }); it('refuses to save a value outside the schema bounds', () => { // Same check the settings dialog runs, on the other place the value can be typed. const component = fixture.componentInstance as any; openNumericEditor('llmDescriptor.parameters.temperature', undefined); component.localEditorMin = 0; component.localEditorMax = 1; component.localEditorValue = '5'; expect(component.localEditorError()).toBe('Must be between 0 and 1'); expect(component.canSaveLocalEditor()).toBe(false); component.localEditorValue = '0.7'; expect(component.localEditorError()).toBeNull(); expect(component.canSaveLocalEditor()).toBe(true); }); it('still lets an emptied optional field be saved, which is how it is unset', () => { const component = fixture.componentInstance as any; openNumericEditor('llmDescriptor.parameters.temperature', undefined); component.localEditorMin = 0; component.localEditorMax = 1; component.localEditorValue = ''; expect(component.localEditorError()).toBeNull(); expect(component.canSaveLocalEditor()).toBe(true); }); it('says an empty optional field is using the default, and offers the way back once set', () => { const component = fixture.componentInstance as any; openNumericEditor('llmDescriptor.parameters.temperature', undefined); component.localEditorDefaultsWhenEmpty = true; component.localEditorValue = ''; expect(component.localEditorUsesDefault()).toBe(true); expect(component.localEditorDefaultHint()).toBe('Using the default'); // Present but inert, the same as in the settings dialog: it keeps its place beside the field. expect(component.showLocalEditorDefault()).toBe(true); expect(component.canUseLocalEditorDefault()).toBe(false); component.localEditorValue = '0.7'; expect(component.localEditorDefaultHint()).toBeNull(); expect(component.canUseLocalEditorDefault()).toBe(true); component.useLocalEditorDefault(); expect(component.localEditorUsesDefault()).toBe(true); expect(component.showLocalEditorDefault()).toBe(true); }); it('offers no reset while the value comes from a workflow input', () => { // The value is not the user's to set here, so there is nothing to hand back to a default. const component = fixture.componentInstance as any; openNumericEditor('llmDescriptor.parameters.temperature', undefined); component.localEditorDefaultsWhenEmpty = true; component.localEditorUseInput = true; expect(component.showLocalEditorDefault()).toBe(false); expect(component.canUseLocalEditorDefault()).toBe(false); }); it('treats a zero as a value there too, so the way back to the default stays offered', () => { const component = fixture.componentInstance as any; openNumericEditor('llmDescriptor.parameters.temperature', undefined); component.localEditorDefaultsWhenEmpty = true; component.localEditorValue = '0'; expect(component.localEditorUsesDefault()).toBe(false); expect(component.canUseLocalEditorDefault()).toBe(true); }); it('saves an ordinary value unchanged', () => { const config = openNumericEditor('llmDescriptor.parameters.temperature', undefined); config['llmDescriptor'] = { provider: 'p', model: 'm', parameters: {} }; (component as any).localEditorValue = '0.7'; (component as any).saveSimpleParamEditor(); expect(config['llmDescriptor'].parameters.temperature).toBe(0.7); }); }); describe('the array item modal, as it behaves today', () => { /** * Characterisation tests. This round trip - schema to dialog fields and back to an object - is * the machinery an optional-group modal wants to reuse, and nothing covered it: the dialog mock * resolved null, so no test ever reached the builder or the parser. These pin what it does now, * so extracting it cannot change it by accident. */ const itemSchema = { type: 'object', required: ['name', 'weight'], properties: { name: { type: 'string', 'x-ui-label': 'Skill name' }, weight: { type: 'integer' }, enabled: { type: 'boolean' } } }; function withArrayField() { const component = fixture.componentInstance as any; component.arrayFieldDefinitions = [{ path: 'skills', label: 'Skills', itemSchema, uniqueBy: null, ui: { structural: false, visibleWhen: [], enabledWhen: [] } }]; return component; } it('builds one dialog field per item property, honouring labels and types', async () => { const component = withArrayField(); const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType; open.mockResolvedValue(null); await component.addArrayItem('skills'); const dialog = open.mock.calls.at(-1)?.[0]; expect(dialog.fields.map((field: any) => [field.key, field.type])).toEqual([ ['name', 'text'], ['weight', 'number'], ['enabled', 'checkbox'] ]); expect(dialog.fields[0].label).toBe('Skill name'); }); it('gives an integer a whole-number step, so the spinner and the check agree', async () => { const component = withArrayField(); const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType; open.mockResolvedValue(null); await component.addArrayItem('skills'); const weight = open.mock.calls.at(-1)?.[0].fields.find((field: any) => field.key === 'weight'); expect(weight.step).toBe(1); expect(weight.stepIncrement).toBe(1); }); it('moves a decimal by a tenth per arrow press, without making a tenth the rule', async () => { // An arrow that jumps by 1 on a 0-to-1 field can only reach the two ends of the range. The // increment is a convenience, so 0.35 has to stay as valid as 0.3. const component = fixture.componentInstance as any; component.arrayFieldDefinitions = [{ path: 'skills', label: 'Skills', itemSchema: { type: 'object', properties: { ratio: { type: 'number', minimum: 0, maximum: 1 } } }, uniqueBy: null, ui: { structural: false, visibleWhen: [], enabledWhen: [] } }]; const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType; open.mockResolvedValue(null); await component.addArrayItem('skills'); const ratio = open.mock.calls.at(-1)?.[0].fields[0]; expect(ratio.stepIncrement).toBe(0.1); expect(ratio.step).toBeUndefined(); }); it('writes the parsed item into the array', async () => { const component = withArrayField(); const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType; open.mockResolvedValue({ name: 'summarise', weight: '3', enabled: true }); await component.addArrayItem('skills'); const config = component.ensureBlockConfiguration(); expect(config['skills']).toEqual([{ name: 'summarise', weight: 3, enabled: true }]); }); it('turns an emptied required number into 0, which is what it has always done', async () => { // Not an endorsement: it is the behaviour a refactor must not change silently. The optional // case is the one that has to differ, and it differs deliberately. const component = withArrayField(); const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType; open.mockResolvedValue({ name: 'x', weight: '', enabled: false }); await component.addArrayItem('skills'); expect(component.ensureBlockConfiguration()['skills'][0].weight).toBe(0); }); it('leaves out an emptied optional number instead of writing 0', async () => { // The behaviour is derived from the schema, not from who is calling: `weight` is required and // keeps its 0, while an optional sibling is omitted so the default still applies. That is // what lets one parser serve both an array item and an optional group. const component = fixture.componentInstance as any; component.arrayFieldDefinitions = [{ path: 'skills', label: 'Skills', itemSchema: { type: 'object', required: ['name', 'weight'], properties: { name: { type: 'string' }, weight: { type: 'integer' }, temperature: { type: 'number' } } }, uniqueBy: null, ui: { structural: false, visibleWhen: [], enabledWhen: [] } }]; const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType; open.mockResolvedValue({ name: 'x', weight: '', temperature: '' }); await component.addArrayItem('skills'); const written = component.ensureBlockConfiguration()['skills'][0]; expect(written.weight).toBe(0); expect('temperature' in written).toBe(false); }); it('keeps a typed zero in an optional number, because it is a value', async () => { const component = fixture.componentInstance as any; component.arrayFieldDefinitions = [{ path: 'skills', label: 'Skills', itemSchema: { type: 'object', properties: { temperature: { type: 'number' } } }, uniqueBy: null, ui: { structural: false, visibleWhen: [], enabledWhen: [] } }]; const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType; open.mockResolvedValue({ temperature: '0' }); await component.addArrayItem('skills'); expect(component.ensureBlockConfiguration()['skills'][0].temperature).toBe(0); }); it('edits an existing item in place rather than appending', async () => { const component = withArrayField(); const config = component.ensureBlockConfiguration(); config['skills'] = [{ name: 'first', weight: 1, enabled: false }]; const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType; open.mockResolvedValue({ name: 'renamed', weight: '2', enabled: true }); await component.editArrayItem('skills', 0); expect(config['skills']).toEqual([{ name: 'renamed', weight: 2, enabled: true }]); }); it('leaves the array untouched when the dialog is cancelled', async () => { const component = withArrayField(); const config = component.ensureBlockConfiguration(); config['skills'] = [{ name: 'first', weight: 1, enabled: false }]; (TestBed.inject(NodeSettingsDialogService).open as ReturnType).mockResolvedValue(null); await component.addArrayItem('skills'); expect(config['skills']).toEqual([{ name: 'first', weight: 1, enabled: false }]); }); }); describe('viewing what a chosen value stands for', () => { /** The shape `SkillBinding` produces: a value that is an id, plus where to read what it names. */ const skillItemSchema = { type: 'object', required: ['skillId'], properties: { skillId: { type: 'string', 'x-retriever-name': 'Skills', 'x-retriever-definition-url': '/retriever/Skills/definitions' } } }; it('takes the property and the endpoint from the schema, whatever the binding is called', () => { const component = fixture.componentInstance as any; const definitions = component.buildArrayFieldDefinitions({ type: 'object', properties: { anything: { type: 'array', items: skillItemSchema } } }); expect(definitions).toEqual([expect.objectContaining({ path: 'anything', definitionSource: { property: 'skillId', url: '/retriever/Skills/definitions' } })]); }); it('finds it through a $ref, which is the shape the server actually sends', () => { // Regression: a binding used by one block type stays in that schema's own `definitions`, and // the array references it by $ref rather than inlining it. const component = fixture.componentInstance as any; const definitions = component.buildArrayFieldDefinitions({ type: 'object', definitions: { SkillBinding: skillItemSchema }, properties: { skills: { type: 'array', items: { $ref: '#/definitions/SkillBinding' } } } }); expect(definitions[0].definitionSource).toEqual({ property: 'skillId', url: '/retriever/Skills/definitions' }); }); it('offers nothing for a binding whose value declares no definition to read', () => { // An MCP server's endpoint answers with a JSON schema, not readable text, so it declares none. const component = fixture.componentInstance as any; const definitions = component.buildArrayFieldDefinitions({ type: 'object', properties: { mcpServers: { type: 'array', items: { type: 'object', properties: { serverName: { type: 'string', 'x-retriever-name': 'MCPServers' } } } } } }); expect(definitions[0].definitionSource).toBeNull(); }); it('carries each row\'s own endpoint and value into its view', () => { const component = fixture.componentInstance as any; const definition = { path: 'skills', label: 'Skills', itemSchema: skillItemSchema, uniqueBy: null, definitionSource: { property: 'skillId', url: '/retriever/Skills/definitions' }, ui: { structural: false, visibleWhen: [], enabledWhen: [] } }; const items = component.toArrayFieldItems(definition, [{ skillId: 'mcp-context-economy' }]); expect(items).toEqual([expect.objectContaining({ index: 0, definition: { url: '/retriever/Skills/definitions', value: 'mcp-context-economy' } })]); }); it('leaves a row without the view when its value is missing', () => { const component = fixture.componentInstance as any; const definition = { path: 'skills', label: 'Skills', itemSchema: skillItemSchema, uniqueBy: null, definitionSource: { property: 'skillId', url: '/retriever/Skills/definitions' }, ui: { structural: false, visibleWhen: [], enabledWhen: [] } }; const items = component.toArrayFieldItems(definition, [{}]); expect(items[0].definition).toBeNull(); }); it('offers the button in every place the node renders an array row', () => { // Regression: the node draws array rows from two near-identical template blocks - one for // fields inside a group fieldset, one for ungrouped fields - and the button was first added // to only one of them, so it never appeared on an LLM node's ungrouped Skills list. const component = fixture.componentInstance as any; const row = { index: 0, summary: 'mcp-context-economy', definition: { url: '/retriever/Skills/definitions', value: 'mcp-context-economy' } }; const arrayField = { path: 'skills', label: 'Skills', items: [row] }; // The node recomputes its display model from the (absent) schema on every check, which would // wipe the rows this test is here to render. component.refreshParameterFields = () => {}; component.schemaReady = true; component.parameterDisplaySections = [ { key: 'grouped', group: { key: 'grouped', legend: 'Group', items: [{ path: 'skills', field: null, richContentField: null, arrayField, optionalGroupField: null }] }, item: null }, { key: 'ungrouped', group: null, item: { path: 'skills', field: null, richContentField: null, arrayField, optionalGroupField: null } } ]; component.cdr.detectChanges(); // A list starts closed, showing only its count; opened, its rows offer the button. for (const toggle of fixture.nativeElement.querySelectorAll('.llm-array-toggle')) { (toggle as HTMLButtonElement).click(); } fixture.detectChanges(); const buttons = fixture.nativeElement.querySelectorAll('[title="View content"]'); expect(buttons.length).toBe(2); }); it('reads the endpoint the row carries and opens the answer read-only', async () => { const component = fixture.componentInstance as any; const httpMock = TestBed.inject(HttpTestingController); const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType; open.mockResolvedValue(null); const pending = component.viewItemDefinition({ url: '/retriever/Skills/definitions', value: 'mcp-context-economy' }); httpMock.expectOne(`${environment.apiUrl}/retriever/Skills/definitions/mcp-context-economy`).flush({ id: 'mcp-context-economy', name: 'MCP Context Economy', content: 'Prefer write_file over apply_patch when creating a file.' }); await pending; const dialog = open.mock.calls.at(-1)?.[0]; expect(dialog.title).toBe('MCP Context Economy'); expect(dialog.previewOnly).toBe(true); expect(dialog.initial.value).toBe('Prefer write_file over apply_patch when creating a file.'); }); it('shows a message rather than throwing when the definition cannot be loaded', async () => { const component = fixture.componentInstance as any; const httpMock = TestBed.inject(HttpTestingController); const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType; open.mockResolvedValue(null); const pending = component.viewItemDefinition({ url: '/retriever/Skills/definitions', value: 'missing-skill' }); httpMock.expectOne(`${environment.apiUrl}/retriever/Skills/definitions/missing-skill`) .flush('not found', { status: 404, statusText: 'Not Found' }); await pending; const dialog = open.mock.calls.at(-1)?.[0]; expect(dialog.title).toBe('missing-skill'); expect(dialog.initial.value).toContain('Could not load'); }); }); describe('a retriever whose values are an incomplete list', () => { /** What the editor holds for a retriever-backed scalar, as openParameterEditor builds it. */ function modelFieldDefinition() { return { path: 'llmDescriptor.model', label: 'Model', retrieverBlockType: 'LLM', retrieverKey: 'models', retrieverUrl: '/retriever/LLM/models', retrieverStructuredData: false, retrieverDependsOn: [] }; } function stubRetriever(values: string[], open: boolean) { const retriever = TestBed.inject(FieldRetriever) as any; retriever.retrieveValues = vi.fn(() => of(values)); retriever.isFieldOpen = vi.fn(() => of(open)); return retriever; } it('takes a typed value when the provider cannot list its models', async () => { // Gemini and the other hosted providers cannot be enumerated without a credential, so a // select would be a dead end: an empty list with nowhere to type the model that does exist. stubRetriever([], true); await (component as any).loadLocalEditorOptions(modelFieldDefinition()); expect(component.localEditorFreeText).toBe(true); expect(component.localEditorOptions).toEqual([]); }); it('keeps the select when the provider lists its models', async () => { stubRetriever(['llama3.2:3b'], false); await (component as any).loadLocalEditorOptions(modelFieldDefinition()); expect(component.localEditorFreeText).toBe(false); expect(component.localEditorOptions).toEqual([{ label: 'llama3.2:3b', value: 'llama3.2:3b' }]); }); it('stays a select when the values call fails, so a closed list is never opened by an error', () => { // An unreachable Ollama returns nothing too. Turning that into a free text box would hide a // broken provider and invite a model name it does not have. const retriever = TestBed.inject(FieldRetriever) as any; retriever.retrieveValues = vi.fn(() => throwError(() => new Error('unreachable'))); retriever.isFieldOpen = vi.fn(() => of(false)); return (component as any).loadLocalEditorOptions(modelFieldDefinition()).then(() => { expect(component.localEditorFreeText).toBe(false); expect(component.localEditorOptions).toEqual([]); expect(component.localEditorLoading).toBe(false); }); }); it('still offers a text field when only the values call fails on an open retriever', async () => { // The two answers are independent on purpose: the one that says "type it" must survive the // one that had nothing to list. const retriever = TestBed.inject(FieldRetriever) as any; retriever.retrieveValues = vi.fn(() => throwError(() => new Error('no catalogue'))); retriever.isFieldOpen = vi.fn(() => of(true)); await (component as any).loadLocalEditorOptions(modelFieldDefinition()); expect(component.localEditorFreeText).toBe(true); }); it('falls back to a select when the open check itself fails', async () => { stubRetriever(['llama3.2:3b'], false); const retriever = TestBed.inject(FieldRetriever) as any; retriever.isFieldOpen = vi.fn(() => throwError(() => new Error('down'))); await (component as any).loadLocalEditorOptions(modelFieldDefinition()); expect(component.localEditorFreeText).toBe(false); }); }); describe('a nested field bound to a node input', () => { /** * The model inside llmDescriptor can be left to an input, and the whole design rests on the * editor reading that at a dotted path: nothing here needed changing for it, and this pins * that it really does. */ function nestedModelDefinition() { return { path: 'llmDescriptor.model', label: 'Model', type: 'string' as const, ui: { bindableAsInput: true, inputName: 'model', inputType: 'TEXT', inputMultiple: false, visibleWhen: [], enabledWhen: [] } }; } function withModelPort(component: any, ports: Array>) { component.data.data = { ...component.data.data, inputs: ports }; } it('reads a blank nested value plus a matching port as "provided by input"', () => { const component = fixture.componentInstance as any; component.ensureBlockConfiguration()['llmDescriptor'] = { provider: 'Gemini', model: '' }; withModelPort(component, [{ name: 'model', type: 'TEXT', multiple: false }]); expect(component.isBindableFieldUsingInput(nestedModelDefinition())).toBe(true); expect(component.fieldDisplayValue(nestedModelDefinition(), '')).toContain('Provided by input model'); }); it('is not bound while the nested value is set', () => { const component = fixture.componentInstance as any; component.ensureBlockConfiguration()['llmDescriptor'] = { provider: 'Gemini', model: 'gemini-2.5-flash' }; withModelPort(component, [{ name: 'model', type: 'TEXT', multiple: false }]); expect(component.isBindableFieldUsingInput(nestedModelDefinition())).toBe(false); }); it('is not bound while the server has not created the port yet', () => { // Clearing the field asks the server to recreate the block; until it has, there is no port // to be provided by, and claiming otherwise would hide a field with no value. const component = fixture.componentInstance as any; component.ensureBlockConfiguration()['llmDescriptor'] = { provider: 'Gemini', model: '' }; withModelPort(component, [{ name: 'prompt', type: 'TEXT', multiple: false }]); expect(component.isBindableFieldUsingInput(nestedModelDefinition())).toBe(false); }); }); describe('taking a field from a global input', () => { /** * The third Source choice. It exists because on a provider with a closed model list the field * is a