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
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…
Reference in new issue