Spaces:
Sleeping
Sleeping
| import { describe, it, expect, beforeEach } from 'vitest'; | |
| import { | |
| DEFAULT_VISUALIZATION_SETTINGS, | |
| DEFAULT_VIZ_COLORS, | |
| DEFAULT_VIZ_FILTER, | |
| VIZ_STORAGE_KEYS, | |
| isValidHex, | |
| normalizeHex, | |
| hexToRgb01, | |
| resolveVisualizationSettings, | |
| loadVisualizationSettings, | |
| saveVisualizationSettings, | |
| resetVisualizationSettings, | |
| anchorsFromSettings, | |
| remapAbsTWithZeroCoverage, | |
| normalizeZeroCoverage, | |
| normalizeHighCoverage, | |
| highCoverageFromSlider, | |
| highCoverageToSlider, | |
| formatHighCoverage, | |
| effectiveZeroCoveragePercent, | |
| HIGH_COVERAGE_MIN, | |
| HIGH_COVERAGE_MAX, | |
| HIGH_COVERAGE_SLIDER_MAX, | |
| } from '../src/ui/visualizationControlsDefaults.js'; | |
| import { | |
| visualizationControlsMarkup, | |
| setVisualizationPanelCollapsed, | |
| setVisualizationPanelLayout, | |
| vizPanelTabGlyph, | |
| vizPanelLayoutForViewport, | |
| resolveVisualizationMountParent, | |
| syncGroupHueColorRows, | |
| } from '../src/ui/VisualizationControls.js'; | |
| import { | |
| NEAR_ZERO_EPS, | |
| shouldShowActivation, | |
| filterPointsData, | |
| lineSegmentIndices, | |
| wideRibbonQuadIndices, | |
| } from '../src/visualizer/activationFilter.js'; | |
| import { getDivergentColor } from '../src/visualizer/DivergentShading.js'; | |
| function mockStorage(initial = {}) { | |
| const map = new Map(Object.entries(initial)); | |
| return { | |
| getItem: (k) => (map.has(k) ? map.get(k) : null), | |
| setItem: (k, v) => { map.set(k, String(v)); }, | |
| removeItem: (k) => { map.delete(k); }, | |
| _map: map, | |
| }; | |
| } | |
| describe('visualizationControlsDefaults', () => { | |
| it('defaults match §0.2 anchors and filter All', () => { | |
| expect(DEFAULT_VIZ_FILTER).toBe('all'); | |
| expect(DEFAULT_VIZ_COLORS.colorPositive).toBe('#FFE600'); | |
| expect(DEFAULT_VIZ_COLORS.colorZero).toBe('#000000'); | |
| expect(DEFAULT_VIZ_COLORS.colorNegative).toBe('#9900E6'); | |
| expect(DEFAULT_VISUALIZATION_SETTINGS).toEqual({ | |
| vizFilterMode: 'all', | |
| colorPositive: '#FFE600', | |
| colorZero: '#000000', | |
| colorNegative: '#9900E6', | |
| zeroCoverageEnabled: false, | |
| zeroCoverage: 30, | |
| labelsVisible: true, | |
| sameSignCancelEnabled: false, | |
| sameSignCancelCoverage: 30, | |
| oppositeHighlightEnabled: false, | |
| oppositeHighlightColor: '#00E5FF', | |
| oppositeHighlightStrength: 70, | |
| oppositeCancelCoverage: 0, | |
| groupHueEnabled: false, | |
| groupHueColors: {}, | |
| }); | |
| }); | |
| it('validates and normalizes hex', () => { | |
| expect(isValidHex('#FFE600')).toBe(true); | |
| expect(isValidHex('#ff00')).toBe(false); | |
| expect(isValidHex('not-a-color')).toBe(false); | |
| expect(normalizeHex('ffe600')).toBe('#FFE600'); | |
| expect(normalizeHex('#abc')).toBeNull(); | |
| }); | |
| it('hexToRgb01 parses endpoints', () => { | |
| expect(hexToRgb01('#FFE600')).toEqual({ | |
| r: 1, | |
| g: 230 / 255, | |
| b: 0, | |
| }); | |
| expect(hexToRgb01('#000000')).toEqual({ r: 0, g: 0, b: 0 }); | |
| expect(hexToRgb01('#9900E6')).toEqual({ | |
| r: 153 / 255, | |
| g: 0, | |
| b: 230 / 255, | |
| }); | |
| }); | |
| it('resolveVisualizationSettings falls back invalid hex and mode', () => { | |
| const resolved = resolveVisualizationSettings({ | |
| vizFilterMode: 'nope', | |
| colorPositive: 'bad', | |
| colorZero: '#112233', | |
| colorNegative: '#9900E6', | |
| }); | |
| expect(resolved.vizFilterMode).toBe('all'); | |
| expect(resolved.colorPositive).toBe('#FFE600'); | |
| expect(resolved.colorZero).toBe('#112233'); | |
| expect(resolved.colorNegative).toBe('#9900E6'); | |
| }); | |
| it('persist round-trip via mock localStorage', () => { | |
| const storage = mockStorage(); | |
| const settings = resolveVisualizationSettings({ | |
| vizFilterMode: 'positive', | |
| colorPositive: '#FF0000', | |
| colorZero: '#111111', | |
| colorNegative: '#0000FF', | |
| }); | |
| saveVisualizationSettings(settings, storage); | |
| expect(storage.getItem(VIZ_STORAGE_KEYS.filter)).toBe('positive'); | |
| expect(loadVisualizationSettings(storage)).toEqual(settings); | |
| }); | |
| it('reset restores defaults and All', () => { | |
| const storage = mockStorage({ | |
| [VIZ_STORAGE_KEYS.filter]: 'negative', | |
| [VIZ_STORAGE_KEYS.colorPositive]: '#FF0000', | |
| }); | |
| const reset = resetVisualizationSettings(storage); | |
| expect(reset).toEqual(DEFAULT_VISUALIZATION_SETTINGS); | |
| expect(loadVisualizationSettings(storage)).toEqual(DEFAULT_VISUALIZATION_SETTINGS); | |
| }); | |
| it('anchorsFromSettings yields RGB01 trio', () => { | |
| const a = anchorsFromSettings(DEFAULT_VISUALIZATION_SETTINGS); | |
| expect(a.positive.r).toBeCloseTo(1, 5); | |
| expect(a.zero).toEqual({ r: 0, g: 0, b: 0 }); | |
| expect(a.negative.b).toBeCloseTo(230 / 255, 5); | |
| }); | |
| }); | |
| describe('Visualization panel collapse tab', () => { | |
| it('edge layout uses vertical glyphs (▼ raised, ▲ resting on HUD)', () => { | |
| expect(vizPanelTabGlyph(false, 'edge')).toBe('▼'); | |
| expect(vizPanelTabGlyph(true, 'edge')).toBe('▲'); | |
| const expanded = visualizationControlsMarkup(DEFAULT_VISUALIZATION_SETTINGS, { | |
| collapsed: false, | |
| layout: 'edge', | |
| }); | |
| expect(expanded).toContain('▼'); | |
| expect(expanded).not.toContain('▶'); | |
| const collapsed = visualizationControlsMarkup(DEFAULT_VISUALIZATION_SETTINGS, { | |
| collapsed: true, | |
| layout: 'edge', | |
| }); | |
| expect(collapsed).toContain('▲'); | |
| expect(collapsed).not.toContain('◀'); | |
| }); | |
| it('markup includes edge tab and aria-expanded', () => { | |
| const html = visualizationControlsMarkup(DEFAULT_VISUALIZATION_SETTINGS, { collapsed: false }); | |
| expect(html).toContain('viz-panel-tab'); | |
| expect(html).toContain('data-viz-layout="edge"'); | |
| expect(html).toContain('aria-expanded="true"'); | |
| expect(html).toContain('▼'); | |
| expect(html).toContain('viz-zero-coverage-slider'); | |
| expect(html).toContain('viz-zero-coverage-enabled'); | |
| expect(html).toContain('Zero coverage'); | |
| expect(html).toContain('viz-group-contrast'); | |
| expect(html).toContain('Group contrast'); | |
| expect(html).toContain('Group hue'); | |
| expect(html).toContain('viz-group-hue-enabled'); | |
| expect(html).toContain('viz-group-hue-rows'); | |
| expect(html).toContain('viz-same-sign-enabled'); | |
| expect(html).toContain('viz-opposite-enabled'); | |
| expect(html).toContain('viz-labels-toggle'); | |
| expect(html).toContain('Hide labels'); | |
| expect(html).toContain('field-info-btn'); | |
| expect(html).toContain('data-field-info="Which signs show."'); | |
| expect(html).toContain('data-field-info="Hold range at zero."'); | |
| expect(html).toContain('data-field-info="Groups only (G1↔G2)."'); | |
| }); | |
| it('collapse tab is a sibling of viz-panel-body (outside card chrome)', () => { | |
| const html = visualizationControlsMarkup(DEFAULT_VISUALIZATION_SETTINGS, { collapsed: false }); | |
| const hostOpen = html.indexOf('id="visualization-controls-container"'); | |
| const tabOpen = html.indexOf('class="viz-panel-tab dock-tab"'); | |
| const bodyOpen = html.indexOf('id="viz-panel-body"'); | |
| const bodyClose = html.indexOf('</div>\n</div>', bodyOpen); | |
| expect(hostOpen).toBeGreaterThan(-1); | |
| expect(tabOpen).toBeGreaterThan(hostOpen); | |
| expect(bodyOpen).toBeGreaterThan(tabOpen); | |
| // Tab must not be nested inside the body card markup. | |
| expect(tabOpen).toBeLessThan(bodyOpen); | |
| expect(bodyClose).toBeGreaterThan(bodyOpen); | |
| expect(html.slice(bodyOpen, bodyClose)).not.toContain('viz-panel-tab'); | |
| }); | |
| it('collapsed markup starts with expand glyph', () => { | |
| const html = visualizationControlsMarkup(DEFAULT_VISUALIZATION_SETTINGS, { collapsed: true }); | |
| expect(html).toContain('is-collapsed'); | |
| expect(html).toContain('aria-expanded="false"'); | |
| expect(html).toContain('▲'); | |
| }); | |
| it('sheet layout uses the same vertical glyphs and layout attribute', () => { | |
| expect(vizPanelLayoutForViewport(true)).toBe('sheet'); | |
| expect(vizPanelLayoutForViewport(false)).toBe('edge'); | |
| expect(vizPanelTabGlyph(true, 'sheet')).toBe('▲'); | |
| expect(vizPanelTabGlyph(false, 'sheet')).toBe('▼'); | |
| const html = visualizationControlsMarkup(DEFAULT_VISUALIZATION_SETTINGS, { | |
| collapsed: true, | |
| layout: 'sheet', | |
| }); | |
| expect(html).toContain('data-viz-layout="sheet"'); | |
| expect(html).toContain('▲'); | |
| expect(html).toContain('Expand Visualization sheet'); | |
| }); | |
| it('resolveVisualizationMountParent always uses app root (HUD glue)', () => { | |
| const dockBody = { id: 'dock' }; | |
| const appRoot = { id: 'app' }; | |
| expect(resolveVisualizationMountParent({ | |
| isMobile: true, | |
| dockBody, | |
| appRoot, | |
| })).toBe(appRoot); | |
| expect(resolveVisualizationMountParent({ | |
| isMobile: false, | |
| dockBody, | |
| appRoot, | |
| })).toBe(appRoot); | |
| }); | |
| it('setVisualizationPanelCollapsed toggles class and glyph', () => { | |
| const classList = new Set(['section-card', 'viz-panel']); | |
| const tab = { | |
| textContent: '▼', | |
| attrs: {}, | |
| setAttribute(k, v) { this.attrs[k] = String(v); }, | |
| }; | |
| const el = { | |
| dataset: { vizLayout: 'edge' }, | |
| classList: { | |
| toggle: (cls, force) => { | |
| if (force) classList.add(cls); | |
| else classList.delete(cls); | |
| }, | |
| contains: (cls) => classList.has(cls), | |
| }, | |
| querySelector: (sel) => (sel === '.viz-panel-tab' ? tab : null), | |
| }; | |
| setVisualizationPanelCollapsed(el, true); | |
| expect(classList.has('is-collapsed')).toBe(true); | |
| expect(tab.textContent).toBe('▲'); | |
| expect(tab.attrs['aria-expanded']).toBe('false'); | |
| setVisualizationPanelCollapsed(el, false); | |
| expect(classList.has('is-collapsed')).toBe(false); | |
| expect(tab.textContent).toBe('▼'); | |
| }); | |
| it('setVisualizationPanelLayout keeps vertical glyphs on sheet', () => { | |
| const classList = new Set(['section-card', 'viz-panel', 'is-collapsed']); | |
| const tab = { | |
| textContent: '▲', | |
| attrs: {}, | |
| setAttribute(k, v) { this.attrs[k] = String(v); }, | |
| }; | |
| const el = { | |
| dataset: { vizLayout: 'edge' }, | |
| classList: { | |
| toggle: (cls, force) => { | |
| if (force) classList.add(cls); | |
| else classList.delete(cls); | |
| }, | |
| contains: (cls) => classList.has(cls), | |
| }, | |
| querySelector: (sel) => (sel === '.viz-panel-tab' ? tab : null), | |
| }; | |
| setVisualizationPanelLayout(el, 'sheet'); | |
| expect(el.dataset.vizLayout).toBe('sheet'); | |
| expect(tab.textContent).toBe('▲'); | |
| }); | |
| }); | |
| describe('shouldShowActivation / filter helpers', () => { | |
| const samples = [1, 0.5, 0.005, -0.5, -1]; | |
| it('all mode shows every sample', () => { | |
| for (const v of samples) { | |
| expect(shouldShowActivation(v, 'all')).toBe(true); | |
| } | |
| }); | |
| it('+ only hides negatives and near-zero', () => { | |
| expect(shouldShowActivation(1, 'positive')).toBe(true); | |
| expect(shouldShowActivation(0.5, 'positive')).toBe(true); | |
| expect(shouldShowActivation(0.005, 'positive')).toBe(false); | |
| expect(shouldShowActivation(-0.5, 'positive')).toBe(false); | |
| expect(shouldShowActivation(-1, 'positive')).toBe(false); | |
| }); | |
| it('− only hides positives and near-zero', () => { | |
| expect(shouldShowActivation(1, 'negative')).toBe(false); | |
| expect(shouldShowActivation(0.5, 'negative')).toBe(false); | |
| expect(shouldShowActivation(0.005, 'negative')).toBe(false); | |
| expect(shouldShowActivation(-0.5, 'negative')).toBe(true); | |
| expect(shouldShowActivation(-1, 'negative')).toBe(true); | |
| }); | |
| it('uses ε = 0.01 by default', () => { | |
| expect(NEAR_ZERO_EPS).toBe(0.01); | |
| expect(shouldShowActivation(0.009, 'positive')).toBe(false); | |
| expect(shouldShowActivation(0.01, 'positive')).toBe(true); | |
| }); | |
| it('filterPointsData keeps aligned entries', () => { | |
| const points = samples.map((activation, i) => ({ i, activation })); | |
| const kept = filterPointsData(points, samples, 'positive'); | |
| expect(kept.map((p) => p.i)).toEqual([0, 1]); | |
| }); | |
| it('lineSegmentIndices breaks across hidden signs', () => { | |
| // visible, visible, near-zero, visible-neg → only first segment | |
| const norm = [0.5, 0.8, 0.0, -0.5]; | |
| expect(lineSegmentIndices(norm, 'positive')).toEqual([0, 1]); | |
| expect(lineSegmentIndices(norm, 'all')).toEqual([0, 1, 1, 2, 2, 3]); | |
| }); | |
| it('wideRibbonQuadIndices omits quads for filtered pairs', () => { | |
| const norm = [0.5, 0.8, -0.5]; | |
| expect(wideRibbonQuadIndices(norm, 'positive')).toEqual([0, 2, 1, 1, 2, 3]); | |
| expect(wideRibbonQuadIndices(norm, 'negative')).toEqual([]); | |
| }); | |
| }); | |
| describe('getDivergentColor with color anchors', () => { | |
| const anchors = anchorsFromSettings(DEFAULT_VISUALIZATION_SETTINGS); | |
| it('t=1 → +1 hex', () => { | |
| const c = getDivergentColor(1, 1, anchors); | |
| expect(c.r).toBeCloseTo(anchors.positive.r, 5); | |
| expect(c.g).toBeCloseTo(anchors.positive.g, 5); | |
| expect(c.b).toBeCloseTo(anchors.positive.b, 5); | |
| }); | |
| it('t=0 → 0 hex with low alpha', () => { | |
| const c = getDivergentColor(0, 1, anchors); | |
| expect(c.r).toBeCloseTo(anchors.zero.r, 5); | |
| expect(c.g).toBeCloseTo(anchors.zero.g, 5); | |
| expect(c.b).toBeCloseTo(anchors.zero.b, 5); | |
| expect(c.alpha).toBeCloseTo(0.05, 5); | |
| }); | |
| it('t=-1 → −1 hex', () => { | |
| const c = getDivergentColor(-1, 1, anchors); | |
| expect(c.r).toBeCloseTo(anchors.negative.r, 5); | |
| expect(c.g).toBeCloseTo(anchors.negative.g, 5); | |
| expect(c.b).toBeCloseTo(anchors.negative.b, 5); | |
| }); | |
| it('t=0.5 → midpoint lerp 0↔+1', () => { | |
| const c = getDivergentColor(0.5, 1, anchors); | |
| expect(c.r).toBeCloseTo((anchors.zero.r + anchors.positive.r) / 2, 5); | |
| expect(c.g).toBeCloseTo((anchors.zero.g + anchors.positive.g) / 2, 5); | |
| expect(c.b).toBeCloseTo((anchors.zero.b + anchors.positive.b) / 2, 5); | |
| }); | |
| it('zero coverage 50% keeps mid activations at zero color', () => { | |
| const c = getDivergentColor(0.4, 1, anchors, 50); | |
| expect(c.r).toBeCloseTo(anchors.zero.r, 5); | |
| expect(c.g).toBeCloseTo(anchors.zero.g, 5); | |
| expect(c.b).toBeCloseTo(anchors.zero.b, 5); | |
| const midPos = getDivergentColor(0.75, 1, anchors, 50); | |
| // remapped k = (0.75-0.5)/(1-0.5) = 0.5 → midpoint | |
| expect(midPos.r).toBeCloseTo((anchors.zero.r + anchors.positive.r) / 2, 5); | |
| }); | |
| }); | |
| describe('remapAbsTWithZeroCoverage', () => { | |
| it('identity at 0 coverage', () => { | |
| expect(remapAbsTWithZeroCoverage(0.4, 0)).toBeCloseTo(0.4, 5); | |
| }); | |
| it('holds zero then stretches', () => { | |
| expect(remapAbsTWithZeroCoverage(0.3, 0.5)).toBe(0); | |
| expect(remapAbsTWithZeroCoverage(0.5, 0.5)).toBe(0); | |
| expect(remapAbsTWithZeroCoverage(0.75, 0.5)).toBeCloseTo(0.5, 5); | |
| expect(remapAbsTWithZeroCoverage(1, 0.5)).toBeCloseTo(1, 5); | |
| }); | |
| it('supports near-100% coverage', () => { | |
| const c = HIGH_COVERAGE_MAX / 100; | |
| expect(remapAbsTWithZeroCoverage(0.99, c)).toBe(0); | |
| expect(remapAbsTWithZeroCoverage(1, c)).toBeCloseTo(1, 5); | |
| }); | |
| it('normalizeZeroCoverage clamps conflict cover 0–90', () => { | |
| expect(normalizeZeroCoverage(150)).toBe(90); | |
| expect(normalizeZeroCoverage(-3)).toBe(0); | |
| expect(normalizeZeroCoverage('40')).toBe(40); | |
| }); | |
| it('normalizeHighCoverage clamps 30…99.9999', () => { | |
| expect(normalizeHighCoverage(10)).toBe(HIGH_COVERAGE_MIN); | |
| expect(normalizeHighCoverage(100)).toBe(HIGH_COVERAGE_MAX); | |
| expect(normalizeHighCoverage(50.12345)).toBeCloseTo(50.1235, 4); | |
| }); | |
| it('highCoverage slider maps endpoints', () => { | |
| expect(highCoverageFromSlider(0)).toBe(HIGH_COVERAGE_MIN); | |
| expect(highCoverageFromSlider(HIGH_COVERAGE_SLIDER_MAX)).toBe(HIGH_COVERAGE_MAX); | |
| expect(highCoverageToSlider(HIGH_COVERAGE_MIN)).toBe(0); | |
| expect(highCoverageToSlider(HIGH_COVERAGE_MAX)).toBe(HIGH_COVERAGE_SLIDER_MAX); | |
| expect(formatHighCoverage(HIGH_COVERAGE_MAX)).toBe('99.9999%'); | |
| }); | |
| it('effectiveZeroCoveragePercent is 0 when Off', () => { | |
| expect(effectiveZeroCoveragePercent({ zeroCoverageEnabled: false, zeroCoverage: 80 })).toBe(0); | |
| expect(effectiveZeroCoveragePercent({ zeroCoverageEnabled: true, zeroCoverage: 80 })).toBe(80); | |
| }); | |
| it('syncGroupHueColorRows uses textarea labels and scrolls past 3 groups', () => { | |
| let rowsHtml = ''; | |
| let scrollable = false; | |
| const host = { | |
| classList: { | |
| toggle: (cls, on) => { | |
| if (cls === 'is-scrollable') scrollable = Boolean(on); | |
| }, | |
| }, | |
| set innerHTML(v) { rowsHtml = String(v); }, | |
| get innerHTML() { return rowsHtml; }, | |
| querySelectorAll: () => [], | |
| }; | |
| const container = { | |
| querySelector: (sel) => { | |
| if (sel === '#viz-group-hue-rows') return host; | |
| return null; | |
| }, | |
| querySelectorAll: () => [], | |
| }; | |
| const config = resolveVisualizationSettings({ groupHueEnabled: true }); | |
| syncGroupHueColorRows(container, [ | |
| { id: 'animals', label: 'animals' }, | |
| { id: 'it_core', label: 'it_core' }, | |
| { id: 'women', label: 'women' }, | |
| ], config); | |
| expect(rowsHtml).toContain('>animals<'); | |
| expect(rowsHtml).toContain('>it_core<'); | |
| expect(rowsHtml).toContain('>women<'); | |
| expect(rowsHtml).toContain('data-group-id="animals"'); | |
| expect(scrollable).toBe(false); | |
| syncGroupHueColorRows(container, ['G1', 'G2', 'G3', 'G4'], config); | |
| expect(rowsHtml).toContain('>G1<'); | |
| expect(rowsHtml).toContain('>G4<'); | |
| expect(scrollable).toBe(true); | |
| }); | |
| }); | |