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.
svelte-kit-vice/src/uix/soma/components/words/words-provider.svelte.test.ts

590 lines
18 KiB

// @vitest-environment jsdom
import { flushSync, 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 '../field/field-provider.svelte';
import {
WordsCommandButtonProvider,
WordsContentProvider,
WordsHiddenInputProvider,
WordsProvider
} from './words-provider.svelte';
import type { WordsCommandName } from './types';
import {
createEmptyWordsDocument,
createList,
createListItem,
createParagraph,
createText,
renderWordsPlainText,
type WordsDocument,
type WordsSelection
} from './engine';
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 logger = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn()
};
const soma = {
dom,
logger,
langs: {
ts: vi.fn((key: string) => 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(WordsProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined);
return { dom, logger };
}
function wordsOpts(root = document.createElement('div'), value = createEmptyWordsDocument()) {
return {
id: state('words-root'),
ref: state<HTMLElement | null>(root),
contentId: state('words-content'),
value: state<WordsDocument>(value),
selection: state<WordsSelection | null>(null),
placeholder: state<string | undefined>('Start writing'),
name: state<string | undefined>('body'),
disabled: state(false),
readonly: state(false),
required: state(false),
invalid: state(false),
commitOnBlur: state(true),
ariaLabel: state<string | undefined>(undefined),
onValueChange: state<((value: WordsDocument) => void) | undefined>(undefined),
onSelectionChange: state<((value: WordsSelection | null) => void) | undefined>(undefined),
onValueCommit: state<
((value: WordsDocument, reason: 'programmatic' | 'blur' | 'button') => void) | undefined
>(undefined)
};
}
function mountRenderedSelection(root: HTMLElement, offset = 0) {
const text = root.querySelector('[data-words-node="text"]')?.firstChild;
if (!text) throw new Error('Words text node not rendered');
const range = document.createRange();
range.setStart(text, offset);
range.collapse(true);
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function mountRenderedRange(root: HTMLElement, start: number, end: number) {
const text = root.querySelector('[data-words-node="text"]')?.firstChild;
if (!text) throw new Error('Words text node not rendered');
const range = document.createRange();
range.setStart(text, start);
range.setEnd(text, end);
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
describe('WordsProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('projects provider/content attrs and exposes serialized hidden input', async () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const contentEl = document.createElement('div');
const inputEl = document.createElement('input');
document.body.append(root, contentEl, inputEl);
const opts = wordsOpts(root, {
version: 1,
children: [createParagraph([createText('hello')])]
});
opts.required.current = true;
opts.invalid.current = true;
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const content = WordsContentProvider.create({
id: state('words-content'),
ref: state<HTMLElement | null>(contentEl)
});
const hidden = WordsHiddenInputProvider.create({
id: state('words-hidden'),
ref: state<HTMLElement | null>(inputEl)
});
provider.setContentRef(contentEl);
return { provider, content, hidden };
});
expect(result.provider.props).toMatchObject({
id: 'words-root',
'data-words': ''
});
expect(result.content.props).toMatchObject({
id: 'words-content',
'data-words-content': '',
role: 'textbox',
contenteditable: 'true',
'aria-multiline': 'true',
'aria-invalid': true
});
expect(result.hidden.props).toMatchObject({
id: 'words-hidden',
'data-words-hidden-input': '',
type: 'hidden',
name: 'body'
});
expect(result.hidden.props.value).toContain('hello');
await tick();
expect(root.getAttribute('data-required')).toBe('');
expect(root.getAttribute('data-invalid')).toBe('');
expect(root.hasAttribute('data-empty')).toBe(false);
expect(contentEl.getAttribute('data-block')).toBe('paragraph');
cleanup();
dom.dispose();
});
it('maps DOM selection plus beforeinput into document edits', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const contentEl = document.createElement('div');
document.body.append(root, contentEl);
const opts = wordsOpts(root);
const onValueChange = vi.fn();
opts.onValueChange.current = onValueChange;
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const content = WordsContentProvider.create({
id: state('words-content'),
ref: state<HTMLElement | null>(contentEl)
});
provider.setContentRef(contentEl);
return { provider, content };
});
contentEl.innerHTML = result.provider.html;
mountRenderedSelection(contentEl, 0);
result.content.props.onbeforeinput({
inputType: 'insertText',
data: 'Hi',
isComposing: false,
currentTarget: contentEl,
preventDefault: vi.fn()
} as never);
expect(renderWordsPlainText(opts.value.current)).toBe('Hi');
expect(onValueChange).toHaveBeenCalledOnce();
expect(result.provider.canUndo).toBe(true);
result.provider.undo(contentEl);
expect(renderWordsPlainText(opts.value.current)).toBe('');
expect(result.provider.canRedo).toBe(true);
cleanup();
dom.dispose();
});
it('groups contiguous typing beforeinput events into one undo snapshot', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const contentEl = document.createElement('div');
document.body.append(root, contentEl);
const opts = wordsOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const content = WordsContentProvider.create({
id: state('words-content'),
ref: state<HTMLElement | null>(contentEl)
});
provider.setContentRef(contentEl);
return { provider, content };
});
contentEl.innerHTML = result.provider.html;
mountRenderedSelection(contentEl, 0);
result.content.props.onbeforeinput({
inputType: 'insertText',
data: 'A',
isComposing: false,
currentTarget: contentEl,
preventDefault: vi.fn()
} as never);
contentEl.innerHTML = result.provider.html;
mountRenderedSelection(contentEl, 1);
result.content.props.onbeforeinput({
inputType: 'insertText',
data: 'B',
isComposing: false,
currentTarget: contentEl,
preventDefault: vi.fn()
} as never);
expect(renderWordsPlainText(opts.value.current)).toBe('AB');
expect(result.provider.canUndo).toBe(true);
result.provider.undo(contentEl);
expect(renderWordsPlainText(opts.value.current)).toBe('');
expect(result.provider.canUndo).toBe(false);
cleanup();
dom.dispose();
});
it('commits IME composition text through the provider', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const contentEl = document.createElement('div');
document.body.append(root, contentEl);
const opts = wordsOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const content = WordsContentProvider.create({
id: state('words-content'),
ref: state<HTMLElement | null>(contentEl)
});
provider.setContentRef(contentEl);
return { provider, content };
});
contentEl.innerHTML = result.provider.html;
mountRenderedSelection(contentEl, 0);
result.content.props.oncompositionstart({ data: 'a' } as never);
result.content.props.oncompositionupdate({ data: 'あ' } as never);
result.content.props.oncompositionend({ data: 'あ' } as never);
expect(renderWordsPlainText(opts.value.current)).toBe('あ');
expect(result.provider.composition.active).toBe(false);
cleanup();
dom.dispose();
});
it('exposes explicit import and clean export helpers', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
document.body.append(root);
const opts = wordsOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
return { provider };
});
result.provider.snippetProps.importContent('Alpha\nBeta', 'text');
expect(renderWordsPlainText(opts.value.current)).toBe('Alpha\nBeta');
expect(result.provider.snippetProps.exportContent('html')).toBe('<p>Alpha</p><p>Beta</p>');
expect(result.provider.snippetProps.exportContent('html')).not.toContain('data-words');
cleanup();
dom.dispose();
});
it('drives command buttons from active marks and history state', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const buttonEl = document.createElement('button');
document.body.append(root, buttonEl);
const opts = wordsOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const bold = WordsCommandButtonProvider.create({
id: state('words-bold'),
ref: state<HTMLElement | null>(buttonEl),
command: state<WordsCommandName>('bold'),
level: state<1 | 2 | 3 | undefined>(undefined),
listKind: state<'ordered' | 'unordered' | 'check' | undefined>(undefined)
});
return { provider, bold };
});
expect(result.bold.props).toMatchObject({
type: 'button',
'data-command': 'bold',
'aria-pressed': false
});
result.bold.props.onclick({ currentTarget: buttonEl } as never);
expect(result.provider.activeMarks).toEqual(['bold']);
expect(result.bold.props['aria-pressed']).toBe(true);
cleanup();
dom.dispose();
});
it('syncs a DOM range selection and formats that range from a toolbar command', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const contentEl = document.createElement('div');
const buttonEl = document.createElement('button');
document.body.append(root, contentEl, buttonEl);
const opts = wordsOpts(root, {
version: 1,
children: [createParagraph([createText('Hello')])]
});
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const content = WordsContentProvider.create({
id: state('words-content'),
ref: state<HTMLElement | null>(contentEl)
});
const bold = WordsCommandButtonProvider.create({
id: state('words-bold'),
ref: state<HTMLElement | null>(buttonEl),
command: state<WordsCommandName>('bold'),
level: state<1 | 2 | 3 | undefined>(undefined),
listKind: state<'ordered' | 'unordered' | 'check' | undefined>(undefined)
});
provider.setContentRef(contentEl);
return { provider, content, bold };
});
contentEl.innerHTML = result.provider.html;
mountRenderedRange(contentEl, 1, 4);
result.content.props.onselect(new Event('select') as never);
expect(result.provider.selection).toEqual({
anchor: { path: [0, 0], offset: 1 },
focus: { path: [0, 0], offset: 4 }
});
result.bold.props.onclick({ currentTarget: buttonEl } as never);
expect(result.provider.exportContent('html')).toBe('<p>H<strong>ell</strong>o</p>');
cleanup();
dom.dispose();
});
it('activates only the command button matching the current list kind', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const unorderedEl = document.createElement('button');
const orderedEl = document.createElement('button');
const checkEl = document.createElement('button');
document.body.append(root, unorderedEl, orderedEl, checkEl);
const opts = wordsOpts(root, {
version: 1,
children: [createList('ordered', [createListItem([createText('Item')])])]
});
opts.selection.current = {
anchor: { path: [0, 0, 0], offset: 0 },
focus: { path: [0, 0, 0], offset: 0 }
};
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const unordered = WordsCommandButtonProvider.create({
id: state('words-unordered'),
ref: state<HTMLElement | null>(unorderedEl),
command: state<WordsCommandName>('unordered-list'),
level: state<1 | 2 | 3 | undefined>(undefined),
listKind: state<'ordered' | 'unordered' | 'check' | undefined>('unordered')
});
const ordered = WordsCommandButtonProvider.create({
id: state('words-ordered'),
ref: state<HTMLElement | null>(orderedEl),
command: state<WordsCommandName>('ordered-list'),
level: state<1 | 2 | 3 | undefined>(undefined),
listKind: state<'ordered' | 'unordered' | 'check' | undefined>('ordered')
});
const check = WordsCommandButtonProvider.create({
id: state('words-check'),
ref: state<HTMLElement | null>(checkEl),
command: state<WordsCommandName>('check-list'),
level: state<1 | 2 | 3 | undefined>(undefined),
listKind: state<'ordered' | 'unordered' | 'check' | undefined>('check')
});
return { unordered, ordered, check };
});
expect(result.unordered.props['aria-pressed']).toBe(false);
expect(result.ordered.props['aria-pressed']).toBe(true);
expect(result.check.props['aria-pressed']).toBe(false);
cleanup();
dom.dispose();
});
it('syncs external value replacements without emitting user change callbacks', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
document.body.append(root);
const opts = wordsOpts(root, {
version: 1,
children: [createParagraph([createText('Initial')])]
});
const onValueChange = vi.fn();
opts.onValueChange.current = onValueChange;
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
return { provider };
});
onValueChange.mockClear();
opts.value.current = {
version: 1,
children: [createParagraph([createText('External')])]
};
flushSync();
expect(renderWordsPlainText(result.provider.document)).toBe('External');
expect(result.provider.dirty).toBe(false);
expect(result.provider.canUndo).toBe(false);
expect(onValueChange).not.toHaveBeenCalled();
cleanup();
dom.dispose();
});
it('syncs external selection changes without echoing onSelectionChange', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
document.body.append(root);
const opts = wordsOpts(root, {
version: 1,
children: [createParagraph([createText('Hello')])]
});
const onSelectionChange = vi.fn();
opts.onSelectionChange.current = onSelectionChange;
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
return { provider };
});
onSelectionChange.mockClear();
opts.selection.current = {
anchor: { path: [0, 0], offset: 2 },
focus: { path: [0, 0], offset: 2 }
};
flushSync();
expect(result.provider.selection).toEqual({
anchor: { path: [0, 0], offset: 2 },
focus: { path: [0, 0], offset: 2 }
});
expect(result.provider.activeMarks).toEqual([]);
expect(onSelectionChange).not.toHaveBeenCalled();
cleanup();
dom.dispose();
});
it('logs unsupported paste payloads through the UIX logger', () => {
const { dom, logger } = installSomaHarness();
const root = document.createElement('div');
const contentEl = document.createElement('div');
document.body.append(root, contentEl);
const opts = wordsOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const content = WordsContentProvider.create({
id: state('words-content'),
ref: state<HTMLElement | null>(contentEl)
});
provider.setContentRef(contentEl);
return { content };
});
result.content.props.onpaste({
currentTarget: contentEl,
clipboardData: {
getData: (type: string) => (type === 'text/html' ? '<p>unsafe</p>' : '')
},
preventDefault: vi.fn()
} as never);
expect(logger.warn).toHaveBeenCalledWith(
'soma.words',
'Ignored unsupported editor input: unsupported-html',
expect.objectContaining({ context: { reason: 'unsupported-html' } })
);
cleanup();
dom.dispose();
});
it('does not cancel native beforeinput when the engine does not own the input type', () => {
const { dom, logger } = installSomaHarness();
const root = document.createElement('div');
const contentEl = document.createElement('div');
document.body.append(root, contentEl);
const opts = wordsOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const content = WordsContentProvider.create({
id: state('words-content'),
ref: state<HTMLElement | null>(contentEl)
});
provider.setContentRef(contentEl);
return { content };
});
const preventDefault = vi.fn();
result.content.props.onbeforeinput({
inputType: 'insertFromYank',
data: null,
isComposing: false,
currentTarget: contentEl,
preventDefault
} as never);
expect(preventDefault).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
cleanup();
dom.dispose();
});
});

Powered by TurnKey Linux.