humainflow-web/src/app/shared/nodes/generic-node/generic-node.spec.ts

1128 lines
46 KiB
TypeScript

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<GenericNodeComponent>;
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<typeof vi.fn>;
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<typeof vi.fn>;
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<typeof vi.fn>;
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<typeof vi.fn>;
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<typeof vi.fn>;
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<typeof vi.fn>;
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<typeof vi.fn>;
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<typeof vi.fn>;
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<typeof vi.fn>).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<Record<string, unknown>>) {
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 <select>: there is no text box to type ${{global.x}} into, so the placeholder has to be
* built by picking from a list.
*/
function bindableFieldDefinition() {
return {
path: 'llmDescriptor.model',
label: 'Model',
type: 'string' as const,
enumOptions: [],
nodeOptionsSource: null,
retrieverKey: null,
retrieverBlockType: null,
retrieverUrl: null,
retrieverStructuredData: false,
retrieverDependsOn: [],
ui: {
bindableAsInput: true,
inputName: 'model',
acceptVariableAsPlaceholder: true,
visibleWhen: [],
enabledWhen: []
}
};
}
function withGlobals(globals: Array<Record<string, unknown>>) {
const editorState = TestBed.inject(EditorStateHolder) as any;
// A whole FlowData, not just the globals: saving clones the active flow to mark it dirty.
editorState.activeFlowData.mockReturnValue({
blocks: [],
containers: [],
connections: [],
globalInputs: globals
});
}
function openModelEditor(component: any, currentValue?: string) {
const config = component.ensureBlockConfiguration();
config['llmDescriptor'] = { provider: 'InternalOllama', ...(currentValue === undefined ? {} : { model: currentValue }) };
component.editableFieldDefinitions = [bindableFieldDefinition()];
// What the backend emits for a @ConfigurableAsInput field: structural, because adding or
// removing its port changes the node's shape and only the server can do that.
component.blockSchema = {
type: 'object',
properties: {
llmDescriptor: {
type: 'object',
properties: {
model: {
type: 'string',
'x-ui-structural': true,
'x-ui-bindable-as-input': true,
'x-ui-input-name': 'model',
'x-ui-accept-variable-as-placeholder': true
}
}
}
}
};
return component.openParameterEditor('llmDescriptor.model');
}
it('offers the choice wherever the field can be configured as an input', () => {
// Not gated on anything narrower: a port replaces the whole value, so such a field is by
// construction one value rather than prose with references inside it.
const component = fixture.componentInstance as any;
component.localEditorBindableAsInput = true;
component.localEditorPath = 'llmDescriptor.model';
expect(component.canTakeGlobalInput()).toBe(true);
component.localEditorPath = 'name';
expect(component.canTakeGlobalInput()).toBe(false);
});
it('writes the reference for you when a global is picked', async () => {
const component = fixture.componentInstance as any;
withGlobals([{ name: 'modelName', type: 'TEXT', multiple: false }]);
await openModelEditor(component);
component.onLocalEditorSourceModeChange('global');
expect(component.localEditorSourceMode()).toBe('global');
expect(component.localEditorValue).toBe('');
component.onLocalEditorGlobalInputChange('modelName');
expect(component.localEditorValue).toBe('${{global.modelName}}');
expect(component.canSaveLocalEditor()).toBe(true);
});
it('saves the reference and asks the server to rebuild the node', async () => {
// The field is structural, and a written value means the server must not expose a port.
const component = fixture.componentInstance as any;
withGlobals([{ name: 'modelName', type: 'TEXT', multiple: false }]);
await openModelEditor(component);
component.onLocalEditorSourceModeChange('global');
component.onLocalEditorGlobalInputChange('modelName');
const recreate = vi.spyOn(component, 'markBlockForServerRecreate');
component.saveSimpleParamEditor();
expect(component.ensureBlockConfiguration()['llmDescriptor'].model).toBe('${{global.modelName}}');
expect(recreate).toHaveBeenCalled();
});
it('reopens on the mode it was left in, with the global preselected', async () => {
const component = fixture.componentInstance as any;
withGlobals([{ name: 'modelName', type: 'TEXT', multiple: false }]);
await openModelEditor(component, '${{global.modelName}}');
expect(component.localEditorSourceMode()).toBe('global');
expect(component.localEditorGlobalName).toBe('modelName');
});
it('opens as a static value when the reference is embedded in other text', async () => {
// That is a template someone wrote by hand; the picker can only express a whole value.
const component = fixture.componentInstance as any;
withGlobals([{ name: 'modelName', type: 'TEXT', multiple: false }]);
await openModelEditor(component, 'prefix-${{global.modelName}}');
expect(component.localEditorSourceMode()).toBe('static');
expect(component.localEditorGlobalName).toBe('');
});
it('says why the list is empty instead of hiding the choice', async () => {
const component = fixture.componentInstance as any;
withGlobals([]);
await openModelEditor(component);
component.onLocalEditorSourceModeChange('global');
expect(component.canTakeGlobalInput()).toBe(true);
expect(component.localEditorGlobalInputs).toEqual([]);
// Nothing picked, so nothing to save either.
expect(component.canSaveLocalEditor()).toBe(false);
});
it('does not leave a reference behind when the mode is switched away', async () => {
const component = fixture.componentInstance as any;
withGlobals([{ name: 'modelName', type: 'TEXT', multiple: false }]);
await openModelEditor(component, '${{global.modelName}}');
component.onLocalEditorSourceModeChange('static');
// A ${{...}} lingering here would be saved as a literal model name.
expect(component.localEditorValue).toBe('');
expect(component.localEditorGlobalName).toBe('');
});
it('renders the field description, which the modal never used to show', async () => {
// The other half of why none of this was discoverable: the modal held no tip at all, so a
// @UiDescription explaining that the field takes a placeholder was invisible.
const component = fixture.componentInstance as any;
const definition = bindableFieldDefinition();
definition.ui = { ...definition.ui, tip: 'Leave empty to take it from a node input.' } as any;
component.editableFieldDefinitions = [definition];
component.ensureBlockConfiguration()['llmDescriptor'] = { provider: 'InternalOllama' };
await component.openParameterEditor('llmDescriptor.model');
// The component now carries it, which it never did - the template renders it from here.
expect(component.localEditorTip).toBe('Leave empty to take it from a node input.');
});
it('knows the choice is unusable when the flow declares no global', async () => {
const component = fixture.componentInstance as any;
withGlobals([]);
await openModelEditor(component);
// Selecting it used to lead to a mode nothing could complete: an empty picker, a hidden
// value, and a Save that stays disabled. The option is left in the list and disabled off
// this, so the capability stays discoverable.
expect(component.canTakeGlobalInput()).toBe(true);
expect(component.localEditorGlobalInputs).toEqual([]);
});
it('knows it is usable as soon as the flow has one', async () => {
const component = fixture.componentInstance as any;
withGlobals([{ name: 'modelName', type: 'TEXT', multiple: false }]);
await openModelEditor(component);
expect(component.canTakeGlobalInput()).toBe(true);
expect(component.localEditorGlobalInputs.map((input: any) => input.name)).toEqual(['modelName']);
});
it('cannot be saved from global mode until a global is picked', async () => {
const component = fixture.componentInstance as any;
withGlobals([{ name: 'modelName', type: 'TEXT', multiple: false }]);
await openModelEditor(component);
component.onLocalEditorSourceModeChange('global');
// Entering the mode starts from nothing chosen, and the value control is hidden there: the
// reference the picker writes *is* the value, so there is nothing else to fill in.
expect(component.localEditorFromGlobal).toBe(true);
expect(component.localEditorValue).toBe('');
expect(component.canSaveLocalEditor()).toBe(false);
component.onLocalEditorGlobalInputChange('modelName');
expect(component.localEditorValue).toBe('${{global.modelName}}');
expect(component.canSaveLocalEditor()).toBe(true);
});
it('labels a global with its type and multiplicity', () => {
const component = fixture.componentInstance as any;
expect(component.globalInputLabel({ name: 'modelName', type: 'TEXT', multiple: false }))
.toBe('modelName (TEXT)');
expect(component.globalInputLabel({ name: 'cvs', type: 'TEXT', multiple: true }))
.toBe('cvs (TEXT[])');
});
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('writes parameter changes into the active subflow data instead of the main flow', () => {
const editorState = TestBed.inject(EditorStateHolder) as any;
const activeSubflowData = {
blocks: [{
id: 'node-1',
name: 'Node 1',
typeName: 'LLMBlock',
inputs: [],
outputs: [],
specificConfiguration: { name: 'Node 1' }
}],
containers: [],
connections: [],
dependencies: []
};
editorState.activeFlowData.mockReturnValue(activeSubflowData);
component.data.data = {
...component.data.data,
typeName: 'LLMBlock',
specificConfiguration: { name: 'Edited node', prompt: 'Updated prompt' }
};
(component as any).markFlowDirty();
expect(editorState.updateData).toHaveBeenCalledWith(expect.objectContaining({
blocks: [expect.objectContaining({
id: 'node-1',
name: 'Edited node',
specificConfiguration: expect.objectContaining({ prompt: 'Updated prompt' })
})]
}));
});
it('renders every input and output after the Rete node payload is updated', () => {
fixture.componentRef.setInput('data', {
...component.data,
inputs: {
existing: { socket: { name: 'ANY' } },
new: { socket: { name: 'ANY' } }
},
outputs: {
noAssessment: { socket: { name: 'ANY' } },
excluded: { socket: { name: 'ANY' } },
continued: { socket: { name: 'ANY' } }
},
data: {
...component.data.data,
inputs: [
{ name: 'existing', type: 'ANY', multiple: false },
{ name: 'new', type: 'ANY', multiple: false }
],
outputs: [
{ name: 'noAssessment', type: 'ANY', multiple: false },
{ name: 'excluded', type: 'ANY', multiple: false },
{ name: 'continued', type: 'ANY', multiple: false }
]
}
});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
const inputLabels = Array.from(host.querySelectorAll('.llm-row-input .llm-pill-name'))
.map((element) => element.textContent?.trim());
const outputLabels = Array.from(host.querySelectorAll('.llm-row-output .llm-pill-name'))
.map((element) => element.textContent?.trim());
expect(inputLabels).toEqual(['existing', 'new']);
expect(outputLabels).toEqual(['noAssessment', 'excluded', 'continued']);
});
it('keeps long port labels available as tooltips', () => {
const longInput = 'input.with.a.very.long.descriptive.label.that.must.not.expand.the.node';
const longOutput = 'output.with.a.very.long.descriptive.label.that.must.not.expand.the.node';
fixture.componentRef.setInput('data', {
...component.data,
inputs: { [longInput]: { socket: { name: 'TEXT' } } },
outputs: { [longOutput]: { socket: { name: 'TEXT' } } },
data: {
...component.data.data,
inputs: [{ name: longInput, type: 'TEXT', multiple: false }],
outputs: [{ name: longOutput, type: 'TEXT', multiple: false }]
}
});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('.llm-row-input .llm-pill')?.getAttribute('title')).toBe(longInput);
expect(host.querySelector('.llm-row-output .llm-pill')?.getAttribute('title')).toBe(longOutput);
});
it('keeps a flexible port kind-selectable after narrowing it once', () => {
fixture.componentRef.setInput('data', {
...component.data,
outputs: {
output: { socket: { name: 'ANY' } }
},
data: {
...component.data.data,
outputs: [{ name: 'output', type: 'ANY', multiple: false }]
}
});
fixture.detectChanges();
expect(component.canTogglePortMultiplicity('output', 'output')).toBe(true);
expect(component.portSelectableKindOptions('output', 'output').map((option) => option.label)).toEqual(
expect.arrayContaining(['TEXT', 'FILE', 'JSON'])
);
component.onPortKindChange('output', 'output', 'TEXT::single');
expect(component.portCurrentKindLabel('output', 'output')).toBe('TEXT');
expect(component.canTogglePortMultiplicity('output', 'output')).toBe(true);
expect(component.portSelectableKindOptions('output', 'output').map((option) => option.label)).toEqual(
expect.arrayContaining(['TEXT', 'FILE', 'JSON'])
);
});
it('restores and syncs persisted expanded-mode state', () => {
const nodeData = component.data.data as Record<string, unknown>;
nodeData['__focusOpen'] = true;
(component as any).restorePersistedFocusState();
expect(component.focusOpen).toBe(true);
expect(nodeData['__focusOpen']).toBe(true);
component.toggleFocus();
expect(component.focusOpen).toBe(false);
expect(nodeData['__focusOpen']).toBe(false);
});
it('has no bias annotation badge when the block has no annotations', () => {
expect(component.biasAnnotationBadge).toBeNull();
});
it('allows bias annotations by default when no descriptor capabilities are known', () => {
expect(component.biasAnnotationsAllowed).toBe(true);
});
it('hides the bias badge and the annotations panel when biasAnnotationsAllowed is false', async () => {
const blocks = TestBed.inject(BlocksService) as any;
blocks.peekBlockType.mockReturnValue({
type: 'EndBlock',
capabilities: { ...DEFAULT_NODE_CAPABILITIES, biasAnnotationsAllowed: false }
});
component.data.data = {
...component.data.data,
typeName: 'EndBlock',
biasAnnotations: [{ id: 'a1', severity: 'HIGH' }]
};
await (component as any).loadSchemaContext();
fixture.detectChanges();
expect(component.biasAnnotationsAllowed).toBe(false);
expect(component.biasAnnotationBadge).toBeNull();
expect((fixture.nativeElement as HTMLElement).querySelector('app-bias-annotations')).toBeNull();
});
it('has no lane badge when the block has no laneId', () => {
expect(component.laneBadge).toBeNull();
});
it('does not render a lane badge while swimlanes are disabled', () => {
const editorState = TestBed.inject(EditorStateHolder) as any;
editorState.currentFlow.mockReturnValue({
data: { lanes: [{ id: 'lane-hr', name: 'HR', order: 0, color: '#F59F00' }] }
});
component.data.data = { ...component.data.data, laneId: 'lane-hr' };
expect(component.laneBadge).toBeNull();
});
it('computes the bias annotation badge from the node annotations and the severity catalog', () => {
const blocks = TestBed.inject(BlocksService) as any;
blocks.biasAnnotationsDescriptor.mockReturnValue({
options: {
severity: [
{ value: 'LOW', label: 'Low' },
{ value: 'HIGH', label: 'High' }
]
}
});
component.data.data = {
...component.data.data,
biasAnnotations: [
{ id: 'a1', severity: 'LOW' },
{ id: 'a2', severity: 'HIGH', biasProbe: { activationMode: 'PROMPT_DIRECTIVE', instruction: 'do it' } }
]
};
expect(component.biasAnnotationBadge).toEqual({
count: 2,
hasExecutableProbe: true,
maxSeverityLabel: 'High'
});
});
it('preserves id, position and bias annotations during block regeneration', async () => {
const blocks = TestBed.inject(BlocksService) as any;
const replacement = vi.fn().mockResolvedValue(undefined);
component.data.data = {
...component.data.data,
id: 'old-id', typeName: 'LLMBlock', position: { x: 10, y: 20 },
biasAnnotations: [{ id: 'bias-1', category: 'DYNAMIC', issue: 'keep me' }],
__needsServerCreate: true, replaceWithCreatedNode: replacement
};
blocks.updateBlock.mockReturnValue(of({
id: 'generated-id', name: 'Generated', typeName: 'LLMBlock', inputs: [], outputs: [],
specificConfiguration: {}, position: { x: 99, y: 99 }, biasAnnotations: []
}));
(component as any).maybeCreateBlockOnServer();
await fixture.whenStable();
expect(replacement).toHaveBeenCalledWith(expect.objectContaining({
id: 'old-id', position: { x: 10, y: 20 },
biasAnnotations: [{ id: 'bias-1', category: 'DYNAMIC', issue: 'keep me' }]
}));
});
describe('an optional group', () => {
/**
* Five empty chips for parameters nobody sets on most nodes became one control. The behaviour
* that matters is what the modal writes back: the group must be able to return to "nothing set"
* so the provider default applies again, and a temperature of 0 must survive as a real value.
*/
const groupSchema = {
type: 'object',
properties: {
temperature: { type: 'number', 'x-ui-label': 'Temperature', minimum: 0, maximum: 1 },
topK: { type: 'integer', 'x-ui-label': 'Top K', minimum: 1 }
}
};
function withOptionalGroup(current?: Record<string, unknown>) {
const component = fixture.componentInstance as any;
component.optionalGroupFieldDefinitions = [{
path: 'llmDescriptor.parameters',
label: 'Model parameters',
objectSchema: groupSchema,
ui: { structural: false, visibleWhen: [], enabledWhen: [] }
}];
const config = component.ensureBlockConfiguration();
config['llmDescriptor'] = { provider: 'p', model: 'm', ...(current ? { parameters: current } : {}) };
return component;
}
it('opens one dialog for the whole group, prefilled with what is set', async () => {
const component = withOptionalGroup({ temperature: 0.7 });
const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType<typeof vi.fn>;
open.mockResolvedValue(null);
await component.openOptionalGroupEditor('llmDescriptor.parameters');
const dialog = open.mock.calls.at(-1)?.[0];
expect(dialog.title).toBe('Model parameters');
expect(dialog.fields.map((field: any) => field.key)).toEqual(['temperature', 'topK']);
expect(dialog.initial).toEqual({ temperature: '0.7', topK: '' });
});
it('carries the schema bounds onto the dialog fields, so the modal can refuse a bad value', async () => {
// The bounds were already in the schema and already enforced by the server; nothing was
// passing them to the control, so a temperature of 5 was typeable and only failed on save.
const component = withOptionalGroup();
const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType<typeof vi.fn>;
open.mockResolvedValue(null);
await component.openOptionalGroupEditor('llmDescriptor.parameters');
const fields = open.mock.calls.at(-1)?.[0].fields;
expect(fields).toEqual([
expect.objectContaining({ key: 'temperature', type: 'number', min: 0, max: 1, stepIncrement: 0.1 }),
expect.objectContaining({ key: 'topK', type: 'number', min: 1, step: 1, stepIncrement: 1 })
]);
});
it('marks every optional property as defaulting when empty, whatever its type', async () => {
// Not a parameter-specific rule: any field the schema does not require means "leave it to
// the default" when empty, and the control has to be able to say so.
const component = fixture.componentInstance as any;
component.optionalGroupFieldDefinitions = [{
path: 'llmDescriptor.parameters',
label: 'Model parameters',
objectSchema: {
type: 'object',
required: ['mode'],
properties: {
mode: { type: 'string' },
note: { type: 'string' },
temperature: { type: 'number' },
verbose: { type: 'boolean' },
outcome: { type: 'string', default: 'DONE' }
}
},
ui: { structural: false, visibleWhen: [], enabledWhen: [] }
}];
component.ensureBlockConfiguration()['llmDescriptor'] = { provider: 'p', model: 'm' };
const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType<typeof vi.fn>;
open.mockResolvedValue(null);
await component.openOptionalGroupEditor('llmDescriptor.parameters');
const byKey = new Map<string, any>(
open.mock.calls.at(-1)?.[0].fields.map((field: any) => [field.key, field])
);
expect(byKey.get('mode').defaultsWhenEmpty).toBe(false);
expect(byKey.get('note').defaultsWhenEmpty).toBe(true);
expect(byKey.get('temperature').defaultsWhenEmpty).toBe(true);
// A checkbox has no empty state: false is a value.
expect(byKey.get('verbose').defaultsWhenEmpty).toBe(false);
expect(byKey.get('outcome')).toMatchObject({ defaultsWhenEmpty: true, defaultValue: 'DONE' });
});
it('writes only what was filled in', async () => {
const component = withOptionalGroup();
const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType<typeof vi.fn>;
open.mockResolvedValue({ temperature: '0.7', topK: '' });
await component.openOptionalGroupEditor('llmDescriptor.parameters');
expect(component.ensureBlockConfiguration()['llmDescriptor'].parameters).toEqual({ temperature: 0.7 });
});
it('keeps a temperature of 0, which is the repeatable setting and not an absence', async () => {
const component = withOptionalGroup();
const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType<typeof vi.fn>;
open.mockResolvedValue({ temperature: '0', topK: '' });
await component.openOptionalGroupEditor('llmDescriptor.parameters');
expect(component.ensureBlockConfiguration()['llmDescriptor'].parameters).toEqual({ temperature: 0 });
});
it('removes the group entirely when everything is cleared', async () => {
// Otherwise the saved flow keeps an empty object, which reads as "parameters were chosen".
const component = withOptionalGroup({ temperature: 0.7 });
const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType<typeof vi.fn>;
open.mockResolvedValue({ temperature: '', topK: '' });
await component.openOptionalGroupEditor('llmDescriptor.parameters');
expect('parameters' in component.ensureBlockConfiguration()['llmDescriptor']).toBe(false);
});
it('leaves the group untouched when the dialog is cancelled', async () => {
const component = withOptionalGroup({ temperature: 0.7 });
const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType<typeof vi.fn>;
open.mockResolvedValue(null);
await component.openOptionalGroupEditor('llmDescriptor.parameters');
expect(component.ensureBlockConfiguration()['llmDescriptor'].parameters).toEqual({ temperature: 0.7 });
});
it('counts a temperature of 0 as set, so the button never says the group is empty', () => {
const component = withOptionalGroup({ temperature: 0, topK: null });
const views = component.optionalGroupViews();
expect(views).toHaveLength(1);
expect(views[0]).toMatchObject({ path: 'llmDescriptor.parameters', label: 'Model parameters', setCount: 1 });
});
it('reports nothing set when the group is absent', () => {
const component = withOptionalGroup();
expect(component.optionalGroupViews()[0].setCount).toBe(0);
});
});
it('summarises an upload row by the branch it is on, not by an untouched flag', () => {
// The row taking its file from a global read "GLOBAL · false": the false was a "several files"
// box nobody had touched, and the global it names never got a look in.
const itemSchema = {
type: 'object',
'x-ui-property-order': ['source', 'name', 'kind', 'multiple', 'globalInput'],
properties: {
source: { type: 'string', enum: ['INPUT', 'GLOBAL'] },
name: { type: 'string', 'x-ui-visible-when': { field: 'source', in: ['INPUT', ''] } },
kind: { type: 'string', 'x-ui-visible-when': { field: 'source', in: ['INPUT', ''] } },
multiple: { type: 'boolean', 'x-ui-visible-when': { field: 'source', in: ['INPUT', ''] } },
globalInput: { type: 'string', 'x-ui-visible-when': { field: 'source', equals: 'GLOBAL' } }
}
};
const definition = { path: 'uploadInputs', label: 'Uploads', itemSchema, uniqueBy: null } as any;
const summary = (component as any).toArrayItemSummary.bind(component);
expect(summary(definition, { source: 'GLOBAL', multiple: false, globalInput: 'document' }, 0))
.toBe('GLOBAL · document');
// The other branch reads by its own fields, and an untouched flag still says nothing.
expect(summary(definition, { source: 'INPUT', name: 'planDoc', multiple: false }, 0))
.toBe('INPUT · planDoc');
// A flag that was touched is a fact worth showing.
expect(summary(definition, { source: 'INPUT', name: 'planDoc', multiple: true }, 0))
.toBe('INPUT · planDoc');
});
});