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