parent
4b5d9078ed
commit
419ec80890
@ -0,0 +1,286 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { tick } from 'svelte';
|
||||
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 '$soma/components/field/field-provider.svelte';
|
||||
|
||||
import {
|
||||
PinInputCellProvider,
|
||||
PinInputInputProvider,
|
||||
PinInputProvider
|
||||
} from './pin-input-provider.svelte';
|
||||
import type { PinInputCellData } from './types';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
function installSomaHarness(field: FieldProvider | undefined = undefined) {
|
||||
const dom = createActiveDom();
|
||||
const soma = {
|
||||
dom,
|
||||
langs: {
|
||||
ts: vi.fn(() => 'Verification code')
|
||||
},
|
||||
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(field);
|
||||
vi.spyOn(PinInputProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function pinInputOpts(root = document.createElement('div')) {
|
||||
return {
|
||||
id: state('pin-input-root'),
|
||||
ref: state<HTMLElement | null>(root),
|
||||
inputId: state('pin-input-hidden'),
|
||||
value: state('12'),
|
||||
length: state(4),
|
||||
type: state<'numeric' | 'alphanumeric' | 'alphabetic' | 'custom'>('numeric'),
|
||||
pattern: state<string | RegExp | undefined>(undefined),
|
||||
pasteTransformer: state<((text: string) => string) | undefined>(undefined),
|
||||
mask: state(false),
|
||||
placeholder: state(''),
|
||||
disabled: state(false),
|
||||
readonly: state(false),
|
||||
required: state(false),
|
||||
invalid: state(false),
|
||||
name: state<string | undefined>('code'),
|
||||
autocomplete: state<string | undefined>(undefined),
|
||||
inputmode: state<'text' | 'numeric' | 'tel' | 'decimal' | undefined>(undefined),
|
||||
ariaLabel: state<string | undefined>(undefined),
|
||||
onComplete: state<((value: string) => void) | undefined>(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function inputEvent(input: HTMLInputElement) {
|
||||
return {
|
||||
currentTarget: input,
|
||||
preventDefault: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
function pasteEvent(input: HTMLInputElement, text: string) {
|
||||
return {
|
||||
currentTarget: input,
|
||||
preventDefault: vi.fn(),
|
||||
clipboardData: {
|
||||
getData: vi.fn(() => text)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function fakeField() {
|
||||
return {
|
||||
inputId: state(''),
|
||||
get isDisabled() {
|
||||
return false;
|
||||
},
|
||||
get isReadonly() {
|
||||
return false;
|
||||
},
|
||||
get isRequired() {
|
||||
return true;
|
||||
},
|
||||
get isInvalid() {
|
||||
return true;
|
||||
}
|
||||
} as FieldProvider;
|
||||
}
|
||||
|
||||
describe('PinInputProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('resolves input props, OR-merges Field flags and fires onComplete once', async () => {
|
||||
const field = fakeField();
|
||||
const { dom } = installSomaHarness(field);
|
||||
const root = document.createElement('div');
|
||||
const inputEl = document.createElement('input');
|
||||
document.body.append(root, inputEl);
|
||||
const opts = pinInputOpts(root);
|
||||
const onComplete = vi.fn();
|
||||
opts.onComplete.current = onComplete;
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = PinInputProvider.create(opts);
|
||||
vi.spyOn(PinInputProvider, 'require').mockReturnValue(provider);
|
||||
const input = PinInputInputProvider.create({
|
||||
id: state('pin-input-hidden'),
|
||||
ref: state<HTMLInputElement | null>(inputEl)
|
||||
});
|
||||
provider.setInputRef(inputEl);
|
||||
return { provider, input };
|
||||
});
|
||||
|
||||
expect(field.inputId.current).toBe('pin-input-hidden');
|
||||
expect(result.provider.props).toMatchObject({
|
||||
id: 'pin-input-root',
|
||||
'data-pin-input': '',
|
||||
'data-required': '',
|
||||
'data-invalid': ''
|
||||
});
|
||||
expect(result.input.props).toMatchObject({
|
||||
id: 'pin-input-hidden',
|
||||
type: 'text',
|
||||
inputmode: 'numeric',
|
||||
autocomplete: 'one-time-code',
|
||||
maxlength: 4,
|
||||
value: '12',
|
||||
name: 'code',
|
||||
required: true,
|
||||
'aria-label': 'Verification code',
|
||||
'aria-invalid': true
|
||||
});
|
||||
|
||||
inputEl.value = '1234';
|
||||
result.input.props.oninput(inputEvent(inputEl) as never);
|
||||
await Promise.resolve();
|
||||
await tick();
|
||||
expect(opts.value.current).toBe('1234');
|
||||
expect(result.provider.isComplete).toBe(true);
|
||||
expect(onComplete).toHaveBeenCalledOnce();
|
||||
expect(onComplete).toHaveBeenCalledWith('1234');
|
||||
|
||||
inputEl.value = '1234';
|
||||
result.input.props.oninput(inputEvent(inputEl) as never);
|
||||
await tick();
|
||||
expect(onComplete).toHaveBeenCalledOnce();
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('filters input by pattern and distributes transformed paste text', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const root = document.createElement('div');
|
||||
const inputEl = document.createElement('input');
|
||||
document.body.append(root, inputEl);
|
||||
const opts = pinInputOpts(root);
|
||||
opts.pasteTransformer.current = (text) => text.replace(/\D/g, '');
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = PinInputProvider.create(opts);
|
||||
vi.spyOn(PinInputProvider, 'require').mockReturnValue(provider);
|
||||
const input = PinInputInputProvider.create({
|
||||
id: state('pin-input-hidden'),
|
||||
ref: state<HTMLInputElement | null>(inputEl)
|
||||
});
|
||||
provider.setInputRef(inputEl);
|
||||
return { provider, input };
|
||||
});
|
||||
|
||||
inputEl.value = '12a';
|
||||
result.input.props.oninput(inputEvent(inputEl) as never);
|
||||
expect(opts.value.current).toBe('12');
|
||||
expect(inputEl.value).toBe('12');
|
||||
|
||||
inputEl.selectionStart = 1;
|
||||
inputEl.selectionEnd = 2;
|
||||
const paste = pasteEvent(inputEl, '9-8');
|
||||
result.input.props.onpaste(paste as never);
|
||||
expect(paste.preventDefault).toHaveBeenCalledOnce();
|
||||
inputEl.value = opts.value.current;
|
||||
await Promise.resolve();
|
||||
expect(opts.value.current).toBe('198');
|
||||
expect(inputEl.selectionStart).toBe(3);
|
||||
expect(inputEl.selectionEnd).toBe(3);
|
||||
|
||||
inputEl.selectionStart = 0;
|
||||
inputEl.selectionEnd = 0;
|
||||
result.input.props.onpaste(pasteEvent(inputEl, 'abc') as never);
|
||||
expect(opts.value.current).toBe('198');
|
||||
|
||||
opts.disabled.current = true;
|
||||
inputEl.value = '1234';
|
||||
const disabledInput = inputEvent(inputEl);
|
||||
result.input.props.oninput(disabledInput as never);
|
||||
expect(disabledInput.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(opts.value.current).toBe('198');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('tracks focus selection and exposes cell state props', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const root = document.createElement('div');
|
||||
const inputEl = document.createElement('input');
|
||||
const filledCellEl = document.createElement('span');
|
||||
const activeCellEl = document.createElement('span');
|
||||
document.body.append(root, inputEl, filledCellEl, activeCellEl);
|
||||
const opts = pinInputOpts(root);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = PinInputProvider.create(opts);
|
||||
vi.spyOn(PinInputProvider, 'require').mockReturnValue(provider);
|
||||
provider.setInputRef(inputEl);
|
||||
inputEl.value = opts.value.current;
|
||||
provider.onfocus({ currentTarget: inputEl } as never);
|
||||
const filled = PinInputCellProvider.create({
|
||||
id: state('pin-input-cell-0'),
|
||||
ref: state<HTMLElement | null>(filledCellEl),
|
||||
index: state(0),
|
||||
cell: state<PinInputCellData>(provider.cells[0])
|
||||
});
|
||||
const active = PinInputCellProvider.create({
|
||||
id: state('pin-input-cell-2'),
|
||||
ref: state<HTMLElement | null>(activeCellEl),
|
||||
index: state(2),
|
||||
cell: state<PinInputCellData>(provider.cells[2])
|
||||
});
|
||||
return { provider, filled, active };
|
||||
});
|
||||
|
||||
expect(result.provider.focused).toBe(true);
|
||||
expect(result.provider.cells).toEqual([
|
||||
{ char: '1', isActive: false, isFilled: true, hasFakeCaret: false },
|
||||
{ char: '2', isActive: false, isFilled: true, hasFakeCaret: false },
|
||||
{ char: null, isActive: true, isFilled: false, hasFakeCaret: true },
|
||||
{ char: null, isActive: false, isFilled: false, hasFakeCaret: false }
|
||||
]);
|
||||
expect(result.provider.snippetProps).toMatchObject({
|
||||
isFocused: true,
|
||||
value: '12'
|
||||
});
|
||||
expect(result.filled.props).toMatchObject({
|
||||
id: 'pin-input-cell-0',
|
||||
role: 'presentation',
|
||||
'aria-hidden': true,
|
||||
'data-filled': ''
|
||||
});
|
||||
expect(result.active.props).toMatchObject({
|
||||
id: 'pin-input-cell-2',
|
||||
'data-active': ''
|
||||
});
|
||||
expect(result.active.props['data-placeholder']).toBeUndefined();
|
||||
|
||||
result.provider.onblur({ currentTarget: inputEl } as never);
|
||||
expect(result.provider.focused).toBe(false);
|
||||
expect(result.provider.selectionStart).toBeNull();
|
||||
expect(result.provider.props['data-focused']).toBeUndefined();
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue