You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
140 lines
4.2 KiB
140 lines
4.2 KiB
// @vitest-environment jsdom
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { createActiveDom } from '$adom';
|
|
import { state } from '$libs/reactive';
|
|
import type { Morfo } from '$uix/morfo';
|
|
import { Soma } from '$soma/core/soma.svelte';
|
|
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
|
|
|
import { WaveformProvider, WaveformWaveProvider, buildWavePath } from './waveform-provider.svelte';
|
|
|
|
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
|
let result!: T;
|
|
const cleanup = $effect.root(() => {
|
|
result = fn();
|
|
});
|
|
return { result, cleanup };
|
|
}
|
|
|
|
function installSomaHarness() {
|
|
const dom = createActiveDom();
|
|
const soma = {
|
|
dom,
|
|
// The provider resolves its default label through `soma.langs.ts(...)`;
|
|
// the keys embed their English fallback after `|`.
|
|
langs: {
|
|
ts: (key: string) => {
|
|
const i = key.lastIndexOf('|');
|
|
return i >= 0 ? key.slice(i + 1) : key;
|
|
}
|
|
},
|
|
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
|
createSomaRuntime(morfo, {
|
|
dom,
|
|
translate: (key) => key,
|
|
...sources
|
|
})
|
|
} as unknown as Soma;
|
|
|
|
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
|
vi.spyOn(WaveformProvider.ctx, 'set').mockImplementation((value) => value);
|
|
|
|
return { dom };
|
|
}
|
|
|
|
function waveformOpts(root: HTMLElement) {
|
|
return {
|
|
id: state('waveform'),
|
|
ref: state<HTMLElement | null>(root),
|
|
peaks: state([0, 1]),
|
|
shape: state<'bars' | 'wave'>('bars'),
|
|
value: state([25]),
|
|
min: state(0),
|
|
max: state(100),
|
|
readonly: state(false),
|
|
ariaLabel: state<string | undefined>(undefined)
|
|
};
|
|
}
|
|
|
|
describe('WaveformProvider', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
it('exposes the provider contract and the bar geometry', () => {
|
|
installSomaHarness();
|
|
const root = document.createElement('div');
|
|
const opts = waveformOpts(root);
|
|
|
|
const { result: provider, cleanup } = withEffectRoot(() => WaveformProvider.create(opts));
|
|
|
|
// Bag assert — fase C2c retired the syncAttrs effect (P0, audit 2026-08-26); this test renders nothing.
|
|
expect(provider.props).toMatchObject({
|
|
id: 'waveform',
|
|
'data-waveform': '',
|
|
'data-shape': 'bars'
|
|
});
|
|
// Two peaks → two vertical strokes. Silence paints the perceptual FLOOR
|
|
// (a thin line, the SoundCloud reading), a full peak spans the AMP.
|
|
expect(provider.wavePath).toBe('M25.00 15.5V16.5M75.00 1V31');
|
|
expect(provider.progressFraction).toBe(0.25);
|
|
// The embedded Slider's accessible name falls back to the morfo text.
|
|
expect(provider.resolvedAriaLabel).toBe('Audio waveform');
|
|
|
|
cleanup();
|
|
});
|
|
|
|
it('paints the mirrored area for shape=wave and clamps the fraction', () => {
|
|
installSomaHarness();
|
|
const root = document.createElement('div');
|
|
const opts = { ...waveformOpts(root), shape: state<'bars' | 'wave'>('wave') };
|
|
|
|
const { result: provider, cleanup } = withEffectRoot(() => WaveformProvider.create(opts));
|
|
|
|
// One closed band (area top edge + reversed baseline + Z).
|
|
expect(provider.wavePath.startsWith('M')).toBe(true);
|
|
expect(provider.wavePath.endsWith('Z')).toBe(true);
|
|
|
|
// The fraction clamps to the domain, and a degenerate domain reads 0.
|
|
opts.value.current = [150];
|
|
expect(provider.progressFraction).toBe(1);
|
|
opts.max.current = 0;
|
|
expect(provider.progressFraction).toBe(0);
|
|
|
|
cleanup();
|
|
});
|
|
|
|
it('keeps the Wave presentational: aria-hidden + data-shape from the morfo', () => {
|
|
installSomaHarness();
|
|
const root = document.createElement('div');
|
|
const waveEl = document.createElement('span');
|
|
const opts = waveformOpts(root);
|
|
|
|
const { result, cleanup } = withEffectRoot(() => {
|
|
const provider = WaveformProvider.create(opts);
|
|
vi.spyOn(WaveformProvider, 'require').mockReturnValue(provider);
|
|
const wave = WaveformWaveProvider.create({
|
|
id: state('waveform-wave'),
|
|
ref: state<HTMLElement | null>(waveEl)
|
|
});
|
|
return { wave };
|
|
});
|
|
|
|
expect(result.wave.props).toMatchObject({
|
|
'data-waveform-wave': '',
|
|
'data-shape': 'bars',
|
|
'aria-hidden': 'true'
|
|
});
|
|
|
|
cleanup();
|
|
});
|
|
|
|
it('buildWavePath degrades to an empty (valid) path with no peaks', () => {
|
|
expect(buildWavePath([], 'bars')).toBe('');
|
|
expect(buildWavePath([], 'wave')).toBe('');
|
|
});
|
|
});
|