Skip to content

Commit 1ce750b

Browse files
os-zhuangclaude
andauthored
fix(console): binding-reach 探针少报了自己 6 个块的覆盖面,而且是静默的 (#3149) (#3153)
`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 下、以及展示原语——未动,仍然开着。 Claude-Session: https://claude.ai/code/session_01S3cP1eY1novcNhQEDBrSZD Co-authored-by: Claude <noreply@anthropic.com>
1 parent efd7767 commit 1ce750b

2 files changed

Lines changed: 165 additions & 9 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@object-ui/console": patch
3+
---
4+
5+
The binding-reach probe was under-reporting its own coverage by six object blocks, silently (#3149).
6+
7+
`public-block-binding-reach.test.tsx` selects what to probe by filtering `getPublicConfigs()`
8+
for a declared `objectName` input. The console registers most object blocks with
9+
`registerLazy`, and a pending stub carries no `inputs``Registry.getMeta` says in as many
10+
words that a consumer must read that as *"not yet known"*, not as *"declares no props"*. The
11+
filter read it as the latter, so `object-chart`, `object-kanban`, `object-calendar`,
12+
`object-gantt`, `object-timeline` and `object-map` — six Tier-A blocks, every one declaring
13+
`objectName` as **required** — dropped out of the candidate set while the suite reported eight
14+
green probes and no gap.
15+
16+
That is objectui#2953's shape (a lazy registration falling out of the contract) recurring in a
17+
consumer, and objectstack#4472's shape recurring inside the suite written to answer it: a gate
18+
whose stated scope was wider than its reach. The coverage guard could not see it — `length > 0`
19+
and `toContain('object-form')` both stayed true at 8 of 14.
20+
21+
- Pending public lazy loaders are resolved through the registry's own `loadLazy` before
22+
candidates are selected — driven off the recorded loaders, not a hand-written list of plugin
23+
imports that would drift out of step with `register-plugins.ts` and reintroduce the same
24+
shrinkage by another route.
25+
- The guard is now an **exact** candidate list, the lesson `public-contract.test.ts` already
26+
carries: the failure mode is a set getting smaller, and only an exact comparison makes both
27+
directions a deliberate edit. Verified by simulating the regression — with resolution
28+
disabled the assertion fails naming the missing blocks.
29+
30+
All six were already wired correctly (`ObjectChart` reads the context itself; gantt/timeline/map
31+
and kanban/calendar have context→prop wrappers), so this found no new defect of the #3144 kind.
32+
It found two more probe artifacts, which is the same lesson a third and fourth time — a
33+
plausible value for every input is not a plausible *configuration*:
34+
35+
- **`data` supersedes the binding.** `ObjectChart`'s fetch is guarded by
36+
`if ((schema.objectName || schema.dataset) && !boundData && !schema.data)`, and the spec
37+
glosses `data` as static data to chart *instead of* binding via `objectName`. Filling it and
38+
then reporting "objectName never reached" would have been the probe manufacturing its own
39+
finding. Binding-superseding inputs are now excluded, narrowly and with the guard quoted.
40+
- **Teardown is not the subject.** `object-map` mounts maplibre-gl, whose `map.remove()` throws
41+
in jsdom for want of a WebGL context. Unmount is caught so the assertion speaks to data reach;
42+
an error thrown during *render* still propagates.
43+
44+
Coverage after this: 14 of 14 object-bound public blocks. The rest of #3149 — bindings other
45+
than `objectName`, the `record:*` family under a record context, and the display primitives —
46+
is untouched and still open.

apps/console/src/__tests__/public-block-binding-reach.test.tsx

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@
5050
* non-reaching block carries a written reason, and the ledger is asserted to
5151
* equal the observed set in BOTH directions, so a block that starts reaching
5252
* must be removed from it and a block that stops reaching fails here.
53+
*
54+
* The other half of "not over-read" is COVERAGE, and it has to be asserted
55+
* rather than assumed — see {@link EXPECTED_CANDIDATES}. This suite's first
56+
* release under-reported its own scope by 6 of 14 object-bound blocks and said
57+
* nothing (objectui#3149), which is the same shape as the gate it was written to
58+
* compensate for: a claim wider than the thing behind it.
5359
*/
5460

5561
import { describe, it, expect } from 'vitest';
@@ -122,10 +128,101 @@ const NO_DATA_REACH: Readonly<Record<string, string>> = {
122128
// objectstack#4413 ship.
123129
};
124130

131+
/**
132+
* Every public block this suite must probe, exactly.
133+
*
134+
* An exact list rather than a floor, because the failure mode is a SHRINKING
135+
* candidate set — and that is not hypothetical, it is what shipped
136+
* (objectui#3149). The console registers most object blocks with
137+
* `registerLazy`, and a pending stub carries no `inputs`: `Registry.getMeta`
138+
* says in as many words that consumers must treat that as *"not yet known"*,
139+
* not as *"declares no props"*. The first release of this file filtered on
140+
* `inputs` directly and so silently dropped `object-chart`, `object-kanban`,
141+
* `object-calendar`, `object-gantt`, `object-timeline` and `object-map` — six
142+
* Tier-A object blocks, every one of them declaring `objectName` as
143+
* **required** — while reporting eight green probes and no gap.
144+
*
145+
* That is objectui#2953's shape (a lazy registration falling out of the
146+
* contract) recurring one layer up, in the consumer this time; and it is
147+
* objectstack#4472's shape recurring in the very suite written to answer it — a
148+
* gate whose stated scope was wider than its actual reach. The lesson both
149+
* times is the same one `public-contract.test.ts` already carries: a `toContain`
150+
* or a `length > 0` sails straight past a set that quietly got smaller. Only an
151+
* exact list makes both directions a deliberate edit.
152+
*/
153+
const EXPECTED_CANDIDATES = [
154+
'object-grid',
155+
'list-view',
156+
'object-form',
157+
'embeddable-form',
158+
'object-master-detail-form',
159+
'object-metric',
160+
'object-pivot',
161+
'record:related_list',
162+
// The six objectui#3149 restored. Lazily registered by the console, so they
163+
// only surface here once their loaders have run (see resolveLazyPublicBlocks).
164+
'object-chart',
165+
'object-kanban',
166+
'object-calendar',
167+
'object-gantt',
168+
'object-timeline',
169+
'object-map',
170+
];
171+
172+
/**
173+
* Run every pending public lazy loader, so `getPublicConfigs()` reports each
174+
* block's real `inputs` instead of a stub's absent ones.
175+
*
176+
* Driven off the registry's OWN recorded loaders rather than a hand-written list
177+
* of plugin imports: a list here would drift out of step with
178+
* `register-plugins.ts` and reintroduce the same silent shrinkage by a different
179+
* route. It also keeps the registry mutation scoped to this file — loading via
180+
* `loadLazy` is what the app itself does on first use, not a test-only override
181+
* of what is registered.
182+
*/
183+
async function resolveLazyPublicBlocks(): Promise<void> {
184+
const pending = ComponentRegistry.getPublicConfigs().filter((c) => c.lazy);
185+
await Promise.all(
186+
pending.map((c) => {
187+
// A lazy entry is keyed under both its bare tag and `namespace:tag`;
188+
// `getPublicConfigs` reports the canonical (namespaced) one.
189+
const bare = c.type.includes(':') ? c.type.slice(c.type.indexOf(':') + 1) : c.type;
190+
return (
191+
ComponentRegistry.loadLazy(c.type) ??
192+
ComponentRegistry.loadLazy(bare) ??
193+
Promise.resolve()
194+
);
195+
}),
196+
);
197+
}
198+
199+
await resolveLazyPublicBlocks();
200+
125201
/** Does this config declare an `objectName` input? */
126202
const declaresObjectName = (cfg: { inputs?: Array<{ name?: string }> }) =>
127203
(cfg.inputs ?? []).some((i) => i?.name === 'objectName');
128204

205+
/**
206+
* Inputs that SUPERSEDE the `objectName` binding — filling them is the author
207+
* telling the block not to fetch, so the probe must leave them unset.
208+
*
209+
* Narrow and reasoned, not a convenience escape hatch. `data` is the documented
210+
* alternative data source: @objectstack/spec's react overlay glosses it as
211+
* *"static/precomputed data to chart directly **instead of** binding via
212+
* objectName + aggregate"*, and `ObjectChart`'s fetch is guarded by
213+
* `if ((schema.objectName || schema.dataset) && !boundData && !schema.data)`.
214+
* Filling it and then reporting "objectName never reached the data layer" would
215+
* be the probe manufacturing its own finding — the same mistake as seeding
216+
* `columns: []` (which makes a list render its empty state without fetching) or
217+
* spreading a bare Proxy (which strips a derived source of every method). Three
218+
* instances of one lesson: a plausible value for EVERY input is not the same as
219+
* a plausible CONFIGURATION.
220+
*
221+
* Add to this list only with the guard quoted, so the next reader can check the
222+
* claim instead of trusting it.
223+
*/
224+
const SUPERSEDES_BINDING = new Set(['data']);
225+
129226
/**
130227
* A plausible value for one declared input.
131228
*
@@ -185,14 +282,12 @@ async function dataCallsFor(cfg: any): Promise<string[]> {
185282
get: (target, key: string) => (key in target ? (target as any)[key] : record(key)),
186283
});
187284

188-
// Only `objectName` + the inputs the block declares REQUIRED. A bogus value
189-
// for an optional input is a degenerate config that can make a block bail out
190-
// before it ever asks for data — which would read here as a false finding.
191285
// EVERY declared input, not just the required ones — see the file header: a
192286
// read path can be gated on an optional input, and leaving it out would report
193287
// a block as unbound when it was simply never asked to fetch.
194288
const schema: Record<string, unknown> = { type: cfg.type };
195289
for (const input of cfg.inputs ?? []) {
290+
if (SUPERSEDES_BINDING.has(input.name)) continue;
196291
schema[input.name] = sampleFor(input);
197292
}
198293

@@ -208,18 +303,33 @@ async function dataCallsFor(cfg: any): Promise<string[]> {
208303
await new Promise((resolve) => setTimeout(resolve, 50));
209304
});
210305
}
211-
view.unmount();
306+
// Teardown is not the subject. `object-map` mounts maplibre-gl, whose
307+
// `map.remove()` throws in jsdom because there is no WebGL context to release
308+
// — a fact about the DOM implementation, not about whether the block bound to
309+
// its object. Every call it made is already recorded above, so swallow the
310+
// unmount and let the assertion speak to the data reach. Deliberately scoped
311+
// to unmount: an error thrown during RENDER still propagates and fails.
312+
try {
313+
view.unmount();
314+
} catch {
315+
/* see above */
316+
}
212317
return calls;
213318
}
214319

215320
const candidates = ComponentRegistry.getPublicConfigs().filter(declaresObjectName);
216321

217322
describe('public blocks — a declared objectName reaches the data layer (objectstack#4472)', () => {
218-
it('finds the object-bound blocks to probe (guards against probing nothing)', () => {
219-
// If the registry ever stops exposing these, this suite would pass by
220-
// probing an empty list — the failure mode a coverage gate must not have.
221-
expect(candidates.length).toBeGreaterThan(0);
222-
expect(candidates.map((c) => c.type)).toContain('object-form');
323+
it('probes exactly the public blocks that declare an objectName (objectui#3149)', () => {
324+
// The guard this replaces was `length > 0` + `toContain('object-form')`,
325+
// which is precisely the shape that let six blocks fall out unnoticed: both
326+
// assertions stayed true the whole time the candidate set was 8 instead of
327+
// 14. A gate that cannot report its own coverage shrinking is not reporting
328+
// coverage.
329+
//
330+
// A block appearing here is additive and cheap to accept; a block
331+
// DISAPPEARING is the regression, and only the exact comparison catches it.
332+
expect([...candidates.map((c) => c.type)].sort()).toEqual([...EXPECTED_CANDIDATES].sort());
223333
});
224334

225335
for (const cfg of candidates) {

0 commit comments

Comments
 (0)