humainflow-web/src/app/shared/node-settings-dialog/node-settings-dialog.spec.ts

342 lines
15 KiB
TypeScript

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { NodeSettingField, NodeSettingsDialogService, validateFieldValue } from '@services/dialogs/node-settings-dialog';
import { NodeSettingsDialogHostComponent } from './node-settings-dialog';
const PARAMETER_GROUP = 'Model parameters';
function fields(): NodeSettingField[] {
return [
{ key: 'provider', label: 'Provider', type: 'select', options: [{ label: 'p', value: 'p' }], required: true },
{ key: 'model', label: 'Model', type: 'select', options: [{ label: 'm', value: 'm' }], required: true },
{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 1, group: PARAMETER_GROUP },
{ key: 'seed', label: 'Seed', type: 'number', group: PARAMETER_GROUP }
];
}
describe('NodeSettingsDialogHostComponent collapsible groups', () => {
let fixture: ComponentFixture<NodeSettingsDialogHostComponent>;
let component: NodeSettingsDialogHostComponent;
let dialog: NodeSettingsDialogService;
async function open(initial: Record<string, string | number | boolean> = {}) {
dialog.open({ title: 'Simulation Settings', fields: fields(), initial });
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [NodeSettingsDialogHostComponent] }).compileComponents();
fixture = TestBed.createComponent(NodeSettingsDialogHostComponent);
component = fixture.componentInstance;
dialog = TestBed.inject(NodeSettingsDialogService);
});
afterEach(() => TestBed.resetTestingModule());
it('shows the ungrouped fields and keeps the group closed', async () => {
// Provider and model are what anyone opening this came for; the parameters are for the runs
// where you already know you want them.
await open();
expect(component.ungroupedFields().map((field) => field.key)).toEqual(['provider', 'model']);
expect(component.fieldGroups().map((group) => group.name)).toEqual([PARAMETER_GROUP]);
expect(component.isGroupOpen(PARAMETER_GROUP)).toBe(false);
expect(fixture.nativeElement.textContent).toContain(PARAMETER_GROUP);
// Closed means the controls are not rendered, not merely hidden.
expect(fixture.nativeElement.querySelectorAll('input[type="number"]').length).toBe(0);
});
it('opens and closes the group on demand', async () => {
await open();
component.toggleGroup(PARAMETER_GROUP);
fixture.detectChanges();
expect(component.isGroupOpen(PARAMETER_GROUP)).toBe(true);
expect(fixture.nativeElement.querySelectorAll('input[type="number"]').length).toBe(2);
component.toggleGroup(PARAMETER_GROUP);
fixture.detectChanges();
expect(component.isGroupOpen(PARAMETER_GROUP)).toBe(false);
});
it('says how many are set while the group is closed', async () => {
// A closed section that is doing something must not look like one that is not.
await open({ temperature: 0 });
expect(component.groupSetCount(component.fieldGroups()[0].fields)).toBe(1);
expect(fixture.nativeElement.textContent).toContain('1 set');
});
it('counts a set value of zero, which is a real setting', async () => {
await open({ temperature: 0, seed: 42 });
expect(component.groupSetCount(component.fieldGroups()[0].fields)).toBe(2);
});
it('counts nothing when the group is untouched', async () => {
await open();
expect(component.groupSetCount(component.fieldGroups()[0].fields)).toBe(0);
expect(fixture.nativeElement.textContent).not.toContain('set');
});
it('refuses to save an out-of-range value, and says why', async () => {
// The bound is declared on the field; the server would reject it anyway. Catching it here is
// the difference between a message under the input and a failed save after the dialog is gone.
await open({ provider: 'p', model: 'm', temperature: 5 });
component.toggleGroup(PARAMETER_GROUP);
fixture.detectChanges();
expect(component.hasInvalidField()).toBe(true);
expect(fixture.nativeElement.textContent).toContain('Must be between 0 and 1');
const closed = vi.fn();
dialog.state()!.resolve = closed;
component.save();
expect(closed).not.toHaveBeenCalled();
component.setFieldValue('temperature', 0.7);
fixture.detectChanges();
expect(component.hasInvalidField()).toBe(false);
});
it('marks a closed section that holds the bad value, so Save is not refusing invisibly', async () => {
await open({ provider: 'p', model: 'm', temperature: 5 });
expect(component.isGroupOpen(PARAMETER_GROUP)).toBe(false);
expect(component.groupErrorCount(component.fieldGroups()[0].fields)).toBe(1);
expect(fixture.nativeElement.textContent).toContain('1 to fix');
});
it('never reports a readonly field, which the user could not fix anyway', async () => {
dialog.open({
title: 'Fixed',
fields: [{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 1, readonly: true }],
initial: { temperature: 5 }
});
fixture.detectChanges();
await fixture.whenStable();
expect(component.hasInvalidField()).toBe(false);
});
it('says an empty optional field is using the default, rather than looking unfinished', async () => {
dialog.open({
title: 'Parameters',
fields: [
{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 1, defaultsWhenEmpty: true },
{ key: 'mode', label: 'Mode', type: 'text', defaultsWhenEmpty: true, defaultValue: 'PATH_END' },
{ key: 'model', label: 'Model', type: 'select', options: [{ label: 'm', value: 'm' }] }
],
initial: {}
});
fixture.detectChanges();
await fixture.whenStable();
expect(component.defaultHint(component.fields[0])).toBe('Using the default');
expect(component.defaultHint(component.fields[1])).toBe('Using the default: PATH_END');
// A required field has no such state, so it says nothing.
expect(component.defaultHint(component.fields[2])).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Using the default: PATH_END');
});
it('keeps the reset beside the field always, greyed out when there is nothing to reset', async () => {
// Clearing the box by hand does the same thing, but nothing tells you that it does - and an
// empty box you cleared looks exactly like one you never decided about. It stays in place
// rather than appearing, so the field never changes height as you type.
dialog.open({
title: 'Parameters',
fields: [{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 1, defaultsWhenEmpty: true }],
initial: { temperature: 0.7 }
});
fixture.detectChanges();
await fixture.whenStable();
const button = () => [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')]
.find((element) => element.textContent?.trim() === 'Use default') as HTMLButtonElement | undefined;
expect(component.showUseDefault(component.fields[0])).toBe(true);
expect(component.canUseDefault(component.fields[0])).toBe(true);
expect(button()?.disabled).toBe(false);
expect(component.defaultHint(component.fields[0])).toBeNull();
component.useDefault(component.fields[0]);
fixture.detectChanges();
expect(component.usesDefault(component.fields[0])).toBe(true);
expect(component.canUseDefault(component.fields[0])).toBe(false);
// Still there, just inert.
expect(button()).toBeTruthy();
expect(button()?.disabled).toBe(true);
expect(component.defaultHint(component.fields[0])).toBe('Using the default');
});
it('shows no reset at all on a field that has no default to go back to', async () => {
dialog.open({
title: 'Parameters',
fields: [
{ key: 'model', label: 'Model', type: 'select', options: [{ label: 'm', value: 'm' }] },
{ key: 'fixed', label: 'Fixed', type: 'number', defaultsWhenEmpty: true, readonly: true }
],
initial: {}
});
fixture.detectChanges();
await fixture.whenStable();
// Required, and readonly: neither is the user's to reset.
expect(component.showUseDefault(component.fields[0])).toBe(false);
expect(component.showUseDefault(component.fields[1])).toBe(false);
expect([...(fixture.nativeElement as HTMLElement).querySelectorAll('button')]
.filter((element) => element.textContent?.trim() === 'Use default')).toHaveLength(0);
});
it('keeps a zero as a value, not as a fallback to the default', async () => {
// The reason the whole thing is built on empty rather than on a flag: 0 is a real temperature.
dialog.open({
title: 'Parameters',
fields: [{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 1, defaultsWhenEmpty: true }],
initial: { temperature: 0 }
});
fixture.detectChanges();
await fixture.whenStable();
expect(component.usesDefault(component.fields[0])).toBe(false);
expect(component.canUseDefault(component.fields[0])).toBe(true);
});
it('never offers the default on a readonly field', async () => {
dialog.open({
title: 'Parameters',
fields: [{ key: 'temperature', label: 'Temperature', type: 'number', defaultsWhenEmpty: true, readonly: true }],
initial: { temperature: 0.7 }
});
fixture.detectChanges();
await fixture.whenStable();
expect(component.canUseDefault(component.fields[0])).toBe(false);
});
it('shows a description once, and not in error red', async () => {
// It used to render twice: as a mat-hint inside the form field and again below in red, so an
// ordinary description read as a failure and overlapped the button beside it.
const tip = 'Higher values make the output more varied.';
dialog.open({
title: 'Model parameters',
fields: [{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 1, tip, defaultsWhenEmpty: true }],
initial: {}
});
fixture.detectChanges();
await fixture.whenStable();
const host = fixture.nativeElement as HTMLElement;
const occurrences = [...host.querySelectorAll('*')]
.filter((element) => element.children.length === 0 && element.textContent?.trim() === tip);
expect(occurrences).toHaveLength(1);
expect(occurrences[0].className).not.toContain('red');
expect(host.querySelectorAll('mat-hint').length).toBe(0);
});
it('ranks the status above the description, and keeps the button out of both', async () => {
dialog.open({
title: 'Model parameters',
fields: [{
key: 'temperature',
label: 'Temperature',
type: 'number',
min: 0,
max: 1,
tip: 'Higher values make the output more varied.',
defaultsWhenEmpty: true
}],
initial: { temperature: 5 }
});
fixture.detectChanges();
await fixture.whenStable();
const host = fixture.nativeElement as HTMLElement;
const text = host.textContent ?? '';
// The error first, the description after it, and one button that is not inside either.
expect(text.indexOf('Must be between 0 and 1')).toBeLessThan(text.indexOf('Higher values'));
const buttons = [...host.querySelectorAll('button')]
.filter((element) => element.textContent?.trim() === 'Use default');
expect(buttons).toHaveLength(1);
// Beside the control, not in the meta block under it.
expect(buttons[0].closest('.mt-1\\.5')).toBeNull();
});
it('reopens a later dialog with the group closed again', async () => {
await open();
component.toggleGroup(PARAMETER_GROUP);
expect(component.isGroupOpen(PARAMETER_GROUP)).toBe(true);
dialog.close(null);
await open();
expect(component.isGroupOpen(PARAMETER_GROUP)).toBe(false);
});
});
describe('validateFieldValue', () => {
it('accepts an empty optional value, because empty means "leave it to the default"', () => {
expect(validateFieldValue({ type: 'number', min: 0, max: 1 }, '')).toBeNull();
expect(validateFieldValue({ type: 'text' }, ' ')).toBeNull();
expect(validateFieldValue({ type: 'text' }, null)).toBeNull();
});
it('reports an empty value only when the caller declared the field required', () => {
expect(validateFieldValue({ type: 'select', required: true }, '')).toBe('Required');
});
it('keeps a bounded number inside its bounds', () => {
// The bound the server enforces anyway. Learning about it on save, after the dialog is gone,
// is what this is for.
const temperature = { type: 'number' as const, min: 0, max: 1 };
expect(validateFieldValue(temperature, '0')).toBeNull();
expect(validateFieldValue(temperature, '0.7')).toBeNull();
expect(validateFieldValue(temperature, '1')).toBeNull();
expect(validateFieldValue(temperature, '1.5')).toBe('Must be between 0 and 1');
expect(validateFieldValue(temperature, '-0.1')).toBe('Must be between 0 and 1');
});
it('names the single bound when only one is declared', () => {
expect(validateFieldValue({ type: 'number', min: 1 }, '0')).toBe('Must be 1 or more');
expect(validateFieldValue({ type: 'number', max: 10 }, '11')).toBe('Must be 10 or less');
});
it('rejects something that is not a number at all', () => {
expect(validateFieldValue({ type: 'number' }, 'warm')).toBe('Must be a number');
});
it('rejects a decimal where the step says whole numbers', () => {
// How an integer field says so. Truncating 2.5 to 2 in silence is the alternative.
expect(validateFieldValue({ type: 'number', step: 1 }, '2.5')).toBe('Must be a whole number');
expect(validateFieldValue({ type: 'number', step: 1 }, '2')).toBeNull();
});
it('leaves a decimal alone when no granularity is declared', () => {
// The arrows move by 0.1 for convenience, which must never make 0.35 wrong. FieldValueConstraints
// does not even include stepIncrement, so the increment cannot reach this function to try.
expect(validateFieldValue({ type: 'number', min: 0, max: 1 }, '0.35')).toBeNull();
});
it('checks length and format on text', () => {
expect(validateFieldValue({ type: 'text', minLength: 3 }, 'ab')).toBe('At least 3 characters');
expect(validateFieldValue({ type: 'text', maxLength: 3 }, 'abcd')).toBe('At most 3 characters');
expect(validateFieldValue({ type: 'text', pattern: '^[a-z]+$' }, 'Abc')).toBe('Invalid format');
expect(validateFieldValue({ type: 'text', pattern: '^[a-z]+$' }, 'abc')).toBeNull();
});
it('does not lock a field behind a pattern we got wrong', () => {
// An uncompilable regex is our bug, and the server still has the real say.
expect(validateFieldValue({ type: 'text', pattern: '([unclosed' }, 'anything')).toBeNull();
});
it('has nothing to say about a checkbox or a read-only display', () => {
expect(validateFieldValue({ type: 'checkbox', required: true }, false)).toBeNull();
expect(validateFieldValue({ type: 'display', required: true }, '')).toBeNull();
});
});