mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
refactor(apps): drop the frozen sub-nav predicate oracle (#4841)
The test carried ORIGIN_MAIN_PREDICATES, a hand transcription of the six
`visible` expressions from AppsSubNav.tsx's SUB_NAV_LINKS as they stood at
merge-base 4aab099c91, and asserted the apps-sections.ts registry agreed across
a 1,024-case cohort sweep. That was the right guard for the migration and is
worthless now #4813 has landed: it deleted AppsSubNav.tsx, so the oracle has no
source to be a transcription of. Any legitimate future predicate edit reds it,
and the only fix would be hand-editing the oracle to match the implementation it
is supposed to be independently checking.
Also repoints the apps-sections.ts docstring that referenced it.
What still pins these predicates, verified rather than assumed:
appsSectionRowsMatchPageGates.test.ts evaluates the Marketplace, Build and
Activity predicates against their real page resolvers over the full flag space,
plus appsBuildAccess.test.ts and appsBuildGateCallSites.test.ts.
Two gaps named in-band rather than papered over: Invites, Revenue and Review are
in neither node-tier set — their only per-row behavioural pin is the report-only
browser tier — and the "visible to nobody" direction is no longer asserted. The
replacement guard was deliberately NOT widened here; that is a guard change and
does not belong in a deletion.
Unscoped unit project run against a control at origin/main: delta exactly
-1 file / -10 tests, the deleted file's own contents and nothing else.
Closes #4821
This commit is contained in:
@@ -1,193 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import type { AppsNavContext, AppsNavSummary } from '~/components/Apps/apps-sections';
|
||||
import { appsSections, visibleAppsSections } from '~/components/Apps/apps-sections';
|
||||
|
||||
/**
|
||||
* 🔒 THE PORT GUARD — `appsSections`' visibility predicates are BEHAVIOURALLY IDENTICAL
|
||||
* to the `SUB_NAV_LINKS` table they were moved from.
|
||||
*
|
||||
* 🔴 WHY THIS FILE EXISTS. The `/apps` nav's tab strip became a left rail, which moved
|
||||
* the table from `AppsSubNav.tsx` to `apps-sections.ts`. Every predicate in it is its
|
||||
* page's own access gate restated as a viewer fact, and this table has shipped the SAME
|
||||
* defect twice — #3899 ("Create" store-gated while `/apps/submit` is author-gated) and
|
||||
* again on PR #4668 — both times because a predicate was written or rewritten separately
|
||||
* from the gate it mirrors. A LAYOUT CHANGE IS THE MAXIMUM-LIKELIHOOD MOMENT FOR A THIRD
|
||||
* INSTANCE: predicates get "cleaned up" while the file they live in is being retyped, and
|
||||
* a widened one looks exactly like a tidier one in review.
|
||||
*
|
||||
* 🔴 THE ORACLE IS TRANSCRIBED FROM `origin/main`, NOT DERIVED FROM THE NEW MODULE.
|
||||
* `ORIGIN_MAIN_PREDICATES` below is a hand-copy of `SUB_NAV_LINKS`' six `visible`
|
||||
* expressions as they stand at `4aab099c91` — the commit this change is based on. It is
|
||||
* an INDEPENDENT WITNESS: a test that re-ran the registry's own predicates would agree
|
||||
* with any edit to them, which is precisely the failure this guard exists to prevent.
|
||||
* When a predicate is deliberately changed, this file's oracle must be edited in the SAME
|
||||
* commit and the change is then visible in the diff — which is the whole point.
|
||||
*
|
||||
* 🔴 THE WHOLE COHORT SPACE, NOT A SAMPLE. Seven summary booleans and three context
|
||||
* booleans is 2^10 = 1024 viewers, and every one of them is compared. A sampled matrix
|
||||
* cannot see a predicate that is wrong only for the cohort nobody thought to write down
|
||||
* — which is the shape of all three historical instances.
|
||||
*/
|
||||
|
||||
/** The seven `getNavSummary` booleans, in a fixed order so the enumeration is stable. */
|
||||
const SUMMARY_KEYS = [
|
||||
'hasInstalls',
|
||||
'hasActivity',
|
||||
'hasSubmissions',
|
||||
'hasApprovedApps',
|
||||
'isReviewer',
|
||||
'hasEditableApps',
|
||||
'hasPendingInvites',
|
||||
] as const;
|
||||
|
||||
/** The three viewer capabilities. */
|
||||
const CONTEXT_KEYS = ['isAuthor', 'canBuild', 'canSeeStore'] as const;
|
||||
|
||||
/**
|
||||
* 🔴 THE ORACLE. Hand-transcribed from `SUB_NAV_LINKS` in
|
||||
* `src/components/Apps/AppsSubNav.tsx` at `4aab099c91` (the merge-base of this change),
|
||||
* keyed by the href each row pointed at. Do not "simplify" any of these — a simplified
|
||||
* oracle silently stops being a witness to what shipped.
|
||||
*/
|
||||
const ORIGIN_MAIN_PREDICATES: Record<string, (s: AppsNavSummary, c: AppsNavContext) => boolean> = {
|
||||
'/apps': (_s, c) => c.canSeeStore,
|
||||
'/apps/activity': (s) => s.hasInstalls || s.hasActivity,
|
||||
'/apps/invites': (s, c) => c.isAuthor && s.hasPendingInvites,
|
||||
'/apps/revenue': (s) => s.hasApprovedApps,
|
||||
'/apps/build': (_s, c) => c.canBuild,
|
||||
'/apps/review': (s) => s.isReviewer,
|
||||
};
|
||||
|
||||
/**
|
||||
* The ORDER the rows appeared in at `4aab099c91` — discovery → manage → revenue → build
|
||||
* → moderate. Pinned separately from the predicates because a re-order changes which
|
||||
* destination reads as "what this nav is for" and is a product decision, not a detail.
|
||||
*/
|
||||
const ORIGIN_MAIN_ORDER = [
|
||||
'/apps',
|
||||
'/apps/activity',
|
||||
'/apps/invites',
|
||||
'/apps/revenue',
|
||||
'/apps/build',
|
||||
'/apps/review',
|
||||
] as const;
|
||||
|
||||
/** `section.path` → the href it resolves to, mirroring `getAppsSectionHref`. */
|
||||
const hrefOf = (path: string) => (path ? `/apps/${path}` : '/apps');
|
||||
|
||||
/** Every (summary × context) viewer, as a flat list. 2^7 × 2^3 = 1024. */
|
||||
function everyCohort(): Array<{ summary: AppsNavSummary; context: AppsNavContext }> {
|
||||
const out: Array<{ summary: AppsNavSummary; context: AppsNavContext }> = [];
|
||||
for (let s = 0; s < 1 << SUMMARY_KEYS.length; s += 1) {
|
||||
const summary = Object.fromEntries(
|
||||
SUMMARY_KEYS.map((k, i) => [k, Boolean(s & (1 << i))])
|
||||
) as unknown as AppsNavSummary;
|
||||
for (let c = 0; c < 1 << CONTEXT_KEYS.length; c += 1) {
|
||||
const context = Object.fromEntries(
|
||||
CONTEXT_KEYS.map((k, i) => [k, Boolean(c & (1 << i))])
|
||||
) as unknown as AppsNavContext;
|
||||
out.push({ summary, context });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const describeCohort = (summary: AppsNavSummary, context: AppsNavContext) =>
|
||||
[...SUMMARY_KEYS.filter((k) => summary[k]), ...CONTEXT_KEYS.filter((k) => context[k])].join(
|
||||
'+'
|
||||
) || '(nothing)';
|
||||
|
||||
describe('the extractor and the oracle (validate the instrument first)', () => {
|
||||
test('the cohort enumeration really is the whole space', () => {
|
||||
// A loop over an empty or truncated set is a clean pass over nothing — the reassuring
|
||||
// zero this whole file would otherwise be vulnerable to.
|
||||
const cohorts = everyCohort();
|
||||
expect(cohorts).toHaveLength(1024);
|
||||
// …and the cohorts are DISTINCT, so 1024 is not one viewer counted 1024 times.
|
||||
const seen = new Set(cohorts.map((x) => describeCohort(x.summary, x.context)));
|
||||
expect(seen.size).toBe(1024);
|
||||
});
|
||||
|
||||
test('🔴 NEGATIVE CONTROL: the comparison can go RED', () => {
|
||||
// Feed the oracle a deliberately-wrong predicate for one row and confirm the
|
||||
// cell-by-cell comparison below would notice. Without this, "every cohort agrees"
|
||||
// is indistinguishable from a comparison wired to nothing.
|
||||
const broken = {
|
||||
...ORIGIN_MAIN_PREDICATES,
|
||||
'/apps/invites': (s: AppsNavSummary) => s.hasPendingInvites,
|
||||
};
|
||||
const disagreements = everyCohort().filter(({ summary, context }) => {
|
||||
const actual = appsSections
|
||||
.filter((x) => x.visible(summary, context))
|
||||
.map((x) => hrefOf(x.path));
|
||||
const expected = ORIGIN_MAIN_ORDER.filter((href) => broken[href](summary, context));
|
||||
return actual.join(',') !== expected.join(',');
|
||||
});
|
||||
// The mutant widens Invites to every non-author with a pending invite, so it must
|
||||
// disagree on a non-trivial number of cohorts — not merely "at least one".
|
||||
expect(disagreements.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test('the registry rows are exactly the rows the oracle knows about, in order', () => {
|
||||
// The per-cohort comparison below is keyed by href; a row that vanished from the
|
||||
// registry would simply never be compared. This is what makes the set exact.
|
||||
expect(appsSections.map((s) => hrefOf(s.path))).toEqual([...ORIGIN_MAIN_ORDER]);
|
||||
expect(Object.keys(ORIGIN_MAIN_PREDICATES).sort()).toEqual([...ORIGIN_MAIN_ORDER].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('🔴 every predicate is behaviourally unchanged from `origin/main`', () => {
|
||||
test('all 1024 cohorts resolve to the identical section set', () => {
|
||||
const disagreements: string[] = [];
|
||||
for (const { summary, context } of everyCohort()) {
|
||||
const actual = visibleAppsSections(summary, context).map((s) => hrefOf(s.path));
|
||||
const expected = ORIGIN_MAIN_ORDER.filter((href) =>
|
||||
ORIGIN_MAIN_PREDICATES[href](summary, context)
|
||||
);
|
||||
if (actual.join(',') !== expected.join(',')) {
|
||||
disagreements.push(
|
||||
`${describeCohort(summary, context)}: registry=[${actual.join(', ')}] ` +
|
||||
`origin/main=[${expected.join(', ')}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
disagreements.slice(0, 10),
|
||||
`${disagreements.length} cohort(s) resolve to a DIFFERENT section set than the ` +
|
||||
'`SUB_NAV_LINKS` table this registry was ported from. Every predicate here is its ' +
|
||||
"page's own gate restated as a viewer fact; #3899 and PR #4668 were both a " +
|
||||
'predicate rewritten separately from that gate. If the change is deliberate, edit ' +
|
||||
'ORIGIN_MAIN_PREDICATES in this same commit so the diff shows what moved.'
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 🔴 PER-ROW, SO A FAILURE NAMES THE PREDICATE. The test above proves the SET matches
|
||||
* and would report a single cohort touching two rows as one line; this one attributes a
|
||||
* disagreement to the row that caused it. Both are cheap and they fail differently.
|
||||
*/
|
||||
test.each([...ORIGIN_MAIN_ORDER])('%s: the predicate itself is unchanged', (href) => {
|
||||
const section = appsSections.find((s) => hrefOf(s.path) === href);
|
||||
expect(section, `${href} is missing from appsSections`).toBeDefined();
|
||||
const oracle = ORIGIN_MAIN_PREDICATES[href];
|
||||
|
||||
let sawTrue = false;
|
||||
let sawFalse = false;
|
||||
for (const { summary, context } of everyCohort()) {
|
||||
const actual = section!.visible(summary, context);
|
||||
expect(
|
||||
actual,
|
||||
`${href} @ ${describeCohort(summary, context)}: the ported predicate answers ` +
|
||||
`${actual} where \`origin/main\` answered ${oracle(summary, context)}`
|
||||
).toBe(oracle(summary, context));
|
||||
if (actual) sawTrue = true;
|
||||
else sawFalse = true;
|
||||
}
|
||||
// 🔴 AND THE PREDICATE IS STILL CONDITIONAL. A row that became `() => true` (or
|
||||
// `() => false`) would agree with an oracle mutated the same way, and every
|
||||
// assertion above would pass. Marketplace was `() => true` for months and that was
|
||||
// exactly the #4668-class exposure.
|
||||
expect(sawTrue, `${href} is visible to NOBODY`).toBe(true);
|
||||
expect(sawFalse, `${href} is visible to EVERYONE`).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -200,8 +200,21 @@ export type AppsSection = {
|
||||
* TAB STRIP BECAME THIS RAIL — not re-derived, not simplified, not "cleaned up". A
|
||||
* layout change is exactly the occasion on which a predicate gets rewritten for tidiness
|
||||
* and quietly widens, which is the #3899/#4668 class arriving a third time.
|
||||
* `__tests__/appsSectionsPredicates.test.ts` pins each one against the full cohort
|
||||
* matrix and fails if any is edited.
|
||||
* WHAT PINS THEM NOW, AND WHAT DOES NOT. The migration guard that carried a frozen
|
||||
* transcription of `SUB_NAV_LINKS` was deleted with #4821 — `AppsSubNav.tsx` no longer
|
||||
* exists, so it had no source left to be a transcription of. In its place:
|
||||
* • `__tests__/appsSectionRowsMatchPageGates.test.ts` (node tier, blocking) extracts
|
||||
* each row's `visible` expression from THIS file and evaluates it — Marketplace
|
||||
* against `resolveAppsPageAccess`, Build against `resolveBuildPageAccess`, Activity
|
||||
* against `resolveActivityPageAccess` / `canAccessAppsActivity` — and fails if ANY
|
||||
* row in the table becomes unconditionally visible.
|
||||
* • `__tests__/appsBuildAccess.test.ts` and `__tests__/appsBuildGateCallSites.test.ts`
|
||||
* cover the Build row behaviourally and structurally.
|
||||
* ⚠️ Invites, Revenue and Review are in NEITHER node-tier set. Their only per-row
|
||||
* behavioural pin is `AppsRailNav.browser.test.tsx`, which runs in the report-only
|
||||
* browser tier — so editing one of those three predicates is not blocked by anything.
|
||||
* If one gains a flag term, add it to `appsSectionRowsMatchPageGates.test.ts` rather
|
||||
* than relying on review.
|
||||
*
|
||||
* "Build" is the strongest form of the fix available: its predicate is not restated
|
||||
* here at all, it is the SHARED `canAccessAppsBuild`, which the page's
|
||||
|
||||
Reference in New Issue
Block a user