diff --git a/.changeset/6503-concealment-gate-copy.md b/.changeset/6503-concealment-gate-copy.md new file mode 100644 index 0000000000..642f7aaa9c --- /dev/null +++ b/.changeset/6503-concealment-gate-copy.md @@ -0,0 +1,29 @@ +--- +'@object-ui/react': minor +--- + +The unresolvable-predicate diagnostic tells `hidden` / `hiddenOn` authors what actually +happened to their node (objectui#6503). Those two legs are the only ones in +`SchemaRenderer`'s visibility chain whose verdict is NOT negated: `evaluateCondition` +answers an unevaluable predicate with `true` on every path, the four negated legs turn that +into SHOWN, and these two return it as-is, so the same `true` sets `_hidden` and the +component returns `null`. Both were handed the consequence paragraph written for the negated +legs — "the gate did NOT bite - a predicate that cannot be evaluated reads on screen exactly +like one that said yes" — which is the opposite of what an author whose block VANISHED is +looking at. That line exists to name their predicate, and instead it sent them to hunt a +rendering bug that does not exist. + +The two legs now print their own paragraph: the safe default is the one that BITES here, the +node was REMOVED and is not on the page at all, an absent node is indistinguishable from +metadata that meant to hide it, and nothing is wrong with the renderer. + +Copy only — no verdict moved. The node still vanishes, which is the shipped fail-soft the +neighbouring family (objectui#3862 / #3955 / #6443 / #6487 / #6445) preserved deliberately, +and every case in the new suite pins the verdict beside the sentence. + +`PredicateGateKind` — re-exported from `@object-ui/react`'s entry — gains a third member, +`'concealment'`, joining `'visibility'` and `'enablement'`. The opening line is deliberately +UNCHANGED (`UNRESOLVABLE_VISIBILITY_PREFIX`): these are visibility predicates, and an app or +a test filtering the console by that constant must go on catching them. Consumers that +switch EXHAUSTIVELY over the union, or key a `Record` by it, gain a third case to answer; +no runtime signature moved and every value accepted before still is. diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index 87df2025b2..1fe6b2a88a 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -34,6 +34,7 @@ import { reportUnresolvableVisibilityPredicate, reportAdapterOnlyDataPredicate, } from './utils/visibilityDiagnostic.js'; +import type { PredicateGateKind } from './utils/visibilityDiagnostic.js'; /** * Dev-mode schema validation. @@ -232,6 +233,54 @@ function propsWithoutCanonicalKeys( const VISIBILITY_SHOW_KEYS = ['visibleWhen', 'visible', 'visibleOn', 'visibility'] as const; const VISIBILITY_HIDE_KEYS = ['hidden', 'hiddenOn'] as const; +/** + * The six legs, as ONE closed type — so a seventh cannot be added to the chain + * without also being classified below. + */ +type VisibilityChainKey = + | (typeof VISIBILITY_SHOW_KEYS)[number] + | (typeof VISIBILITY_HIDE_KEYS)[number]; + +/** + * Which CONSEQUENCE the diagnostic should print for a faulting predicate on + * this leg (objectui#6503). + * + * ## The defect this closes + * + * `evaluateCondition` answers an unevaluable predicate with `true` on every + * path. On the four SHOW legs the chain negates that answer, so the node is + * SHOWN and the reporter's "the gate did NOT bite" is true. On the two HIDE + * legs the chain returns it UN-negated (see `shouldHide` below), so the same + * `true` sets `_hidden` and this component returns `null`: the gate bit, and + * bit harder than on either sibling gate — the node is not on screen at all. + * Both were routed to `'visibility'`, so an author whose block VANISHED read a + * line telling them the gate did not bite and went looking for a rendering bug + * that does not exist. The polarity was documented in + * `visibilityDiagnostic.ts` and printed anyway; this is the routing that makes + * the documented split the one that is actually applied. + * + * ## Why derived from the two constants, when the reporter refuses to deduce + * + * `PredicateGateKind`'s docblock refuses to deduce the gate from `key` INSIDE + * the reporter, and that refusal stands: the reporter is exported and called + * from other packages, where an unheard-of spelling would silently inherit + * some other gate's sentence. Here the caller IS the chain. The two arrays + * above are the same declaration `shouldHide` and {@link winningVisibilityKey} + * consult, and {@link VisibilityChainKey} closes the parameter over them, so + * this is not a second table that can drift from the first — it is the same + * one, read for a second question. It also removes the failure mode that + * produced this card: a leg added to the chain and left with a consequence + * sentence written about a different polarity is now a TYPE error here, not a + * line of false console copy. + * + * The `disabled` / `disabledOn` gate is deliberately not reachable from this + * function: those legs are not in the visibility chain and route through + * `evaluateEnablementPredicate`, which states `'enablement'` at its own call + * site. + */ +const visibilityGateKind = (key: VisibilityChainKey): PredicateGateKind => + (VISIBILITY_HIDE_KEYS as readonly string[]).includes(key) ? 'concealment' : 'visibility'; + /** * Which ONE visibility key actually decides a node's fate, mirroring both the * `properties` hoist's precedence (same-named key: `properties.` @@ -278,7 +327,7 @@ const VISIBILITY_HIDE_KEYS = ['hidden', 'hiddenOn'] as const; * Read-only: decides nothing about visibility itself, only which key a * DIAGNOSTIC should look at. */ -function winningVisibilityKey(node: Record): string | undefined { +function winningVisibilityKey(node: Record): VisibilityChainKey | undefined { const propertiesBag = node.properties; const hasPropertiesBag = propertiesBag != null && typeof propertiesBag === 'object' && !Array.isArray(propertiesBag); @@ -647,7 +696,14 @@ export const SchemaRenderer: ForwardRefExoticComponent< * function changed; `shouldHide` below still calls it exactly as before, * on the POST-evaluation, POST-hoist schema, for the real verdict. */ - const evaluateVisibilityPredicate = (raw: VisibilityPredicate, key: string): boolean => { + const evaluateVisibilityPredicate = ( + raw: VisibilityPredicate, + key: VisibilityChainKey, + ): boolean => { + // WHICH consequence this leg's fail-soft default earns (objectui#6503). + // Computed once, used by both branches, so the production and the + // development report cannot disagree about what the default DID. + const gate = visibilityGateKind(key); // PRODUCTION STILL MAKES THE SINGLE CALL — it just no longer makes it // in silence (objectui#6038, maintainer ruling 2026-08-25, option B). // @@ -683,6 +739,7 @@ export const SchemaRenderer: ForwardRefExoticComponent< raw, reason, 'page-component', + gate, ), }); } @@ -705,6 +762,7 @@ export const SchemaRenderer: ForwardRefExoticComponent< raw, err, 'page-component', + gate, ); // The historical fail-soft answer, unchanged — and identical to what // the production branch above returns for the same input, which is what diff --git a/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx b/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx new file mode 100644 index 0000000000..5c9ba8c49d --- /dev/null +++ b/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx @@ -0,0 +1,534 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6503 — the fault diagnostic says something TRUE to an author whose + * node VANISHED. + * + * ## The defect this closes + * + * `evaluateCondition` answers an unevaluable predicate with `true` on every one + * of its paths. `SchemaRenderer`'s visibility chain NEGATES that answer on four + * of its six legs (`visibleWhen` / `visible` / `visibleOn` / `visibility`), so + * there the fail-soft default SHOWS the node and the reporter's sentence — "the + * gate did NOT bite" — is exactly right. The chain returns it UN-negated on the + * other two (`hidden` / `hiddenOn`): the same `true` sets `_hidden` and + * `SchemaRenderer` returns `null`. The gate bit, and bit harder than on either + * sibling gate — the node is not on screen at all — and those two legs were + * handed the sentence that says the opposite. + * + * Worse than a merely wrong sentence: it is wrong in the direction that costs + * the most. `SchemaRenderer`'s own #3862/#3955 comment names the asymmetry — "a + * greyed-out control is still on screen, a node that never rendered is + * indistinguishable from metadata that meant it". An author whose block + * vanished, told the gate did not bite, is sent to hunt a rendering bug that + * does not exist, past the one line that was supposed to name their predicate. + * + * ## ⭐ Why this file pins CONTENT, and why that is the whole point + * + * objectui#6038's suite already covers the `hidden` leg — "the `hidden` leg — + * the NON-negated polarity — reports too, and still hides". It pins that the + * line is EMITTED and that the line contains the KEY. It never pins what the + * line SAYS, which is precisely why this defect shipped through it, survived + * objectui#6487's rewrite of the same paragraph, and survived objectui#6510 + * writing the defect into the module's own docblock and printing it anyway. + * + * A pin that counts lines reproduces that blindness in new syntax. Every case + * below therefore asserts the CONSEQUENCE SENTENCE, byte for byte, in both + * directions: the sentence that must now appear, and the sentence that must no + * longer. And the emission pin is re-asserted here rather than assumed — the + * fix keeps `UNRESOLVABLE_VISIBILITY_PREFIX` on these legs deliberately, so + * objectui#6038's prefix-filtered view still catches them. + * + * ## The control that stops the cheap fix + * + * A fix that "improved" the copy for all six legs by making it vaguer would be + * green on the `hidden` cases alone. Group 2 pins the four NEGATED legs at + * their existing bytes, individually, so the only way to pass this file is to + * change the two legs that were wrong and leave the four that were right. + * + * ## Copy fix only — the verdict is NOT this card's to move + * + * The node still vanishes. That fail-soft is the shipped behaviour the + * neighbouring family (#3862 / #3955 / #6443 / #6487 / #6445) preserved + * deliberately. Every case here pins the VERDICT beside the line, so a run made + * green by flipping the gate fails this file. + * + * ## Reverse verification, and where the prediction was WRONG + * + * Predicted before running: restoring `visibilityDiagnostic.ts` and + * `SchemaRenderer.tsx` to the pinned base commit (`d7acad69d`, hash-verified + * on disk, never `origin/main` — a shared pointer another agent's fetch moves) + * turns groups 1, 3 and 5 RED and leaves 0, 2, 4 and 6 GREEN. + * + * MEASURED: 7 failed / 10 passed. Six of the seven are where predicted. The + * seventh is group 4's dedupe case, which the prediction had in the GREEN set + * and which fails on its closing `CONCEALMENT_CONSEQUENCE` assertion — the + * deliberate refusal to let a rate-limit case pin only a COUNT. The prediction + * was written per group; that one case spans two. The record is corrected here + * rather than restated, because a false claim about which cases guard what is + * the same class of defect this file exists to fix. + * + * Every RED is on a CONTENT assertion; not one is on a count or an emission. + * The base tree printed, for a node that had VANISHED: + * + * [ObjectUI] A visibility predicate could not be evaluated - node "…" + * hidden: "nosuchroot6503.locked == true" + * Reason: … nosuchroot6503 is not defined + * The node was treated as its safe default, which on this surface means the + * gate did NOT bite - … + * + * Group 2 is GREEN in BOTH states, and that is what a control for "unchanged" + * must be: it asserts the four negated legs' bytes, which neither the base tree + * nor the fixed one moves. It fails only against a fix that touched them. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import React from 'react'; +import { + UNRESOLVABLE_ENABLEMENT_PREFIX, + UNRESOLVABLE_VISIBILITY_PREFIX, +} from '../utils/visibilityDiagnostic'; + +const NAME = 'probe-6503'; +const TYPE = 'element:probe-6503'; + +/* -------------------------------------------------------------------------- * + * The two sentences, spelled out. Byte-for-byte copies of the `GATE_KIND_COPY` + * entries — deliberately duplicated rather than imported, because importing the + * table would make every assertion below a tautology that stays green no matter + * what the table is changed to say. + * -------------------------------------------------------------------------- */ + +/** What a VANISHED node's author must now read (objectui#6503). */ +const CONCEALMENT_CONSEQUENCE = + 'The node was treated as its safe default, which on THIS leg is the one\n' + + 'that BITES: `hidden` / `hiddenOn` are NOT negated, so that default\n' + + 'REMOVED the node - it is not on the page at all, and an absent node is\n' + + 'indistinguishable from metadata that meant to hide it. Nothing is broken\n' + + 'in the renderer: the block is missing because the predicate above could\n' + + 'not be evaluated.\n'; + +/** + * What the four NEGATED legs said before this card and must go on saying. The + * sentence is TRUE there, and vaguening it to cover all six would be the fix + * this file exists to refuse. + */ +const NEGATED_LEG_CONSEQUENCE = + 'The node was treated as its safe default, which on this surface means the\n' + + 'gate did NOT bite - a predicate that cannot be evaluated reads on screen\n' + + 'exactly like one that said yes.\n'; + +/** The clause the `hidden` legs used to carry, named so it can be pinned ABSENT. */ +const THE_FALSE_CLAUSE = 'gate did NOT bite'; + +/** A probe that renders only when the chain lets it — absence IS the verdict. */ +const Probe = () =>
; + +/** The ambient scope app-shell's `ExpressionProvider` really mounts. */ +const APP_SCOPE = { + current_user: { id: 'u1' }, + user: { id: 'u1' }, + app: {}, + data: {}, + features: {}, +}; +const ADAPTER = { total: 99 }; +const ROW = { id: 'r1', status: 'open' }; + +/** + * Faulting predicates — an unbound ROOT identifier, the shape objectstack#11254 + * hit and the shape an AI-authored predicate reaches for. + * + * A DISTINCT source per DEVELOPMENT case, for the reason objectui#6445's suite + * records: objectui#6444's `warnedEvaluationFaults` is module state on the + * STATIC graph, so a dev case reusing another's source can have the evaluator's + * own built-in line suppressed on its behalf, and a raw-total pin would then + * read one on a build that reported twice. `inProduction` gets a fresh module + * graph per case, so those may share. + */ +const FAULT_BARE = 'nosuchroot6503.locked == true'; +const FAULT_HIDDEN_DEV = 'devbadroot6503hidden.locked == true'; +const FAULT_HIDDENON_DEV = 'devbadroot6503hiddenon.locked == true'; +const FAULT_VISIBLEWHEN_DEV = 'devbadroot6503vw.locked == true'; +const FAULT_VISIBLE_DEV = 'devbadroot6503v.locked == true'; +const FAULT_VISIBLEON_DEV = 'devbadroot6503von.locked == true'; +const FAULT_VISIBILITY_DEV = 'devbadroot6503vy.locked == true'; +const FAULT_DISABLED_DEV = 'devbadroot6503dis.locked == true'; +const FAULT_TEMPLATE_HIDDEN = '${tmplbadroot6503.locked == true}'; +const FAULT_CEL_HIDDEN = { dialect: 'cel', source: 'record.bad6503(' }; + +type WarnSpy = { mock: { calls: unknown[][] } }; +const spyWarn = () => vi.spyOn(console, 'warn').mockImplementation(() => {}); +/** The VISIBILITY-prefix-filtered view — the same one objectui#6038 reads. */ +const reports = (warn: WarnSpy): string[] => + warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes(UNRESOLVABLE_VISIBILITY_PREFIX)); +/** The sibling gate's view, for the cross-gate control. */ +const enablementReports = (warn: WarnSpy): string[] => + warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes(UNRESOLVABLE_ENABLEMENT_PREFIX)); +const allWarnings = (warn: WarnSpy): string[] => warn.mock.calls.map((c) => String(c[0])); + +/** + * The one pre-existing line a DEVELOPMENT build prints beside these, and it is + * not ours: `validateSchemaOnce` runs in dev only and core's `BASE_SCHEMA_RULES` + * declares these gate keys booleans, so every EXPRESSION-valued one — the + * authoring form this whole family is about — is also reported as an invalid + * schema. A false positive predating this card, filed separately + * (objectui#6505). Named rather than absorbed into a loose assertion. + */ +const DEV_SCHEMA_VALIDATOR_NOISE = '[ObjectUI] Invalid schema detected:'; +const nonValidatorWarnings = (warn: WarnSpy): string[] => + allWarnings(warn).filter((m) => !m.includes(DEV_SCHEMA_VALIDATOR_NOISE)); + +/** + * Mount in a PRODUCTION module graph. + * + * `__DEV__` in `SchemaRenderer` is an IIFE evaluated at module load, so the env + * has to be stubbed before the import — hence `resetModules` and a dynamic + * import in the test BODY (the case `object-ui/no-dynamic-import-in-test-hook` + * exempts). The dedupe `Set` is module state of that same fresh graph, so the + * reset must come from the fresh graph too. + */ +async function inProduction( + fn: (mount: (schemas: Record[]) => void) => void | Promise, +): Promise { + vi.resetModules(); + vi.stubEnv('NODE_ENV', 'production'); + try { + const [core, prod, ctx, rec, expr, diag] = await Promise.all([ + import('@object-ui/core'), + import('../SchemaRenderer'), + import('../context/SchemaRendererContext'), + import('../context/RecordContext'), + import('../hooks/useExpression'), + import('../utils/visibilityDiagnostic'), + ]); + diag.__resetVisibilityPredicateWarnings(); + core.ComponentRegistry.register(NAME, Probe as never, { + namespace: 'element', + skipFallback: true, + } as never); + const mount = (schemas: Record[]) => + render( + + + + {schemas.map((s, i) => ( + + ))} + + + , + ); + await fn(mount); + core.ComponentRegistry.unregister?.(NAME, 'element'); + } finally { + cleanup(); + vi.unstubAllEnvs(); + vi.resetModules(); + } +} + +/** Mount in the ordinary (development) module graph. */ +async function inDevelopment( + fn: (mount: (schemas: Record[]) => void) => void | Promise, +): Promise { + const [core, dev, ctx, rec, expr, diag] = await Promise.all([ + import('@object-ui/core'), + import('../SchemaRenderer'), + import('../context/SchemaRendererContext'), + import('../context/RecordContext'), + import('../hooks/useExpression'), + import('../utils/visibilityDiagnostic'), + ]); + diag.__resetVisibilityPredicateWarnings(); + core.ComponentRegistry.register(NAME, Probe as never, { + namespace: 'element', + skipFallback: true, + } as never); + try { + const mount = (schemas: Record[]) => + render( + + + + {schemas.map((s, i) => ( + + ))} + + + , + ); + await fn(mount); + } finally { + core.ComponentRegistry.unregister?.(NAME, 'element'); + cleanup(); + } +} + +const shownCount = () => screen.queryAllByTestId('probe').length; + +beforeEach(() => { + vi.restoreAllMocks(); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +/* -------------------------------------------------------------------------- * + * Group 0 — the controls that decide whether the rest of the file means + * anything. Both are about the CAPTURE, not about the renderer. + * -------------------------------------------------------------------------- */ + +describe('#6503 group 0 — capture controls', () => { + it('POSITIVE CONTROL: the spy really observes a line carrying the visibility prefix', () => { + // Without this, every `toHaveLength(0)` below is equally green on a capture + // that observes nothing at all. + const warn = spyWarn(); + console.warn(`${UNRESOLVABLE_VISIBILITY_PREFIX} - synthetic control line 6503`); + expect(reports(warn)).toHaveLength(1); + }); + + it('DEGENERATE CONTROL: the two sentences are genuinely different strings', () => { + // A fix that made both entries identical would satisfy "the hidden leg + // prints X" and "the negated legs print X" at once. They must not be equal, + // and neither may contain the other. + expect(CONCEALMENT_CONSEQUENCE).not.toEqual(NEGATED_LEG_CONSEQUENCE); + expect(CONCEALMENT_CONSEQUENCE).not.toContain(THE_FALSE_CLAUSE); + expect(NEGATED_LEG_CONSEQUENCE).toContain(THE_FALSE_CLAUSE); + }); +}); + +/* -------------------------------------------------------------------------- * + * Group 1 — THE acceptance criterion. + * -------------------------------------------------------------------------- */ + +describe('#6503 group 1 — a faulting `hidden` says the node VANISHED, and it did', () => { + it('THE acceptance criterion: the consequence paragraph is the concealment one, NOT "the gate did NOT bite"', async () => { + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hidden: FAULT_BARE }]); + + // The VERDICT, pinned beside the copy: the node is gone. This card moves + // bytes an author reads, never what the gate does. + expect(shownCount()).toBe(0); + + const lines = reports(warn); + expect(lines).toHaveLength(1); + const line = lines[0]; + // Both directions. The first alone would pass on a build that printed + // BOTH sentences; the second alone would pass on one that printed none. + expect(line).toContain(CONCEALMENT_CONSEQUENCE); + expect(line).not.toContain(THE_FALSE_CLAUSE); + }); + }); + + it('the report still names the node, the key and the predicate — the fix moves ONE paragraph', async () => { + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hidden: FAULT_BARE }]); + const line = reports(warn)[0]; + expect(line).toContain(TYPE); + expect(line).toContain('hidden'); + expect(line).toContain(FAULT_BARE); + // And the tier advice below the consequence is untouched (objectui#6487). + expect(line).toContain('Page-component predicates bind `record`'); + }); + }); + + it('development prints the identical line — one message, not two that drift', async () => { + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hidden: FAULT_HIDDEN_DEV }]); + expect(shownCount()).toBe(0); + const lines = reports(warn); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain(CONCEALMENT_CONSEQUENCE); + expect(lines[0]).not.toContain(THE_FALSE_CLAUSE); + // Nothing beyond our line and the known dev-only validator noise. + expect(nonValidatorWarnings(warn).length).toBeGreaterThan(0); + }); + }); + + it('the CEL dialect reaches the same paragraph — the copy is per GATE, not per dialect', async () => { + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hidden: FAULT_CEL_HIDDEN }]); + expect(shownCount()).toBe(0); + const line = reports(warn)[0]; + expect(line).toBeDefined(); + expect(line).toContain(CONCEALMENT_CONSEQUENCE); + expect(line).not.toContain(THE_FALSE_CLAUSE); + }); + }); +}); + +/* -------------------------------------------------------------------------- * + * Group 2 — THE CONTROL. The four negated legs must be UNCHANGED. + * -------------------------------------------------------------------------- */ + +describe('#6503 group 2 — the four NEGATED legs keep their sentence, byte for byte', () => { + // Each leg individually, not "one of them": `shouldHide` is an early-return + // chain, so a single mount can only ever exercise the FIRST declared leg. A + // loop over one schema would have measured `visibleWhen` four times. + const NEGATED: Array<[string, string]> = [ + ['visibleWhen', FAULT_VISIBLEWHEN_DEV], + ['visible', FAULT_VISIBLE_DEV], + ['visibleOn', FAULT_VISIBLEON_DEV], + ['visibility', FAULT_VISIBILITY_DEV], + ]; + + for (const [key, source] of NEGATED) { + it(`\`${key}\` still says the gate did NOT bite — and the node is still SHOWN`, async () => { + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', [key]: source }]); + // The fail-soft answer these four legs have always given. + expect(shownCount()).toBe(1); + const lines = reports(warn); + expect(lines).toHaveLength(1); + const line = lines[0]; + expect(line).toContain(key); + // The sentence that is TRUE here, in full. A fix that made the copy + // vaguer to cover all six legs fails on this line. + expect(line).toContain(NEGATED_LEG_CONSEQUENCE); + // And the concealment copy must NOT leak onto a leg that shows. + expect(line).not.toContain(CONCEALMENT_CONSEQUENCE); + expect(line).not.toContain('REMOVED the node'); + }); + }); + } +}); + +/* -------------------------------------------------------------------------- * + * Group 3 — `hiddenOn`, the second non-negated leg. + * -------------------------------------------------------------------------- */ + +describe('#6503 group 3 — `hiddenOn` is the same gate and gets the same sentence', () => { + it('a faulting `hiddenOn` prints the concealment paragraph and the node vanishes', async () => { + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hiddenOn: FAULT_BARE }]); + expect(shownCount()).toBe(0); + const line = reports(warn)[0]; + expect(line).toBeDefined(); + expect(line).toContain('hiddenOn'); + expect(line).toContain(CONCEALMENT_CONSEQUENCE); + expect(line).not.toContain(THE_FALSE_CLAUSE); + }); + }); + + it('development agrees on `hiddenOn` too', async () => { + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hiddenOn: FAULT_HIDDENON_DEV }]); + expect(shownCount()).toBe(0); + expect(reports(warn)[0]).toContain(CONCEALMENT_CONSEQUENCE); + }); + }); +}); + +/* -------------------------------------------------------------------------- * + * Group 4 — the objectui#6038 pins this card must NOT break. + * -------------------------------------------------------------------------- */ + +describe('#6503 group 4 — emission and rate limit are unchanged', () => { + it('the `hidden` leg still carries the VISIBILITY prefix — objectui#6038 reads through that filter', async () => { + // The routing decision restated as a pin. A third gate kind with a third + // PREFIX would have been the tidy-looking change that silently emptied + // objectui#6038's prefix-filtered view (and every app console filter that + // hard-codes the constant). It is a visibility predicate; only the + // consequence sentence differs. + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hidden: FAULT_BARE }]); + expect(shownCount()).toBe(0); + const lines = reports(warn); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('hidden'); + expect(lines[0].startsWith(UNRESOLVABLE_VISIBILITY_PREFIX)).toBe(true); + // No line escaped the prefix-filtered view. + expect(allWarnings(warn)).toHaveLength(1); + }); + }); + + it('still ONE line for many nodes sharing a faulting `hidden`, and a SECOND distinct source still reports', async () => { + // Both halves together — "a warning was emitted" is green on fifty, and + // "exactly one" is green on an implementation that suppresses everything. + await inProduction((mount) => { + const warn = spyWarn(); + mount([ + { id: 'a', hidden: FAULT_BARE }, + { id: 'b', hidden: FAULT_BARE }, + { id: 'c', hidden: FAULT_BARE }, + ]); + expect(shownCount()).toBe(0); + expect(reports(warn)).toHaveLength(1); + mount([{ id: 'd', hidden: 'anotherbadroot6503.frozen == true' }]); + expect(reports(warn)).toHaveLength(2); + expect(reports(warn)[1]).toContain(CONCEALMENT_CONSEQUENCE); + }); + }); + + it('a HEALTHY `hidden` predicate stays silent — the paired control', async () => { + // Without this, "always print the concealment paragraph" satisfies group 1. + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hidden: "record.status == 'open'" }]); + expect(shownCount()).toBe(0); // a REAL hide, not a fault + expect(reports(warn)).toHaveLength(0); + }); + }); +}); + +/* -------------------------------------------------------------------------- * + * Group 5 — the DYNAMIC routing site (objectui#5756). + * -------------------------------------------------------------------------- */ + +describe('#6503 group 5 — the winning-key report site routes by polarity too', () => { + it('a `${…}` template in `properties.hidden` gets the concealment paragraph', async () => { + // This is the one call site that cannot state its gate as a literal: it + // passes whatever `winningVisibilityKey` returned. A fix that only touched + // the six literal legs in `shouldHide` would leave this path printing the + // old sentence for the same authoring mistake in a different spelling. + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', properties: { hidden: FAULT_TEMPLATE_HIDDEN } }]); + const line = reports(warn)[0]; + expect(line).toBeDefined(); + expect(line).toContain('hidden'); + expect(line).toContain(CONCEALMENT_CONSEQUENCE); + expect(line).not.toContain(THE_FALSE_CLAUSE); + }); + }); +}); + +/* -------------------------------------------------------------------------- * + * Group 6 — the sibling gate is untouched. + * -------------------------------------------------------------------------- */ + +describe('#6503 group 6 — the enablement gate keeps its own paragraph', () => { + it('a faulting `disabled` still gets the enablement copy, not the concealment one', async () => { + // Three gate kinds now share one reporter. This pins that widening the + // union did not re-route the gate that was already correct. + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', disabled: FAULT_DISABLED_DEV }]); + expect(shownCount()).toBe(1); // greyed out, not gone + const line = enablementReports(warn)[0]; + expect(line).toBeDefined(); + expect(line).toContain('the node renders DISABLED'); + expect(line).not.toContain(CONCEALMENT_CONSEQUENCE); + expect(line).not.toContain(THE_FALSE_CLAUSE); + // And no visibility-prefixed line was emitted for it. + expect(reports(warn)).toHaveLength(0); + }); + }); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 5d48422cf6..477f8e1275 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -48,6 +48,15 @@ export type { PredicateScopeTier } from './utils/visibilityDiagnostic.js'; // decides whether the message says the safe default bit or did not. Exported as // a type for the same reason as the tier above: the reporter is public, so a // caller has to be able to spell its arguments. +// +// ⚠️ THREE members since objectui#6503, not two. `'concealment'` was added for +// the `hidden` / `hiddenOn` legs, whose verdict `SchemaRenderer` does NOT +// negate — the same fail-soft `true` that shows a node on the four negated legs +// REMOVES it there, so "the gate did NOT bite" was the one sentence that could +// not be true for them. Widening a union re-exported from this entry is a +// type-level change for any consumer that switches EXHAUSTIVELY over it or +// keys a `Record` by it; no runtime signature moved, and every value that was +// accepted before still is. export type { PredicateGateKind } from './utils/visibilityDiagnostic.js'; // Write-error surfacing utilities (shared by drag-write plugins so a failed diff --git a/packages/react/src/utils/visibilityDiagnostic.ts b/packages/react/src/utils/visibilityDiagnostic.ts index f58b16091d..03e71a4e82 100644 --- a/packages/react/src/utils/visibilityDiagnostic.ts +++ b/packages/react/src/utils/visibilityDiagnostic.ts @@ -188,17 +188,34 @@ const SCOPE_TIER_ADVICE: Record = { * other gate. That is the exact defect class this card and objectui#6487 both * exist to remove, so it is not worth re-introducing to save an argument. * - * ## What this type does NOT fix, stated so it is not mistaken for done - * - * The `hidden` / `hiddenOn` legs are `'visibility'` here, and their `true` is - * NOT negated - a fault there makes the node VANISH, so "the gate did NOT bite" - * is wrong for them as well. That is PRE-EXISTING shipped copy on a surface - * objectui#6487 has just landed on, outside this card's face and filed - * separately (objectui#6503). The shape of its fix is a THIRD entry in the table - * below - visibility prefix, vanishing consequence - passed by those two call - * sites; not a signature change, and deliberately not smuggled in here. + * `'concealment'` - `hidden` / `hiddenOn` (objectui#6503). These are visibility + * legs, but the only two whose verdict `SchemaRenderer` does NOT negate, so the + * SAME `true` that SHOWS a node on the four negated legs REMOVES it here. They + * resolved to `'visibility'` until objectui#6503 and were therefore handed the + * one sentence that is false about them: the gate did not merely bite, it bit + * hardest of the three, and the node is not on screen to be inspected at all. + * This is the third member objectui#6445's docblock predicted, in the shape it + * predicted - a table entry and a routing decision, no signature change. + * + * ## Why a third MEMBER, and not `'visibility'` plus a `negated` flag + * + * What varies is the consequence sentence, and it varies per GATE - not along + * an independent axis a caller could set to contradict the gate it passed + * alongside. Three flat members cannot be spelled inconsistently; a + * `(kind, negated)` pair can, and `('enablement', negated: true)` would name a + * combination no renderer in this repo produces. The table stays the one place + * that decides what a fail-soft default DID. + * + * ## What widening this union costs, stated because it is a published surface + * + * `PredicateGateKind` is re-exported from `packages/react/src/index.ts` - the + * package entry, and the chain stops there (no sibling package re-exports it, + * and no file outside `packages/react` names it). A consumer switching + * EXHAUSTIVELY over the union, or holding a `Record`, + * gains a third case to answer. That is a type-level change only: no runtime + * signature moved, and every value that was accepted before still is. */ -export type PredicateGateKind = 'visibility' | 'enablement'; +export type PredicateGateKind = 'visibility' | 'enablement' | 'concealment'; /** * The two parts of the message that vary with the gate: the opening line (what @@ -221,6 +238,23 @@ const GATE_KIND_COPY: Record