import { ComponentFixture, TestBed } from '@angular/core/testing'; 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 { 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: [ { provide: NodeSettingsDialogService, useValue: { open: vi.fn().mockResolvedValue(null) } }, { 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('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