test(soma): los primeros providers que no tenían red

13 providers de soma no tenían test, y el guard que dos documentos afirmaban
que lo impedía (`NO_MISSING_PROVIDER_TESTS`) no existe. Van los tres primeros,
cada uno apuntado a la doctrina que el provider codifica y que hoy no cubría
nadie — no a su superficie trivial.

- **`button`** (7): la resolución `color`↔`intent`. Un intent evaluativo gana
  sobre la jerarquía Y suprime el marcador custom (la estética no puede
  contradecir a la semántica). Cubre también que `onPress` devuelve el intent
  como contexto VISUAL, no como carga del evento — el contacto no lleva intent
  (libro cap. 22 §11).
- **`anchor-nav`** (6): la escalera de `activeId` (primero en banda → último
  pasado → primero en orden DOM) y el ref-counting que impide que un remount
  keyed tire un target aún referenciado por su hermano.
- **`textarea`** (9): que el warn de overflow se emita SOLO en la transición
  —la proyección es `untilFix` y persiste, re-emitir sólo la reemplazaría—, su
  retracción al bajar del tope, y la matriz completa de `submitOn`
  (`enter` / `mod+enter` / `false`).

Dos fallaron a la primera y en ambos el código tenía razón y la aserción no:
`data-color-custom` es flag de presencia (el color viaja por la var CSS
`--color-custom`, que estampa eidos), y `clearTarget` no dispara sin la parte
`input` montada porque la retracción apunta a ese elemento.

Arnés: el patrón ya establecido en los 87 tests existentes. Donde hace falta
`langs`, el stub devuelve el fallback embebido del idlangref en vez de copia
inventada — es lo que hace el resolver real cuando no encuentra entrada.

Quedan 10: `chat-list`, `chronos`, `field-langs`, `gradient-builder`,
`gradient-picker`, `month-grid`, `nav-tree`, `password-field`, `sidebar`,
`year-grid`. Al cerrar la lista, escribir el guard que la mantenga cerrada.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 2 months ago
parent f8e35b8fd8
commit feb4a84241

@ -0,0 +1,183 @@
// @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 { AnchorNavLinkProvider, AnchorNavProvider } from './anchor-nav-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: {
// Mirrors the real resolver's miss path: an idlangref carries its own
// fallback after the pipe, so no invented copy enters the assertions.
ts: (ref: string) => ref.split('|')[1] ?? ref
},
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(AnchorNavProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
function navOpts(el: HTMLElement, ariaLabel?: string) {
return {
id: state('anchor-nav'),
ref: state<HTMLElement | null>(el),
topOffset: state(0),
root: state(null),
ariaLabel: state(ariaLabel)
} as never;
}
function linkOpts(el: HTMLElement, href: string, level = 2) {
return {
id: state(`link-${href}`),
ref: state<HTMLElement | null>(el),
href: state(href),
level: state(level)
} as never;
}
function mountNav(ariaLabel?: string) {
const el = document.createElement('nav');
document.body.append(el);
const { result, cleanup } = withEffectRoot(() =>
AnchorNavProvider.create(navOpts(el, ariaLabel))
);
vi.spyOn(AnchorNavProvider, 'get').mockReturnValue(result);
return { el, provider: result, cleanup };
}
describe('AnchorNavProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('falls back to the localized landmark name when no ariaLabel is given', () => {
const { dom } = installSomaHarness();
const explicit = mountNav('Contents');
expect(explicit.provider.resolvedAriaLabel.current).toBe('Contents');
explicit.cleanup();
const implicit = mountNav();
expect(implicit.provider.resolvedAriaLabel.current).toBe('On this page');
implicit.cleanup();
dom.dispose();
});
it('resolves the target id from the href fragment', () => {
const { dom } = installSomaHarness();
const nav = mountNav();
const el = document.createElement('a');
const { result, cleanup } = withEffectRoot(() =>
AnchorNavLinkProvider.create(linkOpts(el, '#usage'))
);
expect(result.targetId).toBe('usage');
cleanup();
nav.cleanup();
dom.dispose();
});
it('falls back to the first registered target before any section intersects', () => {
const { dom } = installSomaHarness();
const nav = mountNav();
// No IntersectionObserver has fired: nothing is in-band and nothing is
// above, so the ladder bottoms out at the first section in DOM order.
nav.provider.registerLink('intro');
nav.provider.registerLink('usage');
nav.provider.registerLink('api');
expect(nav.provider.activeId).toBe('intro');
nav.provider.unregisterLink('intro');
expect(nav.provider.activeId).toBe('usage');
nav.cleanup();
dom.dispose();
});
it('ref-counts duplicate links so one unmount does not drop a live target', () => {
const { dom } = installSomaHarness();
const nav = mountNav();
// Two TOC entries pointing at the same section — or a keyed remount
// where the new link registers before the old one tears down.
nav.provider.registerLink('intro');
nav.provider.registerLink('intro');
nav.provider.registerLink('usage');
nav.provider.unregisterLink('intro');
// Still referenced by the surviving link, so it stays observed and
// remains the first target.
expect(nav.provider.activeId).toBe('intro');
nav.provider.unregisterLink('intro');
expect(nav.provider.activeId).toBe('usage');
nav.cleanup();
dom.dispose();
});
it('ignores an empty target id', () => {
const { dom } = installSomaHarness();
const nav = mountNav();
// A link with a bare `#` href must not register a nameless target.
nav.provider.registerLink('');
expect(nav.provider.activeId).toBe(null);
nav.cleanup();
dom.dispose();
});
it('marks only the link whose target is current', async () => {
const { dom } = installSomaHarness();
const nav = mountNav();
const first = document.createElement('a');
const second = document.createElement('a');
document.body.append(first, second);
const { result, cleanup } = withEffectRoot(() => ({
intro: AnchorNavLinkProvider.create(linkOpts(first, '#intro')),
usage: AnchorNavLinkProvider.create(linkOpts(second, '#usage'))
}));
// Links register from their constructor (A30), so `intro` is first in
// DOM order and wins the fallback.
await tick();
expect(nav.provider.activeId).toBe('intro');
expect(result.intro.active).toBe(true);
expect(result.usage.active).toBe(false);
cleanup();
nav.cleanup();
dom.dispose();
});
});

@ -0,0 +1,199 @@
// @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 '../field';
import { ButtonProvider } from './button-provider.svelte';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
async function flushRuntimeTrigger() {
await Promise.resolve();
await tick();
}
function installSomaHarness() {
const dom = createActiveDom();
const soma = {
dom,
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);
// No enclosing Field in these cases — the provider reads it optionally.
vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined);
return { dom };
}
type ButtonOptsOverrides = {
intent?: string;
color?: string;
colorCustom?: string;
disabled?: boolean;
loading?: boolean;
onPress?: (payload: { intent: string }) => void;
};
function buttonOpts(el: HTMLElement, overrides: ButtonOptsOverrides = {}) {
return {
id: state('button-root'),
ref: state<HTMLElement | null>(el),
intent: state(overrides.intent),
color: state(overrides.color),
colorCustom: state(overrides.colorCustom),
type: state('button'),
disabled: state(overrides.disabled ?? false),
loading: state(overrides.loading ?? false),
loadingText: state(undefined),
spinnerPlacement: state(undefined),
'aria-label': state(undefined),
onPress: state(overrides.onPress)
} as never;
}
function mountButton(overrides: ButtonOptsOverrides = {}) {
const el = document.createElement('button');
document.body.append(el);
const opts = buttonOpts(el, overrides);
const { result, cleanup } = withEffectRoot(() => ButtonProvider.create(opts));
return { el, provider: result, cleanup };
}
describe('ButtonProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('projects identity and defaults the visual colour to primary', async () => {
const { dom } = installSomaHarness();
const { el, provider, cleanup } = mountButton();
expect(provider.props).toMatchObject({ id: 'button-root', 'data-button': '' });
await tick();
// Button's default is PROMOTED — an action is normally the focal point
// of its surface, so absent any prop the visual resolves to 'primary'.
expect(el.getAttribute('data-color')).toBe('primary');
expect(el.hasAttribute('data-disabled')).toBe(false);
expect(el.hasAttribute('data-loading')).toBe(false);
cleanup();
dom.dispose();
});
it('lets an evaluative intent WIN over a hierarchy colour override', async () => {
const { dom } = installSomaHarness();
// Doctrine: aesthetic must not contradict semantic. `color` asks for
// secondary, but the intent is evaluative — the intent takes the visual.
const { el, cleanup } = mountButton({ intent: 'threat', color: 'secondary' });
await tick();
expect(el.getAttribute('data-color')).toBe('threat');
cleanup();
dom.dispose();
});
it('routes a custom colour through data-color-custom and drops data-color', async () => {
const { dom } = installSomaHarness();
// The two markers are mutually exclusive: no canonical role may leak
// into the DOM alongside the custom seed.
const { el, cleanup } = mountButton({ colorCustom: '#3b82f6' });
await tick();
expect(el.hasAttribute('data-color')).toBe(false);
// `data-color-custom` is a PRESENCE flag, not a carrier: the morfo emits
// '' from the truthy prop. The colour itself reaches CSS through the
// `--color-custom` seed, which eidos stamps — soma never handles it.
expect(el.getAttribute('data-color-custom')).toBe('');
cleanup();
dom.dispose();
});
it('suppresses the custom colour when the intent is evaluative', async () => {
const { dom } = installSomaHarness();
// Same doctrine as above, applied to the custom path: an evaluative
// intent wins the visual, so it suppresses the custom marker too.
const { el, cleanup } = mountButton({ intent: 'risk', colorCustom: '#3b82f6' });
await tick();
expect(el.getAttribute('data-color')).toBe('risk');
expect(el.hasAttribute('data-color-custom')).toBe(false);
cleanup();
dom.dispose();
});
it('fires onPress with the visual intent echoed back', async () => {
const { dom } = installSomaHarness();
const onPress = vi.fn();
const { el, provider, cleanup } = mountButton({ intent: 'threat', onPress });
provider.onclick({ currentTarget: el } as never);
await flushRuntimeTrigger();
// The echoed intent is VISUAL context for analytics — the contact
// event itself carries no intent (book cap. 22 §11).
expect(onPress).toHaveBeenCalledWith({ intent: 'threat' });
cleanup();
dom.dispose();
});
it('swallows the press while disabled or loading', async () => {
const { dom } = installSomaHarness();
const disabled = mountButton({ disabled: true, onPress: vi.fn() });
disabled.provider.onclick({ currentTarget: disabled.el } as never);
await flushRuntimeTrigger();
expect(disabled.provider.props.disabled).toBe(true);
expect(disabled.el.getAttribute('data-disabled')).toBe('');
const loading = mountButton({ loading: true, onPress: vi.fn() });
loading.provider.onclick({ currentTarget: loading.el } as never);
await flushRuntimeTrigger();
expect(loading.el.getAttribute('data-loading')).toBe('');
disabled.cleanup();
loading.cleanup();
dom.dispose();
});
it('inherits disabled from an enclosing Field', async () => {
const { dom } = installSomaHarness();
// The Button is not disabled itself; the Field around it is.
vi.spyOn(FieldProvider, 'get').mockReturnValue({
isDisabled: true
} as unknown as FieldProvider);
const { el, provider, cleanup } = mountButton();
await tick();
expect(provider.isDisabled).toBe(true);
expect(el.getAttribute('data-disabled')).toBe('');
cleanup();
dom.dispose();
});
});

@ -0,0 +1,286 @@
// @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';
import { TextAreaInputProvider, TextAreaProvider } from './textarea-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: {
// An idlangref carries its own fallback after the pipe — mirrors the
// real resolver's miss path instead of inventing copy.
ts: (ref: string) => ref.split('|')[1] ?? ref,
t: (ref: string) => ref.split('|')[1] ?? ref
},
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(TextAreaProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
type Overrides = {
value?: string;
maxLength?: number;
autosize?: boolean;
resize?: string;
submitOn?: string | false;
disabled?: boolean;
readonly?: boolean;
onValueChange?: (v: string) => void;
onValueCommit?: (v: string) => void;
};
function mountTextArea(overrides: Overrides = {}) {
const el = document.createElement('div');
document.body.append(el);
const opts = {
id: state('textarea-root'),
ref: state<HTMLElement | null>(el),
inputId: state('textarea-input'),
value: state(overrides.value ?? ''),
rows: state(3),
autosize: state(overrides.autosize ?? false),
minRows: state(1),
maxRows: state(undefined),
resize: state(overrides.resize),
submitOn: state(overrides.submitOn ?? false),
maxLength: state(overrides.maxLength),
disabled: state(overrides.disabled ?? false),
readonly: state(overrides.readonly ?? false),
required: state(false),
invalid: state(false),
name: state(undefined),
placeholder: state(undefined),
ariaLabel: state(undefined),
onValueChange: state(overrides.onValueChange),
onValueCommit: state(overrides.onValueCommit)
} as never;
const { result, cleanup } = withEffectRoot(() => TextAreaProvider.create(opts));
vi.spyOn(TextAreaProvider, 'require').mockReturnValue(result);
return { el, provider: result, opts: opts as never as { value: { current: string } }, cleanup };
}
describe('TextAreaProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('clamps to maxLength and warns only on the TRANSITION into overflow', async () => {
const { dom } = installSomaHarness();
const { provider, opts, cleanup } = mountTextArea({ maxLength: 5 });
const trigger = vi.spyOn(provider.runtime, 'trigger');
provider.commit('abcdefgh');
expect(opts.value.current).toBe('abcde');
expect(provider.isOverflow).toBe(true);
expect(trigger).toHaveBeenCalledTimes(1);
expect(trigger.mock.calls[0]![0]).toBe('signal-warn-count-overflow');
// Still over the cap — the `untilFix` projection persists, so re-emitting
// would only replace it for no gain.
provider.commit('abcdefghij');
expect(trigger).toHaveBeenCalledTimes(1);
cleanup();
dom.dispose();
});
it('clears the persistent warn when the value drops back under the cap', async () => {
const { dom } = installSomaHarness();
const { provider, cleanup } = mountTextArea({ maxLength: 5 });
// The retraction targets the INPUT element, so the part must be mounted
// for `partRef('input')` to resolve — without it there is nothing to
// clear and the provider correctly does nothing.
const inputEl = document.createElement('textarea');
document.body.append(inputEl);
const input = withEffectRoot(() =>
TextAreaInputProvider.create({
id: state('textarea-input'),
ref: state<HTMLElement | null>(inputEl)
} as never)
);
provider.commit('abcdefgh');
expect(provider.isOverflow).toBe(true);
const clearTarget = vi.spyOn(provider.runtime, 'clearTarget');
provider.commit('abc');
expect(provider.isOverflow).toBe(false);
// The warn is `untilFix`, so leaving overflow must actively retract it.
expect(clearTarget).toHaveBeenCalledWith(inputEl);
input.cleanup();
cleanup();
dom.dispose();
});
it('refuses to commit while disabled or readonly', () => {
const { dom } = installSomaHarness();
const disabled = mountTextArea({ disabled: true, value: 'kept' });
disabled.provider.commit('changed');
expect(disabled.opts.value.current).toBe('kept');
disabled.cleanup();
const readonly = mountTextArea({ readonly: true, value: 'kept' });
readonly.provider.commit('changed');
expect(readonly.opts.value.current).toBe('kept');
readonly.cleanup();
dom.dispose();
});
it('suppresses the resize handle while autosize owns the height', () => {
const { dom } = installSomaHarness();
// Browser resize would fight the scrollHeight measurement.
const auto = mountTextArea({ autosize: true, resize: 'both' });
expect(auto.provider.resolvedResize).toBe('none');
auto.cleanup();
const manual = mountTextArea({ resize: 'both' });
expect(manual.provider.resolvedResize).toBe('both');
manual.cleanup();
const fallback = mountTextArea();
expect(fallback.provider.resolvedResize).toBe('vertical');
fallback.cleanup();
dom.dispose();
});
it('notifies onValueChange only when the clamped value actually moves', () => {
const { dom } = installSomaHarness();
const onValueChange = vi.fn();
const { provider, cleanup } = mountTextArea({ value: 'same', onValueChange });
provider.commit('same');
expect(onValueChange).not.toHaveBeenCalled();
provider.commit('other');
expect(onValueChange).toHaveBeenCalledExactlyOnceWith('other');
cleanup();
dom.dispose();
});
it('clears the value, drops overflow and refocuses the input', () => {
const { dom } = installSomaHarness();
const onValueChange = vi.fn();
const { provider, opts, cleanup } = mountTextArea({ maxLength: 3, onValueChange });
provider.commit('abcdef');
expect(provider.isOverflow).toBe(true);
provider.clear();
expect(opts.value.current).toBe('');
expect(provider.isOverflow).toBe(false);
expect(onValueChange).toHaveBeenLastCalledWith('');
cleanup();
dom.dispose();
});
});
describe('TextAreaInputProvider — submit modes', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
function mountInput(submitOn: string | false, onValueCommit = vi.fn()) {
const parent = mountTextArea({ submitOn, value: 'draft', onValueCommit });
const el = document.createElement('textarea');
document.body.append(el);
const { result, cleanup } = withEffectRoot(() =>
TextAreaInputProvider.create({
id: state('textarea-input'),
ref: state<HTMLElement | null>(el)
} as never)
);
return { input: result, onValueCommit, cleanup: () => (cleanup(), parent.cleanup()) };
}
function enter(mods: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } = {}) {
return {
key: 'Enter',
shiftKey: false,
ctrlKey: false,
metaKey: false,
preventDefault: vi.fn(),
...mods
} as never;
}
it('submits on bare Enter only in `enter` mode', () => {
const { dom } = installSomaHarness();
const { input, onValueCommit, cleanup } = mountInput('enter');
input.handleKeydown(enter());
expect(onValueCommit).toHaveBeenCalledWith('draft');
// A newline must stay a newline when the user asks for one.
onValueCommit.mockClear();
input.handleKeydown(enter({ shiftKey: true }));
expect(onValueCommit).not.toHaveBeenCalled();
cleanup();
dom.dispose();
});
it('submits on mod+Enter regardless of shift in `mod+enter` mode', () => {
const { dom } = installSomaHarness();
const { input, onValueCommit, cleanup } = mountInput('mod+enter');
input.handleKeydown(enter());
expect(onValueCommit).not.toHaveBeenCalled();
input.handleKeydown(enter({ metaKey: true }));
expect(onValueCommit).toHaveBeenCalledWith('draft');
cleanup();
dom.dispose();
});
it('never submits when submitOn is false', () => {
const { dom } = installSomaHarness();
const { input, onValueCommit, cleanup } = mountInput(false);
input.handleKeydown(enter());
input.handleKeydown(enter({ metaKey: true }));
input.handleKeydown(enter({ shiftKey: true }));
expect(onValueCommit).not.toHaveBeenCalled();
cleanup();
dom.dispose();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.