342 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|