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
5561import { 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? */
126202const 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
215320const candidates = ComponentRegistry . getPublicConfigs ( ) . filter ( declaresObjectName ) ;
216321
217322describe ( '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