1128 lines
46 KiB
TypeScript
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');
|
|
});
|
|
});
|