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/contracts.test.ts

634 lines
21 KiB

// @vitest-environment jsdom
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { createActiveApp, type ActiveApp } from '$active-app';
import { createActiveDom } from '$adom';
import { createActivePrefs, createActivePrefsDomProjection, standardPrefsDimensions } from '$prefs';
import type { LangNode } from '$libs/langs';
import { state } from '$libs/reactive';
import {
ActiveUixMissingServiceError,
attachActiveUix,
createActiveUix
} from './active-uix/active-uix.svelte';
import { defineUixServices, UIX_REQUIRED_SERVICES } from './active-uix/services';
import { createActiveEidos, ActiveEidosConfigError } from './eidos/active-eidos.svelte';
import {
_resetMorfoRegistryForTesting,
connectMorfoTranslations,
registerMorfo,
type Morfo,
v
} from './morfo';
import { toggleMorfo } from './morfo/components/toggle';
import { EngineSemantic, defineEngineSemantic } from './sema';
import { SemaConfigError } from './sema/errors';
import { createSomaRuntime } from './soma/runtime.svelte';
import { UIX_LAYER_CONTRACTS } from './contracts';
import { commonLangs } from './langs';
const minimalLang = { schema: {} satisfies LangNode, defaultLocale: 'es' as const };
const prefsProjectionSchema = standardPrefsDimensions({
languages: ['es-ES', 'ar-EG'],
locales: ['es-ES', 'ar-EG'],
currencies: ['EUR'],
defaults: {
language: 'es-ES',
locale: 'es-ES',
currency: 'EUR'
}
});
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(HERE, '..', '..');
const SOURCE_EXTENSIONS = ['.ts', '.svelte'] as const;
function preferencesHarness() {
return {
getTheme: () => 'base',
getMode: () => 'light' as const,
getDensity: () => 'comfortable' as const,
onPreferenceChange: () => () => {}
};
}
function listSourceFiles(root: string): string[] {
const stat = statSync(root);
if (stat.isFile()) {
return SOURCE_EXTENSIONS.some((ext) => root.endsWith(ext)) ? [root] : [];
}
return readdirSync(root)
.flatMap((entry) => listSourceFiles(join(root, entry)))
.filter(
(path) => !path.endsWith('.test.ts') && !path.includes(`${join('components', 'icon', 'lib')}`)
);
}
function grepSources(root: string, pattern: RegExp): string[] {
return listSourceFiles(root).filter((file) => pattern.test(readFileSync(file, 'utf8')));
}
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
}
function collectKnownMorfoDataAttrs(): Set<string> {
const attrs = new Set<string>();
for (const file of listSourceFiles(join(HERE, 'morfo', 'components'))) {
const source = stripComments(readFileSync(file, 'utf8'));
const component = /kebab:\s*'([^']+)'/.exec(source)?.[1];
if (!component) continue;
const partRe = /kebab:\s*'([^']+)'/g;
let partMatch: RegExpExecArray | null;
while ((partMatch = partRe.exec(source))) {
const part = partMatch[1];
attrs.add(part === 'provider' ? `data-${component}` : `data-${component}-${part}`);
}
const dataRe = /attr:\s*'([^']+)'/g;
let dataMatch: RegExpExecArray | null;
while ((dataMatch = dataRe.exec(source))) {
if (dataMatch[1].startsWith('data-')) attrs.add(dataMatch[1]);
}
}
return attrs;
}
function collectDataAttrs(source: string): Set<string> {
const clean = stripComments(source);
const attrs = new Set<string>();
const stringRe = /['"`](data-[a-zA-Z0-9_-]+)['"`]/g;
const inlineRe = /\b(data-[a-zA-Z0-9_-]+)(?=(?:=|\s|>|\/|}|\]))/g;
let match: RegExpExecArray | null;
while ((match = stringRe.exec(clean))) attrs.add(match[1]);
while ((match = inlineRe.exec(clean))) attrs.add(match[1]);
return attrs;
}
function toPascalName(kebab: string): string {
return kebab
.split('-')
.map((segment) => `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`)
.join('');
}
function collectPublicSomaComponentDirs(): string[] {
const root = join(HERE, 'soma', 'components');
return readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => name !== 'internal' && existsSync(join(root, name, 'index.ts')))
.sort();
}
function collectMorfoComponentNamesByScope(scope: string): string[] {
const root = join(HERE, 'morfo', 'components');
return readdirSync(root)
.filter((file) => file.endsWith('.ts') && !file.endsWith('.test.ts'))
.filter((file) => {
const source = stripComments(readFileSync(join(root, file), 'utf8'));
return new RegExp(`scope:\\s*\\[[^\\]]*'${scope}'`).test(source);
})
.map((file) => file.replace(/\.ts$/, ''))
.sort();
}
function collectComponentLangPathViolations(source: string, file: string): string[] {
const clean = stripComments(source);
const violations: string[] = [];
const refRe = /#\?components\.([^|'"`\s]+)/g;
let match: RegExpExecArray | null;
while ((match = refRe.exec(clean))) {
const path = match[1];
const segments = path.split('.');
if (segments.some((segment) => /[A-Z]/.test(segment))) {
violations.push(`${file.replace(REPO_ROOT, '')}: components.${path}`);
}
}
return violations;
}
function collectLangCatalogKeyViolations(node: unknown, prefix = 'components'): string[] {
if (!node || typeof node !== 'object') return [];
return Object.entries(node as Record<string, unknown>).flatMap(([key, value]) => {
const path = `${prefix}.${key}`;
const ownViolation = /[A-Z]/.test(key) ? [path] : [];
return [...ownViolation, ...collectLangCatalogKeyViolations(value, path)];
});
}
function collectBrokenSomaComponentReadmeLinks(): string[] {
const root = join(HERE, 'soma', 'components');
return collectPublicSomaComponentDirs().flatMap((dir) => {
const file = join(root, dir, 'README.md');
const source = readFileSync(file, 'utf8');
const violations: string[] = [];
const linkRe = /\]\((\.\.\/[^)]+README\.md)\)/g;
let match: RegExpExecArray | null;
while ((match = linkRe.exec(source))) {
const target = join(root, dir, match[1]);
if (!existsSync(target)) {
violations.push(`${dir}/README.md -> ${match[1]}`);
}
}
return violations;
});
}
describe('UIX layer contracts', () => {
it('pins ActiveUix public service names and rejects legacy aliases', () => {
const uix = createActiveUix({ langs: minimalLang });
try {
for (const key of UIX_LAYER_CONTRACTS.activeUix.publicSurface) {
expect(key in uix, key).toBe(true);
}
for (const key of UIX_LAYER_CONTRACTS.activeUix.forbiddenPublicSurface) {
expect(key in uix, key).toBe(false);
}
expect(uix.ownsApp).toBe(true);
expect(uix.app).toBeUndefined();
expect(uix.events).toBeInstanceOf(EngineSemantic);
} finally {
uix.dispose();
}
});
it('keeps attach mode as consumption-only: required services are langs + dom', () => {
expect(UIX_REQUIRED_SERVICES).toEqual(UIX_LAYER_CONTRACTS.activeUix.attachRequires);
const app = createActiveApp({ services: {} });
try {
attachActiveUix(app as unknown as ActiveApp);
expect.fail('attachActiveUix should reject an app without UIX services');
} catch (err) {
if (!(err instanceof ActiveUixMissingServiceError)) throw err;
expect(err.missing).toEqual(['langs', 'dom']);
expect(err.missing).not.toContain('events');
} finally {
app.dispose();
}
});
it('defineUixServices publishes events, never semantic, and cannot attach without dom', () => {
const services = defineUixServices({ langs: minimalLang });
expect(Object.keys(services).sort()).toEqual(['clipboard', 'dom', 'events', 'format', 'langs']);
expect('semantic' in services).toBe(false);
expect('lang' in services).toBe(false);
expect('frontend' in services).toBe(false);
const noDomServices = defineUixServices({ langs: minimalLang, dom: false });
expect('dom' in noDomServices).toBe(false);
expect('events' in noDomServices).toBe(false);
const app = createActiveApp({ services: noDomServices });
try {
expect(() => attachActiveUix(app as unknown as ActiveApp)).toThrow(
ActiveUixMissingServiceError
);
} finally {
app.dispose();
}
});
it('guards ActiveUix as composition root, not Soma/Eidos bootstrapper', () => {
const source = readFileSync(join(HERE, 'active-uix', 'active-uix.svelte.ts'), 'utf8');
expect(source).not.toMatch(/from ['"].*\/soma/);
expect(source).not.toMatch(/from ['"].*\/eidos/);
expect(source).not.toMatch(/\bSoma\.create\(/);
expect(source).not.toMatch(/\bActiveEidos\.create\(/);
expect(source).not.toMatch(/\bEidos\.create\(/);
});
it('prefs DOM projection owns only cross-modal attrs', () => {
expect(UIX_LAYER_CONTRACTS.prefsDomProjection.implementation).toBe(
'$prefs.createActivePrefsDomProjection'
);
expect(UIX_LAYER_CONTRACTS.prefsDomProjection.requires).toEqual(['prefs', 'dom']);
expect(UIX_LAYER_CONTRACTS.prefsDomProjection.ownsAttrs).toEqual([
'dir',
'data-motion',
'data-sound',
'data-haptic'
]);
expect(UIX_LAYER_CONTRACTS.prefsDomProjection.forbiddenAttrs).toEqual([
'data-theme',
'data-mode',
'data-density'
]);
});
it('implements cross-modal projection in prefs, not ActiveUix', () => {
const dom = createActiveDom();
const prefs = createActivePrefs({ schema: prefsProjectionSchema });
const target = document.createElement('div');
const projection = createActivePrefsDomProjection({ prefs, dom, target });
try {
for (const attr of UIX_LAYER_CONTRACTS.prefsDomProjection.ownsAttrs) {
expect(target.hasAttribute(attr), attr).toBe(true);
}
for (const attr of UIX_LAYER_CONTRACTS.prefsDomProjection.forbiddenAttrs) {
expect(target.hasAttribute(attr), attr).toBe(false);
}
} finally {
projection.dispose();
prefs.dispose();
dom.dispose();
}
});
it('SomaRuntime has one public part API and only depends on injected services', async () => {
const dom = createActiveDom();
try {
const runtime = createSomaRuntime(toggleMorfo, { dom });
for (const key of UIX_LAYER_CONTRACTS.somaRuntime.publicSurface) {
expect(key in runtime, key).toBe(true);
}
for (const key of UIX_LAYER_CONTRACTS.somaRuntime.forbiddenPublicSurface) {
expect(key in runtime, key).toBe(false);
}
const id = state('toggle-contract');
runtime.part('provider', { id });
expect(runtime.partProps('provider')).toMatchObject({
id: 'toggle-contract',
'data-toggle': ''
});
} finally {
dom.dispose();
}
});
it('guards Soma components from legacy runtime registration APIs', () => {
const violations = grepSources(
join(HERE, 'soma', 'components'),
/registerContract\(|registerPart\(/
);
expect(violations).toEqual([]);
});
it('guards Soma component barrel exports all public component modules', () => {
const indexSource = readFileSync(join(HERE, 'soma', 'components', 'index.ts'), 'utf8');
const missingExports = collectPublicSomaComponentDirs()
.map((dir) => ({
dir,
name: toPascalName(dir),
exportRe: new RegExp(`export \\* as ${toPascalName(dir)} from ['"]\\./${dir}['"];`)
}))
.filter(({ exportRe }) => !exportRe.test(indexSource))
.map(({ dir, name }) => `${dir} -> ${name}`);
expect(missingExports).toEqual([]);
});
it('guards Soma Provider exports use the canonical root wrapper filename', () => {
const violations = collectPublicSomaComponentDirs()
.filter((dir) => {
const source = readFileSync(join(HERE, 'soma', 'components', dir, 'exports.ts'), 'utf8');
const expected = `export { default as Provider } from './components/${dir}.svelte';`;
return !source.includes(expected);
})
.map((dir) => `${dir}/exports.ts`);
expect(violations).toEqual([]);
});
it('guards Date/Time field roots from swallowing provider DOM attrs', () => {
for (const dir of ['date-field', 'time-field']) {
const source = readFileSync(
join(HERE, 'soma', 'components', dir, 'components', `${dir}.svelte`),
'utf8'
);
expect(source, dir).toContain('ref = $bindable(null)');
expect(source, dir).toContain('...restProps');
expect(source, dir).toContain('const mergedProps');
expect(source, dir).toContain('<div {...mergedProps}>');
expect(source, dir).not.toContain('child({ props: {} })');
}
});
it('guards virtual picker roots from advertising DOM attrs', () => {
const roots = [
['date-picker', 'DatePickerProviderProps'],
['date-range-picker', 'DateRangePickerProviderProps'],
['time-picker', 'TimePickerProviderProps'],
['time-range-picker', 'TimeRangePickerProviderProps']
] as const;
for (const [dir, typeName] of roots) {
const types = readFileSync(join(HERE, 'soma', 'components', dir, 'types.ts'), 'utf8');
const wrapper = readFileSync(
join(HERE, 'soma', 'components', dir, 'components', `${dir}.svelte`),
'utf8'
);
const rootType =
new RegExp(`export type ${typeName}\\s*=\\s*\\{[\\s\\S]*?\\};`).exec(types)?.[0] ?? '';
expect(rootType, dir).toContain('children?: Snippet;');
expect(rootType, dir).not.toContain('WithChild');
expect(rootType, dir).not.toContain('PrimitiveDivAttributes');
expect(wrapper, dir).toContain('{@render children?.()}');
expect(wrapper, dir).not.toContain('child({ props: {} })');
}
});
it('guards Soma public barrels from provider implementation filenames', () => {
const violations = collectPublicSomaComponentDirs()
.map((dir) => join(HERE, 'soma', 'components', dir, 'exports.ts'))
.filter((file) =>
/from ['"][^'"]*provider\.svelte(?:\.ts)?['"]/.test(readFileSync(file, 'utf8'))
)
.map((file) => file.replace(REPO_ROOT, ''));
expect(violations).toEqual([]);
});
it('guards Soma public barrels from shared lib facades', () => {
const violations = collectPublicSomaComponentDirs()
.map((dir) => join(HERE, 'soma', 'components', dir, 'exports.ts'))
.filter((file) => /from ['"]\$libs\//.test(readFileSync(file, 'utf8')))
.map((file) => file.replace(REPO_ROOT, ''));
expect(violations).toEqual([]);
});
it('guards Soma public component modules against Morfo soma scope', () => {
expect(collectPublicSomaComponentDirs()).toEqual(collectMorfoComponentNamesByScope('soma'));
});
it('guards Soma public component modules with local README docs', () => {
const missingReadmes = collectPublicSomaComponentDirs().filter(
(dir) => !existsSync(join(HERE, 'soma', 'components', dir, 'README.md'))
);
expect(missingReadmes).toEqual([]);
});
it('guards Soma component README links to existing component docs', () => {
expect(collectBrokenSomaComponentReadmeLinks()).toEqual([]);
});
it('guards Soma from external re-export facades', () => {
expect(existsSync(join(HERE, 'soma', 'external'))).toBe(false);
const violations = grepSources(join(HERE, 'soma'), /external\/(?:dates|colors)/);
expect(violations).toEqual([]);
});
it('guards Soma root barrel as scope-only', () => {
const source = readFileSync(join(HERE, 'soma', 'index.ts'), 'utf8');
expect(source).toContain("from './core/soma.svelte'");
expect(source).toContain("from './errors'");
expect(existsSync(join(HERE, 'soma', 'core', 'langs.ts'))).toBe(false);
expect(source).not.toMatch(
/from ['"]\.\/(?:provider|props|keyboard|typeahead|css|id|types|runtime\.svelte|components|layers|datetime|color)/
);
});
it('guards Soma internals from importing their own public alias', () => {
const violations = listSourceFiles(join(HERE, 'soma'))
.filter((file) => {
const source = stripComments(readFileSync(file, 'utf8'));
return /from ['"]\$soma(?:['"]|\/)/.test(source);
})
.map((file) => file.replace(REPO_ROOT, ''));
expect(violations).toEqual([]);
});
it('guards Soma from direct mutable DOM writes', () => {
const violations = grepSources(
join(HERE, 'soma'),
/\.setAttribute\(|\.removeAttribute\(|\.toggleAttribute\(|\.classList\.|\.style\.setProperty\(|\.style\.[a-zA-Z]+\s*=/
);
expect(violations).toEqual([]);
});
it('guards Soma component timers through the injected scheduler', () => {
const allowedFallback = join(HERE, 'soma', 'components', 'announce', 'global.svelte.ts');
const violations = listSourceFiles(join(HERE, 'soma', 'components')).filter((file) => {
if (file === allowedFallback) return false;
return /\bsetTimeout\(|\bclearTimeout\(|\bsetInterval\(|\bclearInterval\(/.test(
stripComments(readFileSync(file, 'utf8'))
);
});
expect(violations).toEqual([]);
});
it('guards Soma components through the shared typeahead helper', () => {
const violations = grepSources(
join(HERE, 'soma', 'components'),
/class\s+Typeahead|typeaheadBuffer|typeaheadTimerKey|clearTypeaheadTimer/
);
expect(violations).toEqual([]);
});
it('guards Soma component listeners through ActiveDom', () => {
const violations = grepSources(
join(HERE, 'soma', 'components'),
/\.(addEventListener|removeEventListener)\(/
);
expect(violations).toEqual([]);
});
it('guards hardcoded Soma component data attrs with morfo contracts', () => {
const knownAttrs = collectKnownMorfoDataAttrs();
const violations = listSourceFiles(join(HERE, 'soma', 'components')).flatMap((file) => {
const attrs = collectDataAttrs(readFileSync(file, 'utf8'));
return [...attrs]
.filter((attr) => !knownAttrs.has(attr))
.map((attr) => `${file.replace(REPO_ROOT, '')}: ${attr}`);
});
expect(violations).toEqual([]);
});
it('guards component translation namespaces as kebab-case', () => {
const sourceRoots = [join(HERE, 'soma', 'components'), join(HERE, 'morfo', 'components')];
const pathViolations = sourceRoots.flatMap((root) =>
listSourceFiles(root).flatMap((file) =>
collectComponentLangPathViolations(readFileSync(file, 'utf8'), file)
)
);
expect(pathViolations).toEqual([]);
expect(collectLangCatalogKeyViolations(commonLangs, 'common')).toEqual([]);
});
it('events/sema uses events as service name and requires dom or projector for visual projection', () => {
const factory = defineEngineSemantic({ visual: false });
expect(factory.name).toBe(UIX_LAYER_CONTRACTS.events.publicServiceName);
expect(factory.serviceDependencies).toEqual(['dom']);
expect(UIX_LAYER_CONTRACTS.events.morfoPayloadName).toBe('semantic');
expect(UIX_LAYER_CONTRACTS.events.requiresOneOfForVisualProjection).toEqual([
'dom',
'projector'
]);
expect(() => new EngineSemantic()).toThrow(SemaConfigError);
expect(() => new EngineSemantic({ visual: false })).not.toThrow();
});
it('guards Sema production code from direct global listeners and console output', () => {
const violations = listSourceFiles(join(HERE, 'sema'))
.filter((file) => {
const source = stripComments(readFileSync(file, 'utf8'));
return /(?:document|window)\.(?:addEventListener|removeEventListener)\(|console\./.test(
source
);
})
.map((file) => file.replace(REPO_ROOT, ''));
expect(violations).toEqual([]);
});
it('ActiveEidos is the visual runtime and does not recreate an engine layer', () => {
expect(UIX_LAYER_CONTRACTS.activeEidos.publicRuntime).toBe('ActiveEidos');
expect(existsSync(new URL('./eidos/engine', import.meta.url))).toBe(false);
expect(existsSync(new URL('./eidos/core/headless', import.meta.url))).toBe(false);
expect(UIX_LAYER_CONTRACTS.activeEidos.constructionOptionalServices).toEqual([
'langs',
'format',
'prefs'
]);
expect(UIX_LAYER_CONTRACTS.activeEidos.requiredAccessors).toEqual(['langs', 'prefs']);
expect(UIX_LAYER_CONTRACTS.activeEidos.optionalAccessors).toEqual(['format']);
expect(() => createActiveEidos({ preferences: preferencesHarness() })).toThrow(
ActiveEidosConfigError
);
const eidos = createActiveEidos({
preferences: preferencesHarness(),
applyDom: false
});
expect(eidos.renderStaticCss()).toContain('--size-md-control-height');
expect(() => eidos.prefs).toThrow(ActiveEidosConfigError);
expect(eidos.format).toBeUndefined();
eidos.dispose();
});
it('guards Eidos public surface from wrapper/engine reintroduction', () => {
const eidosRoot = join(HERE, 'eidos');
const violations = grepSources(
eidosRoot,
/\bEngineEidos\b|\bclass\s+Eidos\b|\bEidos\.create\(|from ['"].*\/engine/
);
expect(violations).toEqual([]);
});
it('guards UIX docs shell from writing visual prefs through ActivePrefs', () => {
const violations = grepSources(
join(REPO_ROOT, 'web', 'routes', 'uix'),
/prefs\.theme|setIntent\(['"]theme['"]/
);
expect(violations).toEqual([]);
});
it('Morfo owns translations declaratively and only registers them into a target', () => {
_resetMorfoRegistryForTesting();
const applied: Array<{ namespace: string; module: LangNode }> = [];
const detach = connectMorfoTranslations({
extend(namespace, module) {
applied.push({ namespace, module });
}
});
try {
registerMorfo({
name: 'ContractSample',
kebab: 'contract-sample',
scope: ['soma'],
translations: {
label: { es: 'Contrato', en: 'Contract' }
},
parts: [
{
name: 'Provider',
kebab: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: [
{
attr: 'aria-label',
value: v.translationRef('label', 'Contract')
}
]
}
]
} satisfies Morfo);
expect(applied).toHaveLength(1);
expect(applied[0]?.namespace).toBe('components.contract-sample');
expect(applied[0]?.module).toEqual({
label: { es: 'Contrato', en: 'Contract' }
});
} finally {
detach();
_resetMorfoRegistryForTesting();
}
});
});

Powered by TurnKey Linux.