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 attrs from markup. */ function parseRangeInput(html, id) { const re = new RegExp( `]*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'); }); });