parent
5ebe68d640
commit
eeb168043b
@ -0,0 +1,240 @@
|
||||
// @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 { FieldProvider } from '../field/field-provider.svelte';
|
||||
|
||||
import {
|
||||
NumberFieldDecrementTriggerProvider,
|
||||
NumberFieldIncrementTriggerProvider,
|
||||
NumberFieldInputProvider,
|
||||
NumberFieldProvider,
|
||||
NumberFieldScrubberProvider
|
||||
} from './number-field-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,
|
||||
langs: {
|
||||
getLocale: () => 'de-DE',
|
||||
ts: (key: string) => key
|
||||
},
|
||||
prefs: {
|
||||
getDir: () => 'rtl'
|
||||
},
|
||||
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(FieldProvider, 'get').mockReturnValue(undefined);
|
||||
vi.spyOn(NumberFieldProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function createNumberFieldOpts() {
|
||||
return {
|
||||
id: state('number-field-root'),
|
||||
ref: state<HTMLElement | null>(null),
|
||||
value: state<number | undefined>(undefined),
|
||||
min: state<number | undefined>(undefined),
|
||||
max: state<number | undefined>(undefined),
|
||||
step: state(1),
|
||||
largeStep: state<number | undefined>(undefined),
|
||||
locale: state<string | undefined>(undefined),
|
||||
dir: state<'ltr' | 'rtl' | undefined>(undefined),
|
||||
formatOptions: state<Intl.NumberFormatOptions | undefined>(undefined),
|
||||
clampOnBlur: state(true),
|
||||
allowMouseWheel: state(false),
|
||||
disabled: state(false),
|
||||
readonly: state(false),
|
||||
required: state(false),
|
||||
invalid: state(false),
|
||||
name: state<string | undefined>(undefined),
|
||||
onValueCommit: state<((value: number) => void) | undefined>(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
describe('NumberFieldProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('parses locale edit text, rounds by step and clamps on blur', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createNumberFieldOpts();
|
||||
const onCommit = vi.fn();
|
||||
opts.min.current = 0;
|
||||
opts.max.current = 2;
|
||||
opts.step.current = 0.25;
|
||||
opts.value.current = 1;
|
||||
opts.onValueCommit.current = onCommit;
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => NumberFieldProvider.create(opts));
|
||||
|
||||
provider.handleFocus({} as never);
|
||||
expect(provider.inputValue).toBe('1');
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.value = '2,9';
|
||||
provider.handleInput({ currentTarget: input } as never);
|
||||
provider.handleBlur({} as never);
|
||||
|
||||
expect(opts.value.current).toBe(2);
|
||||
expect(onCommit).toHaveBeenCalledWith(2);
|
||||
expect(provider.inputValue).toBe('2');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('maps keyboard commands and trigger clicks to bounded value changes', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createNumberFieldOpts();
|
||||
opts.min.current = 0;
|
||||
opts.max.current = 10;
|
||||
opts.step.current = 0.5;
|
||||
opts.largeStep.current = 4;
|
||||
opts.value.current = 1;
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => NumberFieldProvider.create(opts));
|
||||
vi.spyOn(NumberFieldProvider.ctx, 'get').mockReturnValue(provider);
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
provider.handleKeydown({
|
||||
key: 'ArrowUp',
|
||||
shiftKey: true,
|
||||
preventDefault
|
||||
} as never);
|
||||
expect(opts.value.current).toBe(5);
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
|
||||
provider.handleKeydown({
|
||||
key: 'End',
|
||||
shiftKey: false,
|
||||
preventDefault
|
||||
} as never);
|
||||
expect(opts.value.current).toBe(10);
|
||||
|
||||
const increment = NumberFieldIncrementTriggerProvider.create({
|
||||
id: state('number-field-increment'),
|
||||
ref: state<HTMLElement | null>(null)
|
||||
});
|
||||
expect(increment.isDisabled).toBe(true);
|
||||
|
||||
const decrement = NumberFieldDecrementTriggerProvider.create({
|
||||
id: state('number-field-decrement'),
|
||||
ref: state<HTMLElement | null>(null)
|
||||
});
|
||||
decrement.handleClick();
|
||||
expect(opts.value.current).toBe(9.5);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('exposes spinbutton props with resolved locale direction and field state', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createNumberFieldOpts();
|
||||
opts.value.current = 3;
|
||||
opts.min.current = 0;
|
||||
opts.max.current = 5;
|
||||
opts.required.current = true;
|
||||
opts.invalid.current = true;
|
||||
opts.name.current = 'amount';
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => NumberFieldProvider.create(opts));
|
||||
vi.spyOn(NumberFieldProvider.ctx, 'get').mockReturnValue(provider);
|
||||
|
||||
const input = NumberFieldInputProvider.create({
|
||||
id: state('number-field-input'),
|
||||
ref: state<HTMLElement | null>(null)
|
||||
});
|
||||
|
||||
expect(provider.dir).toBe('rtl');
|
||||
expect(provider.props.dir).toBe('rtl');
|
||||
expect(input.props).toMatchObject({
|
||||
role: 'spinbutton',
|
||||
inputmode: 'decimal',
|
||||
dir: 'rtl',
|
||||
value: '3',
|
||||
'aria-valuenow': 3,
|
||||
'aria-valuemin': 0,
|
||||
'aria-valuemax': 5,
|
||||
'aria-valuetext': '3',
|
||||
'aria-required': 'true',
|
||||
'aria-invalid': 'true',
|
||||
name: 'amount'
|
||||
});
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('scrubs after the movement buffer and commits the final value once', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createNumberFieldOpts();
|
||||
const onCommit = vi.fn();
|
||||
opts.value.current = 10;
|
||||
opts.step.current = 2;
|
||||
opts.onValueCommit.current = onCommit;
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => NumberFieldProvider.create(opts));
|
||||
vi.spyOn(NumberFieldProvider.ctx, 'get').mockReturnValue(provider);
|
||||
|
||||
const scrubber = NumberFieldScrubberProvider.create({
|
||||
id: state('number-field-scrubber'),
|
||||
ref: state<HTMLElement | null>(null),
|
||||
direction: state<'horizontal' | 'vertical'>('horizontal'),
|
||||
sensitivity: state(5)
|
||||
});
|
||||
const target = { setPointerCapture: vi.fn() };
|
||||
|
||||
scrubber.onpointerdown({
|
||||
button: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
preventDefault: vi.fn()
|
||||
} as never);
|
||||
scrubber.onpointermove({
|
||||
clientX: 15,
|
||||
clientY: 0,
|
||||
pointerId: 1,
|
||||
currentTarget: target,
|
||||
preventDefault: vi.fn()
|
||||
} as never);
|
||||
|
||||
expect(provider.scrubbing).toBe(true);
|
||||
expect(target.setPointerCapture).toHaveBeenCalledWith(1);
|
||||
expect(opts.value.current).toBe(16);
|
||||
|
||||
scrubber.onpointerup();
|
||||
|
||||
expect(provider.scrubbing).toBe(false);
|
||||
expect(onCommit).toHaveBeenCalledOnce();
|
||||
expect(onCommit).toHaveBeenCalledWith(16);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue