test(soma): field-langs y gradient-picker — el invariante, no la superficie

Dos providers más de los 13 sin red. Como los anteriores, apuntados a lo que
cada uno decide y nadie comprobaba:

- **`field-langs`** (8): la resolución de `switcher: 'auto'` por número de
  idiomas (segmentado hasta 3, `<select>` desde 4, y un modo explícito nunca se
  reinterpreta); el guardado de `activeLang` contra el conjunto vigente — sin él
  un idioma retirado deja el editor sin dirección; que un valor de sólo espacios
  cuente como incompleto e invalide el campo si ese idioma era requerido; y que
  los proxies `activeValue` / `activeLangArray` escriban en el hueco correcto,
  incluido que deseleccionar en el ToggleGroup NO deje el editor sin idioma.
- **`gradient-picker`** (7): que `css` serialice el gradiente COMPROMETIDO y no
  el de trabajo — es lo que hace que el chip del trigger no se mueva hasta
  Aceptar en modo diferido; que `showDelete` exija que el preset guardado se
  haya SELECCIONADO y no sólo que coincida con el valor; el tope y el rechazo de
  duplicados en `canSave`; y que el provider dispare él mismo `commit-save` /
  `commit-remove`, porque el Button sólo emite el `contact.activate` neutro.

Los dos fallos de `gradient-picker` en la primera pasada eran de la aserción, no
del código: `$state` devuelve un proxy profundo, así que comparar gradientes con
`toBe` falla por identidad. Comparados por valor.

Quedan 8: `chat-list`, `chronos`, `gradient-builder`, `month-grid`, `nav-tree`,
`password-field`, `sidebar`, `year-grid`.

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

@ -0,0 +1,222 @@
// @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 { FieldLangsProvider } from './field-langs-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,
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(FieldLangsProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
type Overrides = {
languages?: unknown[];
values?: Record<string, string>;
activeLang?: string;
required?: string[];
switcher?: string;
invalid?: boolean;
};
function mountFieldLangs(overrides: Overrides = {}) {
const el = document.createElement('div');
document.body.append(el);
const opts = {
id: state('field-langs'),
ref: state<HTMLElement | null>(el),
values: state(overrides.values ?? {}),
activeLang: state(overrides.activeLang ?? ''),
languages: state(overrides.languages ?? ['es', 'en']),
required: state(overrides.required ?? []),
switcherSide: state('start'),
switcher: state(overrides.switcher ?? 'auto'),
disabled: state(false),
readonly: state(false),
invalid: state(overrides.invalid ?? false)
} as never;
const { result, cleanup } = withEffectRoot(() => FieldLangsProvider.create(opts));
return {
el,
provider: result,
opts: opts as never as {
values: { current: Record<string, string> };
activeLang: { current: string };
},
cleanup
};
}
describe('FieldLangsProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it("resolves switcher 'auto' by language count, segmented up to three", () => {
const { dom } = installSomaHarness();
const three = mountFieldLangs({ languages: ['es', 'en', 'fr'] });
expect(three.provider.switcherKind).toBe('segmented');
three.cleanup();
// Four chips would crowd the control — 'auto' hands over to the native select.
const four = mountFieldLangs({ languages: ['es', 'en', 'fr', 'de'] });
expect(four.provider.switcherKind).toBe('select');
four.cleanup();
// An explicit mode is never second-guessed.
const forced = mountFieldLangs({ languages: ['es', 'en', 'fr', 'de'], switcher: 'segmented' });
expect(forced.provider.switcherKind).toBe('segmented');
forced.cleanup();
dom.dispose();
});
it('guards activeLang to a member of the language set', () => {
const { dom } = installSomaHarness();
// Unset, and pointing at a language that was removed from the set: both
// fall back to the first rather than leaving the editor unaddressed.
const unset = mountFieldLangs({ languages: ['es', 'en'] });
expect(unset.provider.activeLang).toBe('es');
unset.cleanup();
const stale = mountFieldLangs({ languages: ['es', 'en'], activeLang: 'ja' });
expect(stale.provider.activeLang).toBe('es');
stale.cleanup();
dom.dispose();
});
it('accepts a language as a bare code or as a spec object', () => {
const { dom } = installSomaHarness();
const { provider, cleanup } = mountFieldLangs({
languages: ['es', { code: 'ar', label: 'العربية', dir: 'rtl' }]
});
expect(provider.langCodes).toEqual(['es', 'ar']);
expect(provider.langSpecs[1]).toMatchObject({ code: 'ar', dir: 'rtl' });
cleanup();
dom.dispose();
});
it('counts a whitespace-only value as incomplete and invalidates a missing required lang', () => {
const { dom } = installSomaHarness();
const { provider, cleanup } = mountFieldLangs({
languages: ['es', 'en'],
required: ['es', 'en'],
values: { es: 'Hola', en: ' ' }
});
expect(provider.isComplete('es')).toBe(true);
expect(provider.isComplete('en')).toBe(false);
expect(provider.missingRequired).toEqual(['en']);
expect(provider.isInvalid).toBe(true);
expect(provider.hasRequired).toBe(true);
cleanup();
dom.dispose();
});
it('is valid once every required language is filled, and ignores non-required gaps', () => {
const { dom } = installSomaHarness();
const { provider, cleanup } = mountFieldLangs({
languages: ['es', 'en', 'fr'],
required: ['es'],
values: { es: 'Hola' }
});
expect(provider.missingRequired).toEqual([]);
expect(provider.isInvalid).toBe(false);
// `fr` empty is fine — it was never required.
expect(provider.isComplete('fr')).toBe(false);
cleanup();
dom.dispose();
});
it('routes the activeValue proxy into the map slot of the active language', () => {
const { dom } = installSomaHarness();
const { provider, opts, cleanup } = mountFieldLangs({
languages: ['es', 'en'],
values: { es: 'Hola' }
});
expect(provider.activeValue.current).toBe('Hola');
provider.activeValue.current = 'Adiós';
expect(opts.values.current).toEqual({ es: 'Adiós' });
// Switching language re-points the same proxy at the other slot.
provider.setActiveLang('en');
expect(provider.activeValue.current).toBe('');
provider.activeValue.current = 'Bye';
expect(opts.values.current).toEqual({ es: 'Adiós', en: 'Bye' });
cleanup();
dom.dispose();
});
it('exposes the active language as a single-item array for the ToggleGroup', () => {
const { dom } = installSomaHarness();
const { provider, cleanup } = mountFieldLangs({ languages: ['es', 'en'] });
expect(provider.activeLangArray.current).toEqual(['es']);
provider.activeLangArray.current = ['en'];
expect(provider.activeLang).toBe('en');
// A deselect (empty array) must not blank the editor — there is always
// exactly one language being edited.
provider.activeLangArray.current = [];
expect(provider.activeLang).toBe('en');
cleanup();
dom.dispose();
});
it('signals shift-navigate only on a real language change', () => {
const { dom } = installSomaHarness();
const { provider, cleanup } = mountFieldLangs({ languages: ['es', 'en'] });
const trigger = vi.spyOn(provider.runtime, 'trigger');
provider.setActiveLang('es'); // already active
provider.setActiveLang('ja'); // not in the set
expect(trigger).not.toHaveBeenCalled();
provider.setActiveLang('en');
expect(trigger).toHaveBeenCalledExactlyOnceWith('shift-navigate');
cleanup();
dom.dispose();
});
});

@ -0,0 +1,226 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import { gradientToCss, type Gradient } from '$libs/gradient';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { GradientPickerProvider } from './gradient-picker-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,
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(GradientPickerProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
function linear(from: string, to: string, angle = 90): Gradient {
return {
kind: 'linear',
angle,
stops: [
{ color: { kind: 'css', value: from } },
{ color: { kind: 'css', value: to } }
]
};
}
const BLACK_WHITE = linear('#000000', '#ffffff');
const RED_BLUE = linear('#ff0000', '#0000ff');
const GREEN_TEAL = linear('#00ff00', '#008080');
function mountPicker(
overrides: { value?: Gradient; committed?: Gradient; presets?: Gradient[]; maxPresets?: number } = {}
) {
const el = document.createElement('div');
document.body.append(el);
const opts = {
id: state('gradient-picker'),
ref: state<HTMLElement | null>(el),
value: state(overrides.value ?? BLACK_WHITE),
open: state(false),
committedValue: state(overrides.committed ?? overrides.value ?? BLACK_WHITE),
disabled: state(false),
presets: state(overrides.presets),
maxPresets: state(overrides.maxPresets ?? 8)
} as never;
const { result, cleanup } = withEffectRoot(() => GradientPickerProvider.create(opts));
return {
provider: result,
opts: opts as never as {
value: { current: Gradient };
committedValue: { current: Gradient };
},
cleanup
};
}
describe('GradientPickerProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('serializes the COMMITTED gradient, not the working one', () => {
const { dom } = installSomaHarness();
// This IS `deferValue`: the editor mutates the working value while the
// trigger chip must keep showing what is actually committed.
const { provider, opts, cleanup } = mountPicker({
value: BLACK_WHITE,
committed: RED_BLUE
});
expect(provider.css).toBe(gradientToCss(RED_BLUE));
// Compared by value, not identity: `$state` hands back a deep proxy.
expect(provider.value).toStrictEqual(BLACK_WHITE);
// Editing the working value must not move the chip.
provider.setValue(GREEN_TEAL);
expect(provider.css).toBe(gradientToCss(RED_BLUE));
// Accepting does.
opts.committedValue.current = GREEN_TEAL;
expect(provider.css).toBe(gradientToCss(GREEN_TEAL));
cleanup();
dom.dispose();
});
it('refuses to save a gradient already present in presets or in the saved list', () => {
const { dom } = installSomaHarness();
const { provider, cleanup } = mountPicker({ value: RED_BLUE, presets: [RED_BLUE] });
// Already in the read-only palette.
expect(provider.canSave).toBe(false);
provider.setValue(GREEN_TEAL);
expect(provider.canSave).toBe(true);
provider.savePreset();
expect(provider.savedPresets).toHaveLength(1);
// Now it is in the saved list — no duplicate.
expect(provider.canSave).toBe(false);
cleanup();
dom.dispose();
});
it('stops saving at the cap', () => {
const { dom } = installSomaHarness();
const { provider, cleanup } = mountPicker({ value: RED_BLUE, maxPresets: 1 });
provider.savePreset();
expect(provider.savedPresets).toHaveLength(1);
provider.setValue(GREEN_TEAL);
expect(provider.canSave).toBe(false);
provider.savePreset();
expect(provider.savedPresets).toHaveLength(1);
cleanup();
dom.dispose();
});
it('fires the evaluative commit itself on save and on delete', () => {
const { dom } = installSomaHarness();
const { provider, cleanup } = mountPicker({ value: RED_BLUE });
const trigger = vi.spyOn(provider.runtime, 'trigger');
// The Button press only emits the neutral `contact.activate`; the "saved"
// / "removed" cue is the provider's to fire.
provider.savePreset();
expect(trigger).toHaveBeenCalledExactlyOnceWith('commit-save');
trigger.mockClear();
provider.deletePreset(0);
expect(trigger).toHaveBeenCalledExactlyOnceWith('commit-remove');
cleanup();
dom.dispose();
});
it('shows delete only when a saved preset was explicitly SELECTED, not merely matched', () => {
const { dom } = installSomaHarness();
const { provider, cleanup } = mountPicker({ value: RED_BLUE });
provider.savePreset();
// The value equals savedPresets[0], but the user never clicked it.
provider.selectedSaved = null;
expect(provider.showDelete).toBe(false);
provider.selectSaved(0);
expect(provider.showDelete).toBe(true);
// Editing away from the selected preset retracts the delete affordance.
provider.setValue(GREEN_TEAL);
expect(provider.showDelete).toBe(false);
cleanup();
dom.dispose();
});
it('applying a preset sets the working value and records whether it was a saved one', () => {
const { dom } = installSomaHarness();
const { provider, cleanup } = mountPicker({ value: BLACK_WHITE, presets: [RED_BLUE] });
// A read-only palette preset carries no saved index.
provider.applyPreset(RED_BLUE, null);
expect(provider.value).toStrictEqual(RED_BLUE);
expect(provider.selectedSaved).toBe(null);
provider.setValue(GREEN_TEAL);
provider.savePreset();
provider.applyPreset(GREEN_TEAL, 0);
expect(provider.selectedSaved).toBe(0);
cleanup();
dom.dispose();
});
it('deleting clears the selection and defaults to the selected index', () => {
const { dom } = installSomaHarness();
const { provider, cleanup } = mountPicker({ value: RED_BLUE });
provider.savePreset();
provider.setValue(GREEN_TEAL);
provider.savePreset();
expect(provider.savedPresets).toHaveLength(2);
provider.selectSaved(0);
provider.deletePreset(); // no argument → the selected one
expect(provider.savedPresets).toHaveLength(1);
expect(gradientToCss(provider.savedPresets[0]!)).toBe(gradientToCss(GREEN_TEAL));
expect(provider.selectedSaved).toBe(null);
// Nothing selected and no argument → no-op rather than deleting blindly.
provider.deletePreset();
expect(provider.savedPresets).toHaveLength(1);
cleanup();
dom.dispose();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.