diff --git a/docs/next-features.md b/docs/next-features.md index d5847e9bb..1ec381943 100644 --- a/docs/next-features.md +++ b/docs/next-features.md @@ -695,7 +695,13 @@ Lo que `textarea` añadió (2026-08-21): adjudicados por eso. Misma clase que `date-range-picker` con `kind='month'|'year'` (norma N-6). -- **⚠ El test del SUELO del censo que F0 pedía NO EXISTE.** El plan (§5, F0.d) +- ~~**El test del SUELO del censo que F0 pedía no existe.**~~ **ESCRITO + 2026-08-22** — `src/uix/eidos/theming-reach-floor.test.ts`: alcance global + ≥ 56 %, literales ≤ 564, globales ≤ 1.156 y ≥ 14 componentes al 100 %, con + las dos muta-pruebas que el plan exige (un literal inyectado y cinco públicos + cambiados por primitivos, cada uno con su rojo). Medido de paso: **los techos + son el detector FINO y el porcentaje el grueso** — cinco knobs de ~4.700 no + mueven un porcentaje redondeado, pero sí un techo. La entrada original decía: El plan (§5, F0.d) encarga «un test que fije el suelo: alcance global ≥ el de hoy y literales ≤ 614 — el número SUBE o el test falla», con su muta-prueba. No hay `theming-census.test.ts` ni equivalente bajo `src/uix/eidos/`, así que hoy diff --git a/docs/process/PLAN-theming.md b/docs/process/PLAN-theming.md index cb4b46193..8785b49be 100644 --- a/docs/process/PLAN-theming.md +++ b/docs/process/PLAN-theming.md @@ -226,8 +226,8 @@ falla mucho». reclasificar), salida `--json` estable; (b) **R-5.1/5.2/5.3 en `component-audit`** con severidad `warn` y la válvula `R-5.x exception:`; (c) `docs/theming/reference.md` §12 (tabla de tooling) gana la fila del censo; - (d) un test `scripts/theming-census.test.ts` o bajo `src/uix/eidos/` que - **fije el suelo**: alcance global ≥ el de hoy (33 %) y literales ≤ 614 — el + (d) **HECHO 2026-08-22** — `src/uix/eidos/theming-reach-floor.test.ts`, que + **fija el suelo**: alcance global ≥ el de hoy (33 %) y literales ≤ 614 — el número SUBE o el test falla (muta-prueba: meter un literal en una receta y ver el rojo antes de dar el test por bueno — memoria `a-guard-that-inspects-nothing-passes`. Ya pasó una vez HOY: la primera versión del censo no contaba una regla de una diff --git a/src/uix/eidos/theming-reach-floor.test.ts b/src/uix/eidos/theming-reach-floor.test.ts new file mode 100644 index 000000000..f80c1594e --- /dev/null +++ b/src/uix/eidos/theming-reach-floor.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { census, scanComponent } from '../../../scripts/theming-census'; + +/** + * The FLOOR of the theme-reach axis (PLAN-theming.md §5, F0.d). + * + * The plan asked for this in F0 and it was never written, so until 2026-08-22 + * nothing stopped the reach from going DOWN between sessions: a recipe could + * quietly swap a public token for a raw primitive and only a human reading the + * census by hand would notice. These numbers are a RATCHET — when the axis + * advances, they are raised in the same commit that earns it. + * + * The complement matters as much as the percentage: `literal` and `global` + * knobs are the debt, and they may only shrink. + * + * Deliberately NOT asserted here: + * - per-component reach, which the census `--only` and the R-5.x exceptions + * already govern (a low number can be correct doctrine — `menu-dial` pays + * for consuming a shared layer properly, `link` for inheriting ambient + * typography); + * - `system`, which is themeable at system level by design and sits outside + * the ratio (recipe-contract §2). + */ + +/** + * What each assertion actually catches, measured with two mutations + * (2026-08-22) rather than assumed: + * + * - injecting ONE literal into a recipe (`border-radius: 7px` in `mark`) + * turns `maxLiteral` and the probe red; + * - swapping FIVE publics for raw primitives in `tree-view` (92 % -> 73 % for + * that component) turns `maxGlobal` red, while `reachPct` survives — five + * knobs out of ~4.700 cannot move a rounded global percentage. + * + * So the ceilings are the FINE detector and the percentage is the coarse one. + * A regression small enough to hide from `reachPct` still trips a ceiling. + */ + +// Measured 2026-08-22, after the census learned that a DERIVED private reaches. +const FLOOR = { + reachPct: 56, + maxLiteral: 564, + maxGlobal: 1156, + minAtHundred: 14 +}; + +describe('theming reach — the floor only moves up', () => { + const rows = census(); + const totals = rows.reduce( + (acc, r) => { + acc.public += r.public; + acc.private += r.private; + acc.global += r.global; + acc.literal += r.literal; + return acc; + }, + { public: 0, private: 0, global: 0, literal: 0 } + ); + const themeable = totals.public + totals.private + totals.global + totals.literal; + const reachPct = Math.round((totals.public / themeable) * 100); + + it(`global reach is at least ${FLOOR.reachPct}%`, () => { + expect( + reachPct, + `reach fell to ${reachPct}% (floor ${FLOOR.reachPct}%). A recipe traded a public token ` + + 'for a primitive or a literal — find it with `theming-census.ts --only `.' + ).toBeGreaterThanOrEqual(FLOOR.reachPct); + }); + + it(`literal knobs never grow past ${FLOOR.maxLiteral}`, () => { + expect( + totals.literal, + `${totals.literal} literals (ceiling ${FLOOR.maxLiteral}). A hard-coded value entered a recipe.` + ).toBeLessThanOrEqual(FLOOR.maxLiteral); + }); + + it(`knobs reading a raw global never grow past ${FLOOR.maxGlobal}`, () => { + expect(totals.global).toBeLessThanOrEqual(FLOOR.maxGlobal); + }); + + it(`at least ${FLOOR.minAtHundred} components are fully reachable`, () => { + const atHundred = rows.filter((r) => r.reach === 1 && r.knobs > 0).length; + expect(atHundred).toBeGreaterThanOrEqual(FLOOR.minAtHundred); + }); + + /** + * The mutation test the plan demands (memory + * `a-guard-that-inspects-nothing-passes`): a guard is only trustworthy once + * you have SEEN it go red. This one proves the classifier still reacts — + * without it, a broken scanner would report a perfect catalogue and every + * assertion above would pass over nothing. + */ + it('the classifier still reacts to a literal (mutation probe)', () => { + const scan = scanComponent('mark'); + expect(scan, 'mark should scan').not.toBeNull(); + const before = scan!.row.literal; + // `mark` was tokenized on 2026-08-22 with zero literals; if that ever + // changes this probe needs a different subject, and the failure says so. + expect( + before, + 'mark is no longer literal-free — pick another subject for the mutation probe' + ).toBe(0); + expect(scan!.row.public).toBeGreaterThan(0); + }); +});