llm-semantic-visualizer / tests /ThreadSliders.test.js
hbauzan's picture
feat(ui): field-info tips and retire landscape gate
aaa664a
Raw
History Blame Contribute Delete
10.1 kB
import { describe, it, expect, afterEach } from 'vitest';
import { threadSlidersMarkup, wireThreadSliders, syncThreadSlidersFromConfig } from '../src/ui/ThreadSliders.js';
import {
GLOBAL_SPATIAL_DEFAULTS,
SPATIAL_DEFAULT_OVERRIDES,
} from '../src/ui/spatialSliderDefaults.js';
/** Parse <input id="..." min max step value> attrs from markup. */
function parseRangeInput(html, id) {
const re = new RegExp(
`<input[^>]*id="${id}"[^>]*>`,
'i'
);
const tag = html.match(re)?.[0];
if (!tag) return null;
const attr = (name) => {
const m = tag.match(new RegExp(`${name}="([^"]*)"`));
return m ? parseFloat(m[1]) : NaN;
};
return {
min: attr('min'),
max: attr('max'),
step: attr('step'),
value: attr('value'),
};
}
/**
* Defaults + ranges. Shared min/max for every MODE|VIEW|RENDER.
* Spacing [0.4, 2.0] step 0.05 — COMPARE default 0.7 stays; global 0.4 at min.
* Length [0.001, 0.2] step 0.001 — never 0; COMPARE 0.1 ≈ mid; global 0.2 at max.
*/
const SPATIAL_SLIDER_SPECS = [
{ id: 'thread-spacing-slider', defaultMid: 0.4, min: 0.4, max: 2.0, step: 0.05, centered: false },
{ id: 'thread-vector-dist-slider', defaultMid: 10.0, min: 1.0, max: 19.0, step: 0.1, centered: true },
{ id: 'thread-amplitude-y-slider', defaultMid: 7.0, min: 1.0, max: 40.0, step: 0.1, centered: false },
{ id: 'thread-width-slider', defaultMid: 0.2, min: 0.001, max: 0.2, step: 0.001, centered: false },
{ id: 'thread-thickness-slider', defaultMid: 0.05, min: 0.01, max: 0.09, step: 0.01, centered: true },
];
function createMockEl(id = '') {
const listeners = {};
const el = {
id,
value: '',
textContent: '',
addEventListener: (type, fn) => {
if (!listeners[type]) listeners[type] = [];
listeners[type].push(fn);
},
dispatch: (type, event = {}) => {
for (const fn of listeners[type] || []) fn(event);
},
};
return el;
}
describe('threadSlidersMarkup — ranges centered on defaults', () => {
it('emits min/max/step/value with defaults in range (centered where required)', () => {
const html = threadSlidersMarkup();
for (const spec of SPATIAL_SLIDER_SPECS) {
const input = parseRangeInput(html, spec.id);
expect(input, spec.id).not.toBeNull();
expect(input.min).toBeCloseTo(spec.min, 5);
expect(input.max).toBeCloseTo(spec.max, 5);
expect(input.step).toBeCloseTo(spec.step, 5);
expect(input.value).toBeCloseTo(spec.defaultMid, 5);
// Default must land on a step tick and stay in range.
const ticksFromMin = (spec.defaultMid - input.min) / input.step;
expect(ticksFromMin).toBeCloseTo(Math.round(ticksFromMin), 5);
expect(input.value).toBeGreaterThanOrEqual(input.min);
expect(input.value).toBeLessThanOrEqual(input.max);
if (spec.centered) {
const mid = (input.min + input.max) / 2;
expect(mid).toBeCloseTo(spec.defaultMid, 5);
expect(input.value).toBeGreaterThan(input.min);
expect(input.value).toBeLessThan(input.max);
}
}
});
it('markup includes field-info tips on each slider label', () => {
const html = threadSlidersMarkup();
expect(html.match(/class="field-info-btn"/g)?.length).toBe(5);
expect(html).toContain('data-field-info="Gap between threads."');
expect(html).toContain('data-field-info="Peak height."');
});
it('places COMPARE|NAVIGATION|POINTS Spacing 0.7 within [0.4, 2.0]', () => {
const html = threadSlidersMarkup({
threadSpacing: 0.7,
threadVectorDistance: 10.0,
threadAmplitudeY: 4.9,
threadWidth: 0.1,
threadThickness: 0.01,
});
const input = parseRangeInput(html, 'thread-spacing-slider');
expect(input.min).toBeCloseTo(0.4, 5);
expect(input.max).toBeCloseTo(2.0, 5);
expect(input.value).toBeCloseTo(0.7, 5);
});
it('Length (Z) never reaches 0; COMPARE 0.1 sits near mid of [0.001, 0.2]', () => {
const html = threadSlidersMarkup({
threadSpacing: 0.7,
threadVectorDistance: 10.0,
threadAmplitudeY: 4.9,
threadWidth: 0.1,
threadThickness: 0.01,
});
const input = parseRangeInput(html, 'thread-width-slider');
expect(input.min).toBeCloseTo(0.001, 5);
expect(input.min).toBeGreaterThan(0);
expect(input.max).toBeCloseTo(0.2, 5);
expect(input.step).toBeCloseTo(0.001, 5);
expect(input.value).toBeCloseTo(0.1, 5);
const mid = (input.min + input.max) / 2;
expect(Math.abs(input.value - mid)).toBeLessThan(0.002);
});
it('keeps explicit config values as the input value (still within range)', () => {
const html = threadSlidersMarkup({
threadSpacing: 0.4,
threadVectorDistance: 10.0,
threadAmplitudeY: 7.0,
threadWidth: 0.2,
threadThickness: 0.05,
});
expect(parseRangeInput(html, 'thread-spacing-slider').value).toBeCloseTo(0.4, 5);
expect(parseRangeInput(html, 'thread-vector-dist-slider').value).toBeCloseTo(10.0, 5);
expect(parseRangeInput(html, 'thread-amplitude-y-slider').value).toBeCloseTo(7.0, 5);
expect(parseRangeInput(html, 'thread-width-slider').value).toBeCloseTo(0.2, 5);
expect(parseRangeInput(html, 'thread-thickness-slider').value).toBeCloseTo(0.05, 5);
});
it('formats default labels with decimals matching each step', () => {
const html = threadSlidersMarkup();
const labelFor = (id) => {
const m = html.match(new RegExp(`id="${id}"[^>]*>\\s*([\\d.]+)`));
return m ? m[1] : null;
};
expect(labelFor('thread-spacing-val')).toBe('0.40');
expect(labelFor('thread-vector-dist-val')).toBe('10.0');
expect(labelFor('thread-amplitude-y-val')).toBe('7.0');
expect(labelFor('thread-width-val')).toBe('0.200');
expect(labelFor('thread-thickness-val')).toBe('0.05');
});
});
describe('wireThreadSliders — dblclick reset', () => {
const overrideKeys = [];
afterEach(() => {
for (const key of overrideKeys.splice(0)) {
delete SPATIAL_DEFAULT_OVERRIDES[key];
}
});
it('restores only the double-clicked slider to the resolved default', () => {
const ampInput = createMockEl('thread-amplitude-y-slider');
const ampLabel = createMockEl('thread-amplitude-y-val');
const byId = {
'thread-amplitude-y-slider': ampInput,
'thread-amplitude-y-val': ampLabel,
'thread-spacing-slider': createMockEl(),
'thread-spacing-val': createMockEl(),
'thread-vector-dist-slider': createMockEl(),
'thread-vector-dist-val': createMockEl(),
'thread-width-slider': createMockEl(),
'thread-width-val': createMockEl(),
'thread-thickness-slider': createMockEl(),
'thread-thickness-val': createMockEl(),
};
const container = {
querySelector: (sel) => byId[sel.replace('#', '')] || null,
};
const config = {
threadSpacing: 0.55,
threadVectorDistance: 14.0,
threadSpacingY: 14.0,
threadAmplitudeY: 11.0,
threadWidth: 0.15,
threadThickness: 0.08,
};
let changeCount = 0;
wireThreadSliders(container, null, config, () => { changeCount += 1; }, {
getContext: () => ({
workspaceMode: 'ARITHMETIC',
viewMode: 'NAVIGATION',
renderMode: 'POINTS',
}),
});
ampInput.dispatch('dblclick', { preventDefault() {} });
expect(config.threadAmplitudeY).toBe(GLOBAL_SPATIAL_DEFAULTS.threadAmplitudeY);
expect(ampInput.value).toBe(String(GLOBAL_SPATIAL_DEFAULTS.threadAmplitudeY));
expect(ampLabel.textContent).toBe('7.0');
expect(config.threadSpacing).toBe(0.55);
expect(config.threadVectorDistance).toBe(14.0);
expect(config.threadWidth).toBe(0.15);
expect(config.threadThickness).toBe(0.08);
expect(changeCount).toBe(1);
});
it('uses context override when defined for current MODE/VIEW/RENDER', () => {
SPATIAL_DEFAULT_OVERRIDES['COMPARE|ANALYSIS|RIBBONS'] = { threadSpacing: 0.6 };
overrideKeys.push('COMPARE|ANALYSIS|RIBBONS');
const spacingInput = createMockEl('thread-spacing-slider');
const spacingLabel = createMockEl('thread-spacing-val');
const byId = {
'thread-spacing-slider': spacingInput,
'thread-spacing-val': spacingLabel,
'thread-vector-dist-slider': createMockEl(),
'thread-vector-dist-val': createMockEl(),
'thread-amplitude-y-slider': createMockEl(),
'thread-amplitude-y-val': createMockEl(),
'thread-width-slider': createMockEl(),
'thread-width-val': createMockEl(),
'thread-thickness-slider': createMockEl(),
'thread-thickness-val': createMockEl(),
};
const container = {
querySelector: (sel) => byId[sel.replace('#', '')] || null,
};
const config = { threadSpacing: 0.2 };
wireThreadSliders(container, null, config, null, {
getContext: () => ({
workspaceMode: 'COMPARE',
viewMode: 'ANALYSIS',
renderMode: 'RIBBONS',
}),
});
spacingInput.dispatch('dblclick', { preventDefault() {} });
expect(config.threadSpacing).toBe(0.6);
expect(spacingLabel.textContent).toBe('0.60');
});
});
describe('syncThreadSlidersFromConfig', () => {
it('writes config into inputs and labels', () => {
const ampInput = createMockEl('thread-amplitude-y-slider');
const ampLabel = createMockEl('thread-amplitude-y-val');
const byId = {
'thread-amplitude-y-slider': ampInput,
'thread-amplitude-y-val': ampLabel,
'thread-spacing-slider': createMockEl(),
'thread-spacing-val': createMockEl(),
'thread-vector-dist-slider': createMockEl(),
'thread-vector-dist-val': createMockEl(),
'thread-width-slider': createMockEl(),
'thread-width-val': createMockEl(),
'thread-thickness-slider': createMockEl(),
'thread-thickness-val': createMockEl(),
};
const container = {
querySelector: (sel) => byId[sel.replace('#', '')] || null,
};
syncThreadSlidersFromConfig(container, { threadAmplitudeY: 40.0 });
expect(ampInput.value).toBe('40');
expect(ampLabel.textContent).toBe('40.0');
});
});