From 86a9cd67805700b40514b0fa0269d9b0cd8707cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 12:18:42 +0000 Subject: [PATCH] =?UTF-8?q?fix(console):=20binding-reach=20=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E5=B0=91=E6=8A=A5=E4=BA=86=E8=87=AA=E5=B7=B1=206=20?= =?UTF-8?q?=E4=B8=AA=E5=9D=97=E7=9A=84=E8=A6=86=E7=9B=96=E9=9D=A2=EF=BC=8C?= =?UTF-8?q?=E8=80=8C=E4=B8=94=E6=98=AF=E9=9D=99=E9=BB=98=E7=9A=84=20(#3149?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `public-block-binding-reach.test.tsx` 靠 `getPublicConfigs()` 里"有没有 objectName 输入"来选候选。但 console 对多数对象块走 `registerLazy`,而 pending stub 不带 `inputs`——`Registry.getMeta` 的注释原话是:consumer 应当把这当成"还不知道",**不是** "没声明"。过滤器当成了后者,于是 `object-chart` / `object-kanban` / `object-calendar` / `object-gantt` / `object-timeline` / `object-map` 六个 Tier A 块 (每一个都把 `objectName` 声明为 required)整批落出候选集,而套件报告"八个探针全绿、 无缺口"。 这是 objectui#2953 的形状(lazy 注册悄悄掉出契约)在消费端复发,也是 objectstack#4472 的形状在那个**为了回答它而写的套件**内部复发:声明的作用域比实际够得着的范围宽。原来 的覆盖守卫看不见——`length > 0` 和 `toContain('object-form')` 在 8/14 的时候都是真的。 - 候选筛选前,先经注册表自己的 `loadLazy` 解析所有 pending 的 public lazy 条目。用注册表 记录的 loader,不是在测试里手写一份插件 import 清单——那份清单会和 `register-plugins.ts` 脱节,换条路把同样的缩水再引入一次。 - 守卫改成**精确**候选清单,照搬 `public-contract.test.ts` 已经写明的教训:失败模式是集合 变小,只有精确比较能让两个方向都成为 deliberate edit。已用"关掉解析"模拟回归验证:断言 变红并列出缺失的块。 六个块的接线本来就是对的(`ObjectChart` 自己读 context;gantt/timeline/map 与 kanban/calendar 有 context→prop wrapper),所以没有发现新的 #3144 类缺陷。倒是又抓出两个 探针自身的假信号——同一个教训的第三、第四次:给每个输入一个合理值,不等于一份合理的**配置**。 - **`data` 会盖掉绑定**。`ObjectChart` 的取数判据是 `if ((schema.objectName || schema.dataset) && !boundData && !schema.data)`,而 spec 对 `data` 的注释就是"直接图示静态数据,**取代**经 objectName 绑定"。填了它再报告 "objectName 没到达数据层",等于探针自造发现。现在把这类"盖掉绑定"的输入排除,范围很窄, 并且把判据原文抄在注释里,让下一个读的人能核而不是只能信。 - **卸载不是被测对象**。`object-map` 挂 maplibre-gl,其 `map.remove()` 在 jsdom 里因为没有 WebGL context 而抛异常。现在 unmount 被 catch,断言只讲数据到达;**渲染**期抛的异常仍然 照常传播。 覆盖面:14/14 个对象绑定 public block。#3149 的其余部分——objectName 之外的绑定、 record:* 家族在 record context 下、以及展示原语——未动,仍然开着。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01S3cP1eY1novcNhQEDBrSZD --- .../binding-reach-lazy-candidate-coverage.md | 46 +++++++ .../public-block-binding-reach.test.tsx | 128 ++++++++++++++++-- 2 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 .changeset/binding-reach-lazy-candidate-coverage.md diff --git a/.changeset/binding-reach-lazy-candidate-coverage.md b/.changeset/binding-reach-lazy-candidate-coverage.md new file mode 100644 index 000000000..5a6785d49 --- /dev/null +++ b/.changeset/binding-reach-lazy-candidate-coverage.md @@ -0,0 +1,46 @@ +--- +"@object-ui/console": patch +--- + +The binding-reach probe was under-reporting its own coverage by six object blocks, silently (#3149). + +`public-block-binding-reach.test.tsx` selects what to probe by filtering `getPublicConfigs()` +for a declared `objectName` input. The console registers most object blocks with +`registerLazy`, and a pending stub carries no `inputs` — `Registry.getMeta` says in as many +words that a consumer must read that as *"not yet known"*, not as *"declares no props"*. The +filter read it as the latter, so `object-chart`, `object-kanban`, `object-calendar`, +`object-gantt`, `object-timeline` and `object-map` — six Tier-A blocks, every one declaring +`objectName` as **required** — dropped out of the candidate set while the suite reported eight +green probes and no gap. + +That is objectui#2953's shape (a lazy registration falling out of the contract) recurring in a +consumer, and objectstack#4472's shape recurring inside the suite written to answer it: a gate +whose stated scope was wider than its reach. The coverage guard could not see it — `length > 0` +and `toContain('object-form')` both stayed true at 8 of 14. + +- Pending public lazy loaders are resolved through the registry's own `loadLazy` before + candidates are selected — driven off the recorded loaders, not a hand-written list of plugin + imports that would drift out of step with `register-plugins.ts` and reintroduce the same + shrinkage by another route. +- The guard is now an **exact** candidate list, the lesson `public-contract.test.ts` already + carries: the failure mode is a set getting smaller, and only an exact comparison makes both + directions a deliberate edit. Verified by simulating the regression — with resolution + disabled the assertion fails naming the missing blocks. + +All six were already wired correctly (`ObjectChart` reads the context itself; gantt/timeline/map +and kanban/calendar have context→prop wrappers), so this found no new defect of the #3144 kind. +It found two more probe artifacts, which is the same lesson a third and fourth time — a +plausible value for every input is not a plausible *configuration*: + +- **`data` supersedes the binding.** `ObjectChart`'s fetch is guarded by + `if ((schema.objectName || schema.dataset) && !boundData && !schema.data)`, and the spec + glosses `data` as static data to chart *instead of* binding via `objectName`. Filling it and + then reporting "objectName never reached" would have been the probe manufacturing its own + finding. Binding-superseding inputs are now excluded, narrowly and with the guard quoted. +- **Teardown is not the subject.** `object-map` mounts maplibre-gl, whose `map.remove()` throws + in jsdom for want of a WebGL context. Unmount is caught so the assertion speaks to data reach; + an error thrown during *render* still propagates. + +Coverage after this: 14 of 14 object-bound public blocks. The rest of #3149 — bindings other +than `objectName`, the `record:*` family under a record context, and the display primitives — +is untouched and still open. diff --git a/apps/console/src/__tests__/public-block-binding-reach.test.tsx b/apps/console/src/__tests__/public-block-binding-reach.test.tsx index d5f3e90cd..5b85ff5fc 100644 --- a/apps/console/src/__tests__/public-block-binding-reach.test.tsx +++ b/apps/console/src/__tests__/public-block-binding-reach.test.tsx @@ -50,6 +50,12 @@ * non-reaching block carries a written reason, and the ledger is asserted to * equal the observed set in BOTH directions, so a block that starts reaching * must be removed from it and a block that stops reaching fails here. + * + * The other half of "not over-read" is COVERAGE, and it has to be asserted + * rather than assumed — see {@link EXPECTED_CANDIDATES}. This suite's first + * release under-reported its own scope by 6 of 14 object-bound blocks and said + * nothing (objectui#3149), which is the same shape as the gate it was written to + * compensate for: a claim wider than the thing behind it. */ import { describe, it, expect } from 'vitest'; @@ -122,10 +128,101 @@ const NO_DATA_REACH: Readonly> = { // objectstack#4413 ship. }; +/** + * Every public block this suite must probe, exactly. + * + * An exact list rather than a floor, because the failure mode is a SHRINKING + * candidate set — and that is not hypothetical, it is what shipped + * (objectui#3149). The console registers most object blocks with + * `registerLazy`, and a pending stub carries no `inputs`: `Registry.getMeta` + * says in as many words that consumers must treat that as *"not yet known"*, + * not as *"declares no props"*. The first release of this file filtered on + * `inputs` directly and so silently dropped `object-chart`, `object-kanban`, + * `object-calendar`, `object-gantt`, `object-timeline` and `object-map` — six + * Tier-A object blocks, every one of them declaring `objectName` as + * **required** — while reporting eight green probes and no gap. + * + * That is objectui#2953's shape (a lazy registration falling out of the + * contract) recurring one layer up, in the consumer this time; and it is + * objectstack#4472's shape recurring in the very suite written to answer it — a + * gate whose stated scope was wider than its actual reach. The lesson both + * times is the same one `public-contract.test.ts` already carries: a `toContain` + * or a `length > 0` sails straight past a set that quietly got smaller. Only an + * exact list makes both directions a deliberate edit. + */ +const EXPECTED_CANDIDATES = [ + 'object-grid', + 'list-view', + 'object-form', + 'embeddable-form', + 'object-master-detail-form', + 'object-metric', + 'object-pivot', + 'record:related_list', + // The six objectui#3149 restored. Lazily registered by the console, so they + // only surface here once their loaders have run (see resolveLazyPublicBlocks). + 'object-chart', + 'object-kanban', + 'object-calendar', + 'object-gantt', + 'object-timeline', + 'object-map', +]; + +/** + * Run every pending public lazy loader, so `getPublicConfigs()` reports each + * block's real `inputs` instead of a stub's absent ones. + * + * Driven off the registry's OWN recorded loaders rather than a hand-written list + * of plugin imports: a list here would drift out of step with + * `register-plugins.ts` and reintroduce the same silent shrinkage by a different + * route. It also keeps the registry mutation scoped to this file — loading via + * `loadLazy` is what the app itself does on first use, not a test-only override + * of what is registered. + */ +async function resolveLazyPublicBlocks(): Promise { + const pending = ComponentRegistry.getPublicConfigs().filter((c) => c.lazy); + await Promise.all( + pending.map((c) => { + // A lazy entry is keyed under both its bare tag and `namespace:tag`; + // `getPublicConfigs` reports the canonical (namespaced) one. + const bare = c.type.includes(':') ? c.type.slice(c.type.indexOf(':') + 1) : c.type; + return ( + ComponentRegistry.loadLazy(c.type) ?? + ComponentRegistry.loadLazy(bare) ?? + Promise.resolve() + ); + }), + ); +} + +await resolveLazyPublicBlocks(); + /** Does this config declare an `objectName` input? */ const declaresObjectName = (cfg: { inputs?: Array<{ name?: string }> }) => (cfg.inputs ?? []).some((i) => i?.name === 'objectName'); +/** + * Inputs that SUPERSEDE the `objectName` binding — filling them is the author + * telling the block not to fetch, so the probe must leave them unset. + * + * Narrow and reasoned, not a convenience escape hatch. `data` is the documented + * alternative data source: @objectstack/spec's react overlay glosses it as + * *"static/precomputed data to chart directly **instead of** binding via + * objectName + aggregate"*, and `ObjectChart`'s fetch is guarded by + * `if ((schema.objectName || schema.dataset) && !boundData && !schema.data)`. + * Filling it and then reporting "objectName never reached the data layer" would + * be the probe manufacturing its own finding — the same mistake as seeding + * `columns: []` (which makes a list render its empty state without fetching) or + * spreading a bare Proxy (which strips a derived source of every method). Three + * instances of one lesson: a plausible value for EVERY input is not the same as + * a plausible CONFIGURATION. + * + * Add to this list only with the guard quoted, so the next reader can check the + * claim instead of trusting it. + */ +const SUPERSEDES_BINDING = new Set(['data']); + /** * A plausible value for one declared input. * @@ -185,14 +282,12 @@ async function dataCallsFor(cfg: any): Promise { get: (target, key: string) => (key in target ? (target as any)[key] : record(key)), }); - // Only `objectName` + the inputs the block declares REQUIRED. A bogus value - // for an optional input is a degenerate config that can make a block bail out - // before it ever asks for data — which would read here as a false finding. // EVERY declared input, not just the required ones — see the file header: a // read path can be gated on an optional input, and leaving it out would report // a block as unbound when it was simply never asked to fetch. const schema: Record = { type: cfg.type }; for (const input of cfg.inputs ?? []) { + if (SUPERSEDES_BINDING.has(input.name)) continue; schema[input.name] = sampleFor(input); } @@ -208,18 +303,33 @@ async function dataCallsFor(cfg: any): Promise { await new Promise((resolve) => setTimeout(resolve, 50)); }); } - view.unmount(); + // Teardown is not the subject. `object-map` mounts maplibre-gl, whose + // `map.remove()` throws in jsdom because there is no WebGL context to release + // — a fact about the DOM implementation, not about whether the block bound to + // its object. Every call it made is already recorded above, so swallow the + // unmount and let the assertion speak to the data reach. Deliberately scoped + // to unmount: an error thrown during RENDER still propagates and fails. + try { + view.unmount(); + } catch { + /* see above */ + } return calls; } const candidates = ComponentRegistry.getPublicConfigs().filter(declaresObjectName); describe('public blocks — a declared objectName reaches the data layer (objectstack#4472)', () => { - it('finds the object-bound blocks to probe (guards against probing nothing)', () => { - // If the registry ever stops exposing these, this suite would pass by - // probing an empty list — the failure mode a coverage gate must not have. - expect(candidates.length).toBeGreaterThan(0); - expect(candidates.map((c) => c.type)).toContain('object-form'); + it('probes exactly the public blocks that declare an objectName (objectui#3149)', () => { + // The guard this replaces was `length > 0` + `toContain('object-form')`, + // which is precisely the shape that let six blocks fall out unnoticed: both + // assertions stayed true the whole time the candidate set was 8 instead of + // 14. A gate that cannot report its own coverage shrinking is not reporting + // coverage. + // + // A block appearing here is additive and cheap to accept; a block + // DISAPPEARING is the regression, and only the exact comparison catches it. + expect([...candidates.map((c) => c.type)].sort()).toEqual([...EXPECTED_CANDIDATES].sort()); }); for (const cfg of candidates) {