From 04ee89741af8ac9be5329ea2d73ff55ea08f5beb Mon Sep 17 00:00:00 2001 From: os-warren Date: Thu, 10 Sep 2026 09:05:00 +0000 Subject: [PATCH] test(console): pin the four object-metric query members (objectui#8071 slice 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 8 of objectui#8071 takes the first PARTIAL bite of a multi-key block. The batch is a rule, not a pick: every `object-metric` member that shapes the aggregate query behind the NUMBER the tile paints — the four that reach `ObjectMetricWidget`'s `fetchMetric`/`computeOne` (`dataSource` gates the effect; `aggregateKey`, `resolvedFilterKey`, `compareToKey` are its other dependencies). `trend` and `drillDown` reach neither: they shape what is drawn around the number once it exists, and stay exempt for the next slice. MEMBER_PIN_EXEMPTIONS 37 -> 33, MEMBER_PIN_EXEMPTION_CEILING 37 -> 33 and MEMBER_PINS 53 -> 57, in this one commit. NEWLY_JUDGED_UNPINNED_MEMBERS is unchanged — no block it names was touched. Two pins promote pre-existing files, each read end to end and each given the one row it was missing: `ObjectMetric.elementDataSource.test.tsx` gains the negative direction (the view's `columns`/`sort`/`pagination` asserted OUT of the aggregate call, so the pin cannot be satisfied by a mapping that forwards everything), and `ObjectMetricWidget.compareTo.test.tsx` gains the row that mounts `type: 'object-metric'` through SchemaRenderer, so the claim that the block's gate forwards the key untouched is asserted rather than assumed. `aggregate` and `filter` share one new file, written so the aggregate rows author no filter and stay independent of the filter spelling. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../8071-object-metric-query-member-pins.md | 10 + .../registry-inputs-spec-parity.test.ts | 102 ++++++- .../ObjectMetric.elementDataSource.test.tsx | 38 +++ .../ObjectMetricWidget.compareTo.test.tsx | 45 +++ .../objectMetricQueryMembers-8071.test.tsx | 285 ++++++++++++++++++ 5 files changed, 474 insertions(+), 6 deletions(-) create mode 100644 .changeset/8071-object-metric-query-member-pins.md create mode 100644 packages/plugin-dashboard/src/__tests__/objectMetricQueryMembers-8071.test.tsx diff --git a/.changeset/8071-object-metric-query-member-pins.md b/.changeset/8071-object-metric-query-member-pins.md new file mode 100644 index 0000000000..8348a6aaf4 --- /dev/null +++ b/.changeset/8071-object-metric-query-member-pins.md @@ -0,0 +1,10 @@ +--- +--- + +Pin the four `object-metric` members that shape the aggregate query behind the +number — `dataSource`, `aggregate`, `filter`, `compareTo` (objectui#8071, slice +8). `MEMBER_PIN_EXEMPTIONS` drops 37 → 33 and `MEMBER_PIN_EXEMPTION_CEILING` +follows in the same commit; `trend` and `drillDown`, which shape what is drawn +around the number rather than the number, stay exempt. + +Test only; no package is released by this change. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index c5497f50c8..8102fd30b1 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2265,6 +2265,22 @@ const MEMBER_PINS: Record = { file: 'packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx', pins: 'ONE nested position and no more: `schema.grouping?.fields?.[0]?.field` is the FALLBACK source of `swimlaneField`, and that is the entire member contract this board carries for the key. Three rows make it a reading rather than a claim — the swimlane layout appears keyed by `fields[0].field` where without the key there is none; an explicit `swimlaneField` WINS over it; and a second `fields` entry changes nothing, which is what pins the read at `[0]` rather than at "the fields list". The declared description says the rest is inert precisely so the declaration does not recommend a write the renderer cannot honour — this file is what keeps that sentence true. The spec row is `z.unknown()`, so the read site is the whole member contract (objectui#8313).', }, + 'object-metric.aggregate': { + file: 'packages/plugin-dashboard/src/__tests__/objectMetricQueryMembers-8071.test.tsx', + pins: 'A three-member options bag whose members are read TWICE — once into the adapter call, once back out of the response. `field`/`function`/`groupBy` become `ds.aggregate(object, { field, function, groupBy, filter })` with `groupBy` OPTIONAL and defaulting to `\'_all\'` (one bucket) and an authored value outranking it; the bag is asserted AS A WHOLE so a member added to or dropped from the call is red in either direction. The readback is the half that has no second chance: `function: \'count\'` sums `_count` across EVERY returned row while any other function reads the FIRST row\'s `_`, and the two arms run on the SAME two-row response so only a readback reading both members can answer 120 on one and 7 on the other — a `field`/`function` that reached the query but not the readback paints 0 over a response that carried the right number. `` unsuffixed is pinned as the chain\'s second limb. The key\'s ABSENCE is a member semantic in its own right and is the file\'s non-vacuity floor: with no `aggregate` the query VERB changes to `find()` and the value becomes the row count. Driven through the registered block, not the bare widget. New file (objectui#8071 slice 8).', + }, + 'object-metric.compareTo': { + file: 'packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.test.tsx', + pins: 'The `{ kind, dimension? }` member set under objectstack#5011\'s converged shape: `kind` alone chooses BOTH the comparison window and the trend label, pinned on a QUARTER-scoped filter so the two admitted values disagree about each (`previousYear` = the same quarter one year back and "vs last year"; `previousPeriod` = the quarter before and "vs last quarter") — a year-scoped fixture would have let a `previousYear` that fell through to the other branch pass. `dimension` is CARRIED but inert on this inline path (it addresses a dataset time dimension the executor resolves), asserted by the query\'s filter keys being unchanged by it. An absent `compareTo` runs a single pass and paints no trend, which is the control that keeps the other rows from reading as a coincidence. Pre-existing file (objectui#3337), promoted here after being read end to end; objectui#8071 slice 8 added the row that mounts `type: \'object-metric\'` through `SchemaRenderer`, so the claim that the block\'s gate forwards this key untouched is asserted rather than assumed.', + }, + 'object-metric.dataSource': { + file: 'packages/plugin-dashboard/src/ObjectMetric.elementDataSource.test.tsx', + pins: 'The per-element binding\'s members as a WHITELIST in both directions. Acting: `object` is what gets aggregated, and a named `view`\'s own `filter` becomes the metric\'s scope — with an unresolvable `view` REPORTING instead of aggregating the whole object, which for a metric is the quiet failure (one number, no rows, nothing to notice). Not acting: the same view fixture declares `columns`, `sort` and `pagination`, and the aggregate options bag is asserted whole to keep all three OUT — `OBJECT_METRIC_DATA_SOURCE` names only `filter`, because a metric is one aggregated number with no projection, ordering or page for the rest to act on. A metric with NO binding behaving exactly as before is the control. Pre-existing file (objectstack#6953), promoted here after being read end to end; objectui#8071 slice 8 added the whole-bag row, without which the pin would have been satisfied by a mapping that forwarded everything.', + }, + 'object-metric.filter': { + file: 'packages/plugin-dashboard/src/__tests__/objectMetricQueryMembers-8071.test.tsx', + pins: 'No named member set — the renderer never inspects the predicate — so the member shape is the SPELLING it arrives under, and there are two, chosen by an adapter capability the author cannot see: FLAT under its own name inside the aggregate options bag, and WRAPPED as `$filter` on the no-`aggregate()` `find()` fallback. Collapsing them into one drops the predicate on whichever path lost and the tile counts every row — the same defect `element:number.filter` was pinned for (slice 7) on a different renderer. Two further halves: placeholders are resolved BEFORE the query (an authored `{current_quarter_start}` reaches the adapter as a real date, and a macro surviving onto the wire is a literal nobody matches), and the key is read BY VALUE rather than by identity (`JSON.stringify` memo) — a deep-equal rebuild by a re-rendering parent must NOT re-probe while a changed comparand MUST and carries the new predicate, each arm the other\'s control against a dependency "simplified" to the raw object. New file (objectui#8071 slice 8).', + }, 'page:accordion.items': { file: 'packages/components/src/__tests__/pageAccordionItemMembers-8071.test.tsx', pins: 'Which member of one panel definition becomes which part of the rendered accordion — the four `PageAccordionItem` members (`label`, `icon`, `collapsed`, `children`) asserted as a SET through the real renderer, which nothing did before: `label` becomes the trigger\'s accessible name and `children` the panel BODY, asserted against each other so a renderer painting the wrong one cannot pass. `collapsed` is the reading with real semantics to get wrong and it is strictly `=== false`: `collapsed: false` OPENS a panel while `collapsed: true` AND an omitted `collapsed` both leave it shut — the omitted-key arm is the control an "obvious" edit to `!it.collapsed` breaks, and a no-opener fixture keeps the two shut rows from passing on a renderer that opens nothing. The single/multiple split is pinned on the SAME items so only `allowMultiple` varies: single mode takes `defaultOpen[0]` and drops later openers, multiple mode opens them all without opening panels that never asked. `icon` is covered narrowly on purpose — `page-accordion-icon.test.tsx` (objectui#4721) is its pin and is not re-litigated. New file (objectui#8071 slice 7).', @@ -2508,12 +2524,11 @@ const MEMBER_PIN_EXEMPTIONS: Record = { 'object-master-detail-form.initialValues': AWAITING_A_PIN, 'object-master-detail-form.sections': AWAITING_A_PIN, - // object-metric - 'object-metric.aggregate': AWAITING_A_PIN, - 'object-metric.compareTo': AWAITING_A_PIN, - 'object-metric.dataSource': AWAITING_A_PIN, + // object-metric — objectui#8071 slice 8 pinned the four members that shape + // the aggregate query behind the NUMBER (`dataSource`, `aggregate`, `filter`, + // `compareTo`). The two left shape what is drawn AROUND the number once it + // exists, and neither reaches `fetchMetric`. 'object-metric.drillDown': AWAITING_A_PIN, - 'object-metric.filter': AWAITING_A_PIN, 'object-metric.trend': AWAITING_A_PIN, // page:accordion — objectui#8071 slice 7 pinned `items`, the block's one @@ -2811,11 +2826,86 @@ const NEWLY_JUDGED_UNPINNED_MEMBERS = [ * behaviour in the new file, which authors `bodyShape` and asserts it is * dropped. * + * ## 37 -> 33, the eighth slice, and the FIRST partial bite of a multi-key block + * + * objectui#8071's eighth slice is the first that could not close a block by + * taking its last key — slice 7 exhausted that shape — so it takes a PART of + * one, and the part is chosen by a rule rather than by convenience: + * + * **every `object-metric` member that shapes the aggregate query behind the + * NUMBER the tile paints.** + * + * Four qualify — `dataSource`, `aggregate`, `filter` and `compareTo` — so the + * ceiling follows to 33 in the same commit. The rule is mechanical, not + * editorial: those four are exactly the members that reach + * `ObjectMetricWidget`'s `fetchMetric` / `computeOne` (`dataSource` gates the + * effect; `aggregateKey`, `resolvedFilterKey` and `compareToKey` are its other + * dependencies), and the two left — `trend` and `drillDown` — reach neither. + * They shape what is drawn AROUND the number once it exists: a STATIC badge + * beside it, and a drawer opened by clicking it. Neither can change the number. + * + * ⇒ That is what the next slice inherits, and it is cheap to state: the two + * PRESENTATION members of `object-metric`, `trend` and `drillDown`. They were + * left together on purpose rather than one being swept in — `drillDown` does + * issue a query of its own (the drawer's record list, off the same resolved + * filter), so "reaches an adapter" would have been the wrong cut and is stated + * here so the next reader does not have to re-derive why it was not used. + * + * TWO of the four pins PROMOTE pre-existing files, each read end to end before + * being credited and each given the one row it was missing: + * + * - `ObjectMetric.elementDataSource.test.tsx` (objectstack#6953) already drove + * the binding's `object` and `view` members through the real renderer, with + * an unresolvable `view` reporting rather than aggregating the whole object + * and a binding-less metric unchanged. What it did not have was the + * NEGATIVE direction, so this slice adds it: the view fixture's `columns`, + * `sort` and `pagination` are asserted absent from the aggregate call. + * Without that the pin would have been satisfied by a mapping that forwarded + * everything, which here would mean a metric silently ordered and paged by a + * list view's presentation settings. + * - `ObjectMetricWidget.compareTo.test.tsx` (objectui#3337) already asserted + * the `{ kind, dimension? }` member set — `kind` choosing both window and + * label on a quarter-scoped fixture where the two values disagree about + * each, `dimension` carried but inert, and the no-`compareTo` single-pass + * control. It drove `ObjectMetricWidget` directly, so this slice adds the + * row that mounts `type: 'object-metric'` through `SchemaRenderer`: the + * block's `ElementDataSourceGate` shell re-binds only `objectName` and + * `filter` and forwards the rest untouched, and that is now asserted rather + * than assumed. ⚠️ It is also why the file names the block at all — the + * locator requires it, and a docblock mention alone would have been the + * locator satisfied by prose rather than by behaviour. + * + * `aggregate` and `filter` share ONE new file, + * `objectMetricQueryMembers-8071.test.tsx` — the shape slice 3 used for + * `record-picker-label-placeholder-i18n.test.tsx` and slice 6 for the + * chatter/discussion pair — because the two keys meet inside a single adapter + * call and the assertions have to be written against each other to stay + * independent: the `aggregate` rows author NO filter (so the bag comparison is + * blind to the filter spelling), and the `filter` rows are the ones that pin it. + * + * Every other candidate was read end to end and rejected with a reason, not + * dismissed on its greps: `public-block-binding-reach.test.tsx` states its own + * narrowness — one question per block, "did any call carry the object name" — + * and lists `aggregate` only as a plausible sample value; + * `widget-dom-leak-sweep.test.tsx` is the DOM-attribute canary, where + * `aggregate` is a stub returning `[]`; `ObjectMetricWidget.i18nLabel.test.tsx` + * authors `aggregate`, `trend` and `drillDown` purely to make the drill-down + * reachable, and its subject is `I18nLabel` resolution — crediting it for + * `trend` or `drillDown` would have been slice 7's + * `action-bodyShape-forward.test.tsx` mistake in a new place. The remaining + * seven files that name `object-metric` do so in prose, in a membership list, + * or in a designer-inspector fixture. + * + * ⚠️ Unchanged by this slice: `NEWLY_JUDGED_UNPINNED_MEMBERS` (no block it names + * was touched) and `record:related_list.actions`, whose `NO_READ_SITE_TO_PIN` + * reading was not re-measured here because this slice took a different block — + * slice 7's measurement stands as the last one taken. + * * ⇒ The rule for every future slice of objectui#8071: delete the entry, register * the pin, and set this constant to the new count. Not to the new count plus * room. */ -const MEMBER_PIN_EXEMPTION_CEILING = 37; +const MEMBER_PIN_EXEMPTION_CEILING = 33; /** * Every test file a member pin can live in, as LAZY `?raw` loaders. diff --git a/packages/plugin-dashboard/src/ObjectMetric.elementDataSource.test.tsx b/packages/plugin-dashboard/src/ObjectMetric.elementDataSource.test.tsx index 9e57efd869..9c8efd5951 100644 --- a/packages/plugin-dashboard/src/ObjectMetric.elementDataSource.test.tsx +++ b/packages/plugin-dashboard/src/ObjectMetric.elementDataSource.test.tsx @@ -17,6 +17,18 @@ * `object` and `filter` are the only mapped keys: a metric is one aggregated * number, so there is no projection, no ordering and no page for the binding's * remaining keys to act on. + * + * ## Promoted to the member pin for `object-metric.dataSource` (objectui#8071) + * + * objectui#8071's eighth slice registers this file as the pin for that key. The + * member set it asserts is a WHITELIST in both directions: `object` and `view` + * are the members that act — the bound object is what gets aggregated and the + * named view's own `filter` becomes the metric's scope — while the view's + * `columns`, `sort` and `pagination` are carried in the fixture and asserted + * ABSENT from the call, because `OBJECT_METRIC_DATA_SOURCE` names only + * `filter`. Without that second direction the pin would be satisfied by a + * mapping that forwarded everything, which for this block would mean a metric + * silently ordered and paged by a list view's presentation settings. */ import { describe, it, expect, vi } from 'vitest'; @@ -99,6 +111,32 @@ describe('object-metric — dataSource: { object, view } (objectstack#6953)', () expect(adapter.aggregate).not.toHaveBeenCalled(); }); + it('maps `object` and the view’s `filter` and NOTHING else the view carries', async () => { + // The whitelist direction. `HOT_VIEW` declares `columns`, `sort` and + // `pagination`; none of the three is a member this block reads, so none may + // appear in the aggregate call. Asserted on the WHOLE options bag rather + // than key by key, so a mapping widened to forward the rest is red here. + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-metric', + label: 'Pipeline', + aggregate: AGGREGATE, + dataSource: { object: 'account', view: 'hot' }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled()); + const [, params] = adapter.aggregate.mock.calls[0] as [string, any]; + expect(params).toEqual({ + field: 'amount', + function: 'sum', + groupBy: '_all', + filter: [['rating', '=', 'hot']], + }); + }); + it('leaves a metric with NO dataSource exactly as it was', async () => { const adapter = makeAdapter(); renderBlock( diff --git a/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.test.tsx index 054c7a4b69..ef3afc64c3 100644 --- a/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.test.tsx @@ -13,11 +13,30 @@ * before) and the label (`vs last year` / `vs last quarter`), so each case can * only pass by reading `.kind`. A year-scoped filter would have let a * `previousYear` that fell through to the previousPeriod branch pass anyway. + * + * ## Promoted to the member pin for `object-metric.compareTo` (objectui#8071) + * + * objectui#8071's eighth slice registers this file as the pin for that key: the + * cases below already assert the member SET the renderer reads — `kind` selects + * both the comparison window and the label, `dimension` is carried but inert on + * this inline path, and an absent `compareTo` leaves the tile at one pass with + * no trend — which is what a member-shape claim has to say. + * + * The four cases above drive `ObjectMetricWidget` directly. The registered + * block `object-metric` is the same reader: its `ElementDataSourceGate` shell + * re-binds only `objectName` and `filter` and forwards every other authored key + * untouched, so `compareTo` arrives at this component exactly as authored. That + * is asserted rather than assumed by the last case in this file, which mounts + * `type: 'object-metric'` through `SchemaRenderer`. */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; import { ObjectMetricWidget } from '../ObjectMetricWidget'; +// Registers `object-metric` at MODULE scope, never inside a hook +// (object-ui/no-dynamic-import-in-test-hook). +import '../index'; afterEach(cleanup); @@ -101,4 +120,30 @@ describe('ObjectMetricWidget — compareTo under { kind }', () => { await waitFor(() => expect(src.aggregate).toHaveBeenCalledTimes(1)); expect(screen.queryByText(/vs last/i)).not.toBeInTheDocument(); }); + + it('arrives unchanged through the registered `object-metric` block', async () => { + // What makes the four cases above a pin on the BLOCK's key rather than on + // one component's prop: authored on the schema, `compareTo` passes the + // `ElementDataSourceGate` shell untouched and shifts the window exactly as + // it does when handed straight to the widget. + const src = makeSource(); + render( + + + , + ); + + await waitFor(() => expect(src.aggregate).toHaveBeenCalledTimes(2)); + expect(comparisonFilterOf(src).$gte).toBe(PREVIOUS_YEAR_FROM); + expect(await screen.findByText(/vs last year/i)).toBeInTheDocument(); + }); }); diff --git a/packages/plugin-dashboard/src/__tests__/objectMetricQueryMembers-8071.test.tsx b/packages/plugin-dashboard/src/__tests__/objectMetricQueryMembers-8071.test.tsx new file mode 100644 index 0000000000..b47957b45d --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/objectMetricQueryMembers-8071.test.tsx @@ -0,0 +1,285 @@ +/** + * 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. + */ + +/** + * `object-metric.aggregate` and `object-metric.filter` — the MEMBER SHAPES the + * renderer reads to build the query behind the number (objectui#8071). + * + * The member pins for the two keys objectui#8071's eighth slice takes on the + * QUERY half of `object-metric`. Both are asserted through the registered block + * (`type: 'object-metric'` mounted by `SchemaRenderer`, registered by this + * package's own side-effect import) rather than on `ObjectMetricWidget` + * directly, so the pin covers the path an author actually reaches: the block's + * `ElementDataSourceGate` shell forwards every key but `objectName` / `filter` + * untouched, and this file is what says so. + * + * ## `aggregate` — a three-member options bag, and a result readback keyed on + * two of them + * + * `ObjectMetricWidget.computeOne` turns the authored object into the adapter + * call: + * + * ds.aggregate(objectName, { + * field: aggregate.field, + * function: aggregate.function, + * groupBy: aggregate.groupBy || '_all', + * filter: filterForRun, + * }) + * + * so the member shape is (a) which authored members become which call members, + * (b) that `groupBy` is OPTIONAL with a defined default (`'_all'` — one bucket), + * and (c) that the two other members are read a SECOND time on the way back: + * `function: 'count'` sums `_count` across EVERY returned row, while any + * other function reads the FIRST row's `_`. A `field` or a + * `function` that reached the query but not the readback would paint `0` from a + * response that carried the right number — the silent-wrong-number failure this + * whole direction (objectui#8068) exists to make loud. + * + * The bag is asserted AS A WHOLE (`toEqual`, not per-key), so a member silently + * added to or dropped from the call is red in either direction. The + * aggregate-side bag assertions author NO `filter` on purpose: that keeps them + * independent of the `filter` spelling pinned below, so the two ablations these + * pins were verified with fail for their own reasons rather than as a pair. + * + * The key's ABSENCE is a member semantic too, not merely a control: with no + * `aggregate` the renderer changes the query VERB — `find()` instead of + * `aggregate()` — and paints the row COUNT. That row is also this file's + * non-vacuity floor, because it is the one case whose green requires the + * adapter to have been asked for something. + * + * ## `filter` — one authored predicate, two wire spellings, read by value + * + * `filter` carries no named member set of its own; the renderer never inspects + * it. Its member shape is therefore the SPELLING it arrives under, and there + * are two, chosen by an adapter capability the author cannot see: + * + * - `aggregate(object, { …, filter })` — FLAT, under its own name. + * - `find(object, { $filter })` — WRAPPED, on the no-`aggregate()` fallback. + * + * Collapsing the two into one spelling drops the predicate on whichever path + * lost, and a metric scoped to "hot accounts" then counts every row — the same + * shape `element:number.filter` was pinned for (objectui#8071 slice 7) on a + * different renderer. + * + * Two more halves, both load-bearing: + * + * - Placeholders are resolved BEFORE the query, not by the server: an + * authored `{current_quarter_start}` must reach the adapter as a real date. + * A macro that survives into the wire is not a filter, it is a literal + * nobody matches, and the metric reads 0 with no diagnostic. + * - The predicate is read BY VALUE (`JSON.stringify` memo), not by identity. + * A parent that rebuilds a deep-equal filter literal every render must NOT + * re-query; a parent that changes one comparand MUST, and must carry the new + * predicate. Each arm is the other's control: a dependency "simplified" to + * the raw object passes the second and turns the first into a fetch storm. + * + * ## Nothing pre-existing covered either key + * + * Measured, then read end to end rather than dismissed on greps. Twelve + * collected test files name `object-metric` at all, which is the locator's own + * precondition. `ObjectMetric.elementDataSource.test.tsx` authors an + * `aggregate` in every case, but as a FIXTURE — its subject is the + * `dataSource` binding, and it asserts nothing about which members reach the + * call or how the response is read (it is credited here for `dataSource` + * instead). `public-block-binding-reach.test.tsx` states its own narrowness in + * prose — one question per block, "did any call carry the object name" — and + * lists `aggregate` only as a plausible sample value. + * `widget-dom-leak-sweep.test.tsx` is the DOM-attribute canary: its + * `aggregate` is a stub returning `[]` and `object-metric` is one render + * target among many. `ObjectMetricWidget.i18nLabel.test.tsx` authors + * `aggregate` and `filter`-adjacent props purely to make the drill-down + * reachable; its subject is `I18nLabel` resolution. The remaining seven name + * `object-metric` in prose, in a membership list, or in a designer-inspector + * fixture. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Registers `object-metric` (the `ObjectMetricBlock` shell under test) at +// MODULE scope, never inside a hook — object-ui/no-dynamic-import-in-test-hook. +import '../index'; + +afterEach(cleanup); + +/** The adapter call signature both paths share: `(object, params)`. */ +type Params = Record; + +/** An adapter that can aggregate — the primary path. */ +const aggregatingAdapter = (rows: Record[]) => ({ + aggregate: vi.fn(async (_object: string, _params: Params) => rows), + find: vi.fn(async (_object: string, _params: Params) => ({ data: [] as unknown[] })), +}); + +/** An adapter that CANNOT aggregate — the `find()` fallback path. */ +const countingAdapter = (records: unknown[]) => ({ + find: vi.fn(async (_object: string, _params: Params) => ({ data: records })), +}); + +const mount = (schema: Record, adapter: unknown) => + render( + + + , + ); + +/** The options bag of the nth (default first) `aggregate()` call. */ +const bagOf = (adapter: ReturnType, nth = 0): Params => + adapter.aggregate.mock.calls[nth][1]; + +describe('object-metric — the `aggregate` member shape (objectui#8071)', () => { + it('maps its members onto the call and defaults `groupBy` to one bucket', async () => { + const adapter = aggregatingAdapter([{ amount_sum: 120 }]); + mount({ objectName: 'deal', aggregate: { field: 'amount', function: 'sum' } }, adapter); + + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled()); + expect(adapter.aggregate.mock.calls[0][0]).toBe('deal'); + // The WHOLE bag, so a member added or dropped is red in either direction. + // No `filter` is authored here on purpose — see the docblock. + expect(bagOf(adapter)).toEqual({ + field: 'amount', + function: 'sum', + groupBy: '_all', + filter: undefined, + }); + }); + + it('lets an authored `groupBy` outrank that default', async () => { + const adapter = aggregatingAdapter([{ amount_sum: 120 }]); + mount( + { objectName: 'deal', aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' } }, + adapter, + ); + + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled()); + expect(bagOf(adapter)).toEqual({ + field: 'amount', + function: 'sum', + groupBy: 'stage', + filter: undefined, + }); + }); + + it('reads a `count` back across EVERY row, under `_count`', async () => { + // Two rows, and the count arm sums them: 3 + 4. + const adapter = aggregatingAdapter([{ amount_count: 3 }, { amount_count: 4 }]); + mount({ objectName: 'deal', aggregate: { field: 'amount', function: 'count' } }, adapter); + + expect(await screen.findByText('7')).toBeTruthy(); + }); + + it('reads any other function back off the FIRST row, under `_`', async () => { + // Same two-row response as the count case above, which is what makes the + // pair discriminate: only a readback that reads BOTH members can answer 120 + // here and 7 there. A readback that ignored `function` would sum to 1119. + const adapter = aggregatingAdapter([{ amount_sum: 120 }, { amount_sum: 999 }]); + mount({ objectName: 'deal', aggregate: { field: 'amount', function: 'sum' } }, adapter); + + expect(await screen.findByText('120')).toBeTruthy(); + expect(screen.queryByText('1,119')).toBeNull(); + }); + + it('reads the response under `` when the adapter does not suffix it', async () => { + // The second limb of the same readback chain, and the reason `field` is a + // member of the RESPONSE contract and not only of the request. + const adapter = aggregatingAdapter([{ amount: 42 }]); + mount({ objectName: 'deal', aggregate: { field: 'amount', function: 'sum' } }, adapter); + + expect(await screen.findByText('42')).toBeTruthy(); + }); + + it('with NO `aggregate` the query verb changes to `find()` and the value is the row count', async () => { + // The key's absence is a member semantic, and this row is the file's + // non-vacuity floor: its green requires the adapter to have been asked. + const adapter = countingAdapter([{ id: 1 }, { id: 2 }, { id: 3 }]); + mount({ objectName: 'deal' }, adapter); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + expect(await screen.findByText('3')).toBeTruthy(); + }); +}); + +describe('object-metric — the `filter` member shape (objectui#8071)', () => { + const HOT = [['rating', '=', 'hot']]; + + it('reaches the aggregate FLAT, under its own name, beside the aggregate members', async () => { + const adapter = aggregatingAdapter([{ amount_sum: 120 }]); + mount( + { objectName: 'deal', aggregate: { field: 'amount', function: 'sum' }, filter: HOT }, + adapter, + ); + + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled()); + // The whole bag again, now WITH the predicate — this is the assertion that + // a "tidy the two spellings into one" change has to get past. + expect(bagOf(adapter)).toEqual({ + field: 'amount', + function: 'sum', + groupBy: '_all', + filter: HOT, + }); + }); + + it('reaches the `find()` fallback WRAPPED as `$filter`', async () => { + // Same authored key, adapter without `aggregate()`, different spelling. + // Neither row can be satisfied by the other's spelling, which is the point. + const adapter = countingAdapter([{ id: 1 }]); + mount({ objectName: 'deal', filter: HOT }, adapter); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + expect(adapter.find.mock.calls[0][0]).toBe('deal'); + expect(adapter.find.mock.calls[0][1]).toEqual({ $filter: HOT }); + }); + + it('resolves date placeholders BEFORE the query, not on the wire', async () => { + const quarterStart = new Date(); + quarterStart.setMonth(Math.floor(quarterStart.getMonth() / 3) * 3, 1); + const expected = `${quarterStart.getFullYear()}-${String(quarterStart.getMonth() + 1).padStart(2, '0')}-01`; + + const adapter = aggregatingAdapter([{ amount_sum: 120 }]); + mount( + { + objectName: 'deal', + aggregate: { field: 'amount', function: 'sum' }, + filter: { close_date: { $gte: '{current_quarter_start}' } }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled()); + const sent = (bagOf(adapter).filter as { close_date: { $gte: string } }).close_date.$gte; + expect(sent).toBe(expected); + // Stated separately: a macro that survives onto the wire is a literal + // nobody matches, and the metric then reads 0 with no diagnostic. + expect(String(sent)).not.toContain('{'); + }); + + it('is read BY VALUE — a deep-equal rebuild does not re-query, a changed comparand does', async () => { + const adapter = aggregatingAdapter([{ amount_sum: 120 }]); + const aggregate = { field: 'amount', function: 'sum' }; + const view = (filter: unknown) => ( + + + + ); + + const { rerender } = render(view([['rating', '=', 'hot']])); + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); + + // A fresh, deep-equal literal — a re-rendering parent, not a new question. + rerender(view([['rating', '=', 'hot']])); + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); + + // …and a changed comparand IS a new question, carrying the new predicate. + rerender(view([['rating', '=', 'cold']])); + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(2)); + expect(bagOf(adapter, 1).filter).toEqual([['rating', '=', 'cold']]); + }); +});