Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .changeset/7113-chart-data-model.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
---
'@object-ui/types': minor
---

`ChartSchema` declares the data model it renders — chart-level `data` and `xAxisKey`, with
the bare-string `xAxis` folded onto the latter — and `ChartDataSeries` accepts both binding
dialects (objectui#7113 option B, 项目总监席 总监批 #28 2026-09-01 「同意」; and
objectui#6939's `chart` row, maintainer ruling 2026-09-02 「同意」 — both rulings
independently instructed declaring these two keys, so they land as one change).

⚠️ Shipped as `minor`, not `patch`, because two document classes that validated before now
REFUSE. objectui#6939 grades this class "patch where the accept set only widens toward what
already renders"; this change is not a pure widening, so it takes the level objectui#6896
set for the same transition in this same file — the mirror starting to refuse — and for the
same reason: this repository's `major` is a cross-repo pin to `@objectstack`'s major rather
than a severity dial, so the break is announced here, which is the channel that carries it.

## What now refuses (the narrowing, named)

**Three** classes validated before and refuse now. The first two survived only on
`BaseSchema`'s `.passthrough()`; the third was silently STRIPPED by the non-strict
`ChartDataSeriesSchema` object.

```jsonc
// 1. chart-level `data` that is not an array of row objects
{ "type": "chart", "chartType": "bar", "data": "oops" } // now: [data] expected array
{ "type": "chart", "chartType": "bar", "data": [1,2,3] } // now: [data.0] expected object

// 2. a non-string `xAxisKey`
{ "type": "chart", "chartType": "bar", "xAxisKey": 123 } // now: [xAxisKey] expected string

// 3. a non-string `series[].dataKey` ⚠️ THIS ONE DRAWS A REAL CHART TODAY
{ "type": "chart", "chartType": "bar",
"series": [{ "name": "a", "dataKey": 123 }] } // now: [series.0.dataKey] expected string
```

⚠️ **Class 3 is the sharp one and is called out separately.** Classes 1 and 2 are malformed
documents whose chart was already broken. Class 3 is not: at base it parsed to
`series: [{ name: 'a' }]` (the non-string `dataKey` stripped in silence) and
`normalizeChartSchema` renders it — `str(123)` is `undefined`, so the read falls back to
`name` and yields `series: [{ dataKey: 'a' }]` (`normalizeChartSchema.ts:239`). So this is a
narrowing away from a document that **renders today**, which is precisely the distinction
objectui#6939's grading language turns on. `dataKey: null` behaves identically. Measured on
both states; the declaration itself is right, and this note is the disclosure it was owed.

## Corrected: what class 2 actually did

An earlier draft of this changeset said `xAxisKey: 123` "drew an EMPTY CHART". The read
sites do not support that: `ChartRenderer.tsx:133` takes `schema.xAxisKey` raw and the rows
still reach `data` at `:164`, while the normaliser drops the key (`str(123)` is `undefined`).
Measured through `normalizeChartSchema`, the result keeps the series and loses only the
category binding — **a drawn chart with a broken category axis**, not an empty one. Class 1
(`data` malformed) is the one that leaves nothing to plot.

## Also changed on the published surface: combinators

Both consts now carry a check (`ChartSchema` the `xAxis` fold, `ChartDataSeriesSchema` the
at-least-one-binding refinement), and on zod 4.4.3 that makes three combinators **throw**
where they previously returned a schema:

```
ChartSchema.pick(…) / .omit(…) / .partial() -> throws "cannot be used on object
ChartDataSeriesSchema.pick(…) / .omit(…) / … schemas containing refinements"
```

`.extend()` with a NEW key still works and preserves the fold and the refinement;
`.optional()`, `z.discriminatedUnion`, `z.toJSONSchema` and `safeValidateSchema` are all
unaffected. Nothing in this repository calls the throwing combinators on either const, and
the published surface already ships refined mirrors (`objectql.zod.ts`, `complex.zod.ts`,
`form.zod.ts`, `app.zod.ts`), so the class is not new — but it is a real behaviour change on
a published export and it belongs in the release note rather than in a reviewer's file.

## What now validates (the widening)

`series: [{ dataKey: 'revenue' }]`. `normalizeSeries` reads
`str(raw.dataKey) ?? str(raw.name)`, so `dataKey` alone has always been a complete binding
— but the mirror REQUIRED `name` and refused it. That is why both catalog chart fixtures
(`advanced-line-chart.json`, `area-chart.json`) failed validation: they are the `chart: 2`
entry in `objectui check`'s 28-file census. `name` is now optional, `dataKey` is declared,
and a series binding to NEITHER is refused by name at `series.N.name` — the same path the
required flag used to report, so the diagnostic did not move.

## `xAxis` folds; it does not become a second name

`xAxis: 'month'` is accepted at input and is ABSENT from the output, having landed on
`xAxisKey`. When both are written the canonical key is kept and the alias dropped — not a
precedence rule minted here, but the one already running at `normalizeChartSchema.ts:292`,
where `xAxisKey` is the first limb of `str(schema.xAxisKey) ?? xAxisSpec?.field ??
str(xAxisRaw)`. No chart that renders today changes what it renders.

⚠️ The `xAxis` **config object** (`{ field, format, title, showGridLines }`) is NOT folded.
Only the bare string is a sibling spelling of `xAxisKey`; the object's presentation keys
survive separately into `out.xAxis` (`normalizeChartSchema.ts:289-291`), and folding it
would discard them.

## Not done, deliberately

objectui#6939's `chart` row also says "`series[].data` stops being required". On this base
it already is not: objectui#6896 replaced it with `retirementTombstone(...)` —
`z.never({ error }).optional()` — which is optional AND refuses any authored value by name.
Implementing the clause literally would re-widen a retired key and reverse a landed ruling,
so it is not done.

## FROM → TO

```ts
// ChartDataSeries
- name: string;
+ name?: string;
+ dataKey?: string;

// ChartSchema
+ data?: Array<Record<string, any>>;
+ xAxisKey?: string;
```
2 changes: 1 addition & 1 deletion packages/plugin-charts/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "eslint ."
},
"dependencies": {
Expand Down
175 changes: 175 additions & 0 deletions packages/plugin-charts/src/ChartRenderer.catalogRender-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
/**
* 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#6939, the `chart` group — the RENDER half. The validator-side
* contract is pinned in
* `packages/types/src/__tests__/chart-data-model-7113.test.ts`.
*
* Ruling 5510084784 (maintainer 2026-09-02, verbatim 「同意」) sets the bar per
* group, from objectui#6318's triage: "the catalog entry validates, **and its
* render is byte-identical in element count and text before and after**". The
* validator half alone cannot make the claim — a "repair" that also changes
* what is drawn has not proved the SCHEMA was wrong, it has changed the
* product. Both landed sibling groups carry this half
* (`plugin-map/src/ObjectMap.catalogRecordSource-6939.test.tsx`,
* `examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx`), and
* this file is the chart group's.
*
* ## The asymmetry this group has and the siblings do not
*
* At BASE both fixtures **FAIL** validation (`series.N.name`: they are authored
* in the `dataKey` dialect, which the mirror required `name` instead of) while
* **drawing correctly**. So the before/after identity cannot be measured through
* a parse-then-render path — there is no parse at base. It is measured through
* the renderer directly, which is also honest about how charts actually reach
* the screen: `ChartRenderer` consumes the AUTHORED schema and calls
* `normalizeChartSchema` on it; it never sees the mirror's parse output.
*
* That also bounds what this file can regress on: the objectui#7113 diff touches
* no file under `packages/plugin-charts`, so the renderer is byte-identical
* across the change. The pin's job is to keep it that way as the mirror moves.
*
* ## PRE_REPAIR — measured, not transcribed
*
* Captured on `origin/main` @ `98d4108a2` (the merge-base), both faces
* untouched, through THIS file's `measure()` in a worktree at that commit.
*
* ⚠️ Element counts are HARNESS-BOUND and must never be carried over from
* another run. The contract review of PR #7545 measured this same property on
* its own harness and read `advanced-line-chart` 136 / `area-chart` 132 with 11
* x-axis ticks; this harness reads 136 and **127** with **6** ticks. Both are
* correct about their own harness — `ResponsiveContainer` is mocked to a fixed
* 480x320 here, and tick density is a function of that width
* (`AdvancedChartImpl`'s categorical axis thins labels by available space). The
* claim that discriminates is IDENTITY WITHIN ONE HARNESS, which is why the
* numbers below were re-derived here rather than copied.
*
* Three readings per fixture, because a count alone cannot tell a swapped
* element from an equal one: element count, a tag census, and a SHA-256 of the
* text.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0x0
// under the headless DOM, so nothing paints. Fix its size — the same shim the
// other render tests in this package use.
vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import { ChartRenderer } from './ChartRenderer';
import { safeValidateSchema } from '@object-ui/types/zod';

afterEach(cleanup);

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const NAMES = ['advanced-line-chart', 'area-chart'] as const;

function catalogEntry(name: (typeof NAMES)[number]): Record<string, unknown> {
const file = path.join(REPO_ROOT, 'examples/schema-catalog/src/schemas/plugin-charts', `${name}.json`);
return JSON.parse(fs.readFileSync(file, 'utf8'));
}

interface Reading {
elements: number;
tags: Record<string, number>;
lines: number;
areas: number;
xTicks: number;
sha256: string;
}

/** Measured at `98d4108a2` through `measure()` below. See the header. */
const PRE_REPAIR: Record<(typeof NAMES)[number], Reading> = {
'advanced-line-chart': {
elements: 136,
tags: { DIV: 9, STYLE: 1, svg: 1, title: 1, desc: 1, g: 48, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 14, stop: 28, path: 2, text: 11, tspan: 11 },
lines: 2,
areas: 0,
xTicks: 6,
sha256: 'dd56a5f8c25242bb737db18308f2952c3f5dabbe1dcb6eb9e0b71cdb3a3bbccd',
},
'area-chart': {
elements: 127,
tags: { DIV: 7, STYLE: 1, svg: 1, title: 1, desc: 1, g: 47, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 12, stop: 24, path: 2, text: 11, tspan: 11 },
lines: 0,
areas: 1,
xTicks: 6,
sha256: 'c1270f6053d2dd82c9da87b57607ae04896bf3fc317c9c15357632b48fa05386',
},
};

async function measure(schema: unknown): Promise<Reading> {
const { container } = render(
<ChartRenderer schema={{ ...(schema as any), isAnimationActive: false }} />,
);
// `AdvancedChartImpl` is lazy — wait for the real plot, not the skeleton. A
// fixture that stopped drawing fails HERE, loudly, rather than reporting a
// tidy zero further down.
await waitFor(() => {
if (!container.querySelector('.recharts-surface')) throw new Error('nothing drew');
});
const nodes = Array.from(container.querySelectorAll('*'));
// React's `useId` lands in the injected <style> block (`chart-_r_0_`), so it
// varies with render ORDER inside the file. Normalise it, or the hash pins
// the test order rather than the drawing.
const text = (container.textContent ?? '').replace(/chart-_r_[0-9a-z]+_/g, 'chart-ID');
return {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((h, el) => ((h[el.tagName] = (h[el.tagName] ?? 0) + 1), h), {}),
lines: container.querySelectorAll('.recharts-line').length,
areas: container.querySelectorAll('.recharts-area').length,
xTicks: container.querySelectorAll('.recharts-xAxis .recharts-cartesian-axis-tick').length,
sha256: createHash('sha256').update(text).digest('hex'),
};
}

describe('objectui#6939 `chart` — the catalog fixtures draw exactly what they drew before', () => {
it.each(NAMES)('%s renders identically to BASE', async (name) => {
expect(await measure(catalogEntry(name))).toEqual(PRE_REPAIR[name]);
});

/*
* The verdict half — the thing that DID change. At `98d4108a2` both of these
* reported `series.N.name: Invalid input: expected string, received undefined`
* from `safeValidateSchema` while drawing the readings pinned above. Together
* with the identity above, that is objectui#6318's bar: the validator's
* verdict moves, the drawing does not.
*/
it.each(NAMES)('%s now VALIDATES, which is the half that changed', (name) => {
const r = safeValidateSchema(catalogEntry(name));
expect(r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`)).toEqual([]);
});

/*
* LIT CONTROL for the identity assertions. Without it, `toEqual(PRE_REPAIR)`
* passing proves only that two things matched — it cannot show the instrument
* would have NOTICED a difference. Perturb the authored rows and the same
* measurement must move.
*/
it('CONTROL — the measurement detects a changed drawing', async () => {
const doc = catalogEntry('area-chart') as { data: Record<string, unknown>[] };
const perturbed = { ...doc, data: [...doc.data, { month: 'Jul', users: 2600 }] };
const reading = await measure(perturbed);
expect(reading.sha256).not.toBe(PRE_REPAIR['area-chart'].sha256);
expect(reading.elements).not.toBe(PRE_REPAIR['area-chart'].elements);
});
});
19 changes: 18 additions & 1 deletion packages/plugin-charts/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,5 +13,22 @@
"composite": true,
"skipLibCheck": true
},
"include": ["src"]
"include": ["src"],
// Tooling is excluded by DIRECTORY, not just by file NAME — the arrangement
// 34 of this repo's 38 test-bearing packages already use (see
// `packages/plugin-map/tsconfig.json`, whose comment carries the history).
// Until objectui#7113 this package had no exclusion at all, so its 45 test
// files were inputs to THIS program — which emits (`declaration`, `composite`,
// `outDir: dist`). That is the defect objectui#4006 / #4836 / #6943 hit three
// times and `pnpm check:published-tsconfig-exclude` exists to stop. The tests
// are still type-checked, by `tsconfig.test.json`, which `type-check` chains.
"exclude": [
"node_modules",
"dist",
"**/__tests__/**",
"**/__mocks__/**",
"**/__benchmarks__/**",
"**/*.test.ts",
"**/*.test.tsx"
]
}
31 changes: 31 additions & 0 deletions packages/plugin-charts/tsconfig.test.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
{
// Type-checks this package's TESTS, which `tsconfig.json` excludes.
// Modelled on `packages/plugin-map/tsconfig.test.json` — the sibling that
// already reads schema-catalog fixtures off disk from a `*-6939` render pin.
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
// The package build emits `dist`; this project emits nothing, so it must
// not inherit `composite` / `declaration` from the build config.
"composite": false,
// Naming `types` at all switches off automatic `@types/*` inclusion, so
// every type package these tests need is named here.
// - `@testing-library/jest-dom` is a global augmentation, not an import,
// and does not live under `@types/`, so it is never picked up
// automatically (two test files here use its matchers).
// - `node` is what `ChartRenderer.catalogRender-6939.test.tsx` needs: it
// reads the schema-catalog chart fixtures off disk with `node:fs` /
// `node:path` / `node:url` and hashes their render with `node:crypto`.
// The pin has to cross a package boundary, and it cannot move to
// `examples/schema-catalog/test/` — `vi.mock('recharts')` does not
// intercept plugin-charts' import from there, because pnpm's strict
// layout gives the two packages different resolved paths for recharts,
// so the real `ResponsiveContainer` renders 0x0 and nothing paints.
"types": ["@testing-library/jest-dom", "node"],
// Drop the root tsconfig's source-tree `paths` so `@object-ui/*` resolves
// through the workspace dependency's built `.d.ts` instead of pulling
// sibling sources in as program inputs (TS6059).
"paths": {}
},
"include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.d.ts"]
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .changeset/7113-chart-data-model.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
---
'@object-ui/types': minor
---

`ChartSchema` declares the data model it renders — chart-level `data` and `xAxisKey`, with
the bare-string `xAxis` folded onto the latter — and `ChartDataSeries` accepts both binding
dialects (objectui#7113 option B, 项目总监席 总监批 #28 2026-09-01 「同意」; and
objectui#6939's `chart` row, maintainer ruling 2026-09-02 「同意」 — both rulings
independently instructed declaring these two keys, so they land as one change).

⚠️ Shipped as `minor`, not `patch`, because two document classes that validated before now
REFUSE. objectui#6939 grades this class "patch where the accept set only widens toward what
already renders"; this change is not a pure widening, so it takes the level objectui#6896
set for the same transition in this same file — the mirror starting to refuse — and for the
same reason: this repository's `major` is a cross-repo pin to `@objectstack`'s major rather
than a severity dial, so the break is announced here, which is the channel that carries it.

## What now refuses (the narrowing, named)

**Three** classes validated before and refuse now. The first two survived only on
`BaseSchema`'s `.passthrough()`; the third was silently STRIPPED by the non-strict
`ChartDataSeriesSchema` object.

```jsonc
// 1. chart-level `data` that is not an array of row objects
{ "type": "chart", "chartType": "bar", "data": "oops" } // now: [data] expected array
{ "type": "chart", "chartType": "bar", "data": [1,2,3] } // now: [data.0] expected object

// 2. a non-string `xAxisKey`
{ "type": "chart", "chartType": "bar", "xAxisKey": 123 } // now: [xAxisKey] expected string

// 3. a non-string `series[].dataKey` ⚠️ THIS ONE DRAWS A REAL CHART TODAY
{ "type": "chart", "chartType": "bar",
"series": [{ "name": "a", "dataKey": 123 }] } // now: [series.0.dataKey] expected string
```

⚠️ **Class 3 is the sharp one and is called out separately.** Classes 1 and 2 are malformed
documents whose chart was already broken. Class 3 is not: at base it parsed to
`series: [{ name: 'a' }]` (the non-string `dataKey` stripped in silence) and
`normalizeChartSchema` renders it — `str(123)` is `undefined`, so the read falls back to
`name` and yields `series: [{ dataKey: 'a' }]` (`normalizeChartSchema.ts:239`). So this is a
narrowing away from a document that **renders today**, which is precisely the distinction
objectui#6939's grading language turns on. `dataKey: null` behaves identically. Measured on
both states; the declaration itself is right, and this note is the disclosure it was owed.

## Corrected: what class 2 actually did

An earlier draft of this changeset said `xAxisKey: 123` "drew an EMPTY CHART". The read
sites do not support that: `ChartRenderer.tsx:133` takes `schema.xAxisKey` raw and the rows
still reach `data` at `:164`, while the normaliser drops the key (`str(123)` is `undefined`).
Measured through `normalizeChartSchema`, the result keeps the series and loses only the
category binding — **a drawn chart with a broken category axis**, not an empty one. Class 1
(`data` malformed) is the one that leaves nothing to plot.

## Also changed on the published surface: combinators

Both consts now carry a check (`ChartSchema` the `xAxis` fold, `ChartDataSeriesSchema` the
at-least-one-binding refinement), and on zod 4.4.3 that makes three combinators **throw**
where they previously returned a schema:

```
ChartSchema.pick(…) / .omit(…) / .partial() -> throws "cannot be used on object
ChartDataSeriesSchema.pick(…) / .omit(…) / … schemas containing refinements"
```

`.extend()` with a NEW key still works and preserves the fold and the refinement;
`.optional()`, `z.discriminatedUnion`, `z.toJSONSchema` and `safeValidateSchema` are all
unaffected. Nothing in this repository calls the throwing combinators on either const, and
the published surface already ships refined mirrors (`objectql.zod.ts`, `complex.zod.ts`,
`form.zod.ts`, `app.zod.ts`), so the class is not new — but it is a real behaviour change on
a published export and it belongs in the release note rather than in a reviewer's file.

## What now validates (the widening)

`series: [{ dataKey: 'revenue' }]`. `normalizeSeries` reads
`str(raw.dataKey) ?? str(raw.name)`, so `dataKey` alone has always been a complete binding
— but the mirror REQUIRED `name` and refused it. That is why both catalog chart fixtures
(`advanced-line-chart.json`, `area-chart.json`) failed validation: they are the `chart: 2`
entry in `objectui check`'s 28-file census. `name` is now optional, `dataKey` is declared,
and a series binding to NEITHER is refused by name at `series.N.name` — the same path the
required flag used to report, so the diagnostic did not move.

## `xAxis` folds; it does not become a second name

`xAxis: 'month'` is accepted at input and is ABSENT from the output, having landed on
`xAxisKey`. When both are written the canonical key is kept and the alias dropped — not a
precedence rule minted here, but the one already running at `normalizeChartSchema.ts:292`,
where `xAxisKey` is the first limb of `str(schema.xAxisKey) ?? xAxisSpec?.field ??
str(xAxisRaw)`. No chart that renders today changes what it renders.

⚠️ The `xAxis` **config object** (`{ field, format, title, showGridLines }`) is NOT folded.
Only the bare string is a sibling spelling of `xAxisKey`; the object's presentation keys
survive separately into `out.xAxis` (`normalizeChartSchema.ts:289-291`), and folding it
would discard them.

## Not done, deliberately

objectui#6939's `chart` row also says "`series[].data` stops being required". On this base
it already is not: objectui#6896 replaced it with `retirementTombstone(...)` —
`z.never({ error }).optional()` — which is optional AND refuses any authored value by name.
Implementing the clause literally would re-widen a retired key and reverse a landed ruling,
so it is not done.

## FROM → TO

```ts
// ChartDataSeries
- name: string;
+ name?: string;
+ dataKey?: string;

// ChartSchema
+ data?: Array<Record<string, any>>;
+ xAxisKey?: string;
```
2 changes: 1 addition & 1 deletion packages/plugin-charts/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "eslint ."
},
"dependencies": {
Expand Down
175 changes: 175 additions & 0 deletions packages/plugin-charts/src/ChartRenderer.catalogRender-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
/**
* 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#6939, the `chart` group — the RENDER half. The validator-side
* contract is pinned in
* `packages/types/src/__tests__/chart-data-model-7113.test.ts`.
*
* Ruling 5510084784 (maintainer 2026-09-02, verbatim 「同意」) sets the bar per
* group, from objectui#6318's triage: "the catalog entry validates, **and its
* render is byte-identical in element count and text before and after**". The
* validator half alone cannot make the claim — a "repair" that also changes
* what is drawn has not proved the SCHEMA was wrong, it has changed the
* product. Both landed sibling groups carry this half
* (`plugin-map/src/ObjectMap.catalogRecordSource-6939.test.tsx`,
* `examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx`), and
* this file is the chart group's.
*
* ## The asymmetry this group has and the siblings do not
*
* At BASE both fixtures **FAIL** validation (`series.N.name`: they are authored
* in the `dataKey` dialect, which the mirror required `name` instead of) while
* **drawing correctly**. So the before/after identity cannot be measured through
* a parse-then-render path — there is no parse at base. It is measured through
* the renderer directly, which is also honest about how charts actually reach
* the screen: `ChartRenderer` consumes the AUTHORED schema and calls
* `normalizeChartSchema` on it; it never sees the mirror's parse output.
*
* That also bounds what this file can regress on: the objectui#7113 diff touches
* no file under `packages/plugin-charts`, so the renderer is byte-identical
* across the change. The pin's job is to keep it that way as the mirror moves.
*
* ## PRE_REPAIR — measured, not transcribed
*
* Captured on `origin/main` @ `98d4108a2` (the merge-base), both faces
* untouched, through THIS file's `measure()` in a worktree at that commit.
*
* ⚠️ Element counts are HARNESS-BOUND and must never be carried over from
* another run. The contract review of PR #7545 measured this same property on
* its own harness and read `advanced-line-chart` 136 / `area-chart` 132 with 11
* x-axis ticks; this harness reads 136 and **127** with **6** ticks. Both are
* correct about their own harness — `ResponsiveContainer` is mocked to a fixed
* 480x320 here, and tick density is a function of that width
* (`AdvancedChartImpl`'s categorical axis thins labels by available space). The
* claim that discriminates is IDENTITY WITHIN ONE HARNESS, which is why the
* numbers below were re-derived here rather than copied.
*
* Three readings per fixture, because a count alone cannot tell a swapped
* element from an equal one: element count, a tag census, and a SHA-256 of the
* text.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0x0
// under the headless DOM, so nothing paints. Fix its size — the same shim the
// other render tests in this package use.
vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import { ChartRenderer } from './ChartRenderer';
import { safeValidateSchema } from '@object-ui/types/zod';

afterEach(cleanup);

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const NAMES = ['advanced-line-chart', 'area-chart'] as const;

function catalogEntry(name: (typeof NAMES)[number]): Record<string, unknown> {
const file = path.join(REPO_ROOT, 'examples/schema-catalog/src/schemas/plugin-charts', `${name}.json`);
return JSON.parse(fs.readFileSync(file, 'utf8'));
}

interface Reading {
elements: number;
tags: Record<string, number>;
lines: number;
areas: number;
xTicks: number;
sha256: string;
}

/** Measured at `98d4108a2` through `measure()` below. See the header. */
const PRE_REPAIR: Record<(typeof NAMES)[number], Reading> = {
'advanced-line-chart': {
elements: 136,
tags: { DIV: 9, STYLE: 1, svg: 1, title: 1, desc: 1, g: 48, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 14, stop: 28, path: 2, text: 11, tspan: 11 },
lines: 2,
areas: 0,
xTicks: 6,
sha256: 'dd56a5f8c25242bb737db18308f2952c3f5dabbe1dcb6eb9e0b71cdb3a3bbccd',
},
'area-chart': {
elements: 127,
tags: { DIV: 7, STYLE: 1, svg: 1, title: 1, desc: 1, g: 47, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 12, stop: 24, path: 2, text: 11, tspan: 11 },
lines: 0,
areas: 1,
xTicks: 6,
sha256: 'c1270f6053d2dd82c9da87b57607ae04896bf3fc317c9c15357632b48fa05386',
},
};

async function measure(schema: unknown): Promise<Reading> {
const { container } = render(
<ChartRenderer schema={{ ...(schema as any), isAnimationActive: false }} />,
);
// `AdvancedChartImpl` is lazy — wait for the real plot, not the skeleton. A
// fixture that stopped drawing fails HERE, loudly, rather than reporting a
// tidy zero further down.
await waitFor(() => {
if (!container.querySelector('.recharts-surface')) throw new Error('nothing drew');
});
const nodes = Array.from(container.querySelectorAll('*'));
// React's `useId` lands in the injected <style> block (`chart-_r_0_`), so it
// varies with render ORDER inside the file. Normalise it, or the hash pins
// the test order rather than the drawing.
const text = (container.textContent ?? '').replace(/chart-_r_[0-9a-z]+_/g, 'chart-ID');
return {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((h, el) => ((h[el.tagName] = (h[el.tagName] ?? 0) + 1), h), {}),
lines: container.querySelectorAll('.recharts-line').length,
areas: container.querySelectorAll('.recharts-area').length,
xTicks: container.querySelectorAll('.recharts-xAxis .recharts-cartesian-axis-tick').length,
sha256: createHash('sha256').update(text).digest('hex'),
};
}

describe('objectui#6939 `chart` — the catalog fixtures draw exactly what they drew before', () => {
it.each(NAMES)('%s renders identically to BASE', async (name) => {
expect(await measure(catalogEntry(name))).toEqual(PRE_REPAIR[name]);
});

/*
* The verdict half — the thing that DID change. At `98d4108a2` both of these
* reported `series.N.name: Invalid input: expected string, received undefined`
* from `safeValidateSchema` while drawing the readings pinned above. Together
* with the identity above, that is objectui#6318's bar: the validator's
* verdict moves, the drawing does not.
*/
it.each(NAMES)('%s now VALIDATES, which is the half that changed', (name) => {
const r = safeValidateSchema(catalogEntry(name));
expect(r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`)).toEqual([]);
});

/*
* LIT CONTROL for the identity assertions. Without it, `toEqual(PRE_REPAIR)`
* passing proves only that two things matched — it cannot show the instrument
* would have NOTICED a difference. Perturb the authored rows and the same
* measurement must move.
*/
it('CONTROL — the measurement detects a changed drawing', async () => {
const doc = catalogEntry('area-chart') as { data: Record<string, unknown>[] };
const perturbed = { ...doc, data: [...doc.data, { month: 'Jul', users: 2600 }] };
const reading = await measure(perturbed);
expect(reading.sha256).not.toBe(PRE_REPAIR['area-chart'].sha256);
expect(reading.elements).not.toBe(PRE_REPAIR['area-chart'].elements);
});
});
19 changes: 18 additions & 1 deletion packages/plugin-charts/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,5 +13,22 @@
"composite": true,
"skipLibCheck": true
},
"include": ["src"]
"include": ["src"],
// Tooling is excluded by DIRECTORY, not just by file NAME — the arrangement
// 34 of this repo's 38 test-bearing packages already use (see
// `packages/plugin-map/tsconfig.json`, whose comment carries the history).
// Until objectui#7113 this package had no exclusion at all, so its 45 test
// files were inputs to THIS program — which emits (`declaration`, `composite`,
// `outDir: dist`). That is the defect objectui#4006 / #4836 / #6943 hit three
// times and `pnpm check:published-tsconfig-exclude` exists to stop. The tests
// are still type-checked, by `tsconfig.test.json`, which `type-check` chains.
"exclude": [
"node_modules",
"dist",
"**/__tests__/**",
"**/__mocks__/**",
"**/__benchmarks__/**",
"**/*.test.ts",
"**/*.test.tsx"
]
}
31 changes: 31 additions & 0 deletions packages/plugin-charts/tsconfig.test.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
{
// Type-checks this package's TESTS, which `tsconfig.json` excludes.
// Modelled on `packages/plugin-map/tsconfig.test.json` — the sibling that
// already reads schema-catalog fixtures off disk from a `*-6939` render pin.
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
// The package build emits `dist`; this project emits nothing, so it must
// not inherit `composite` / `declaration` from the build config.
"composite": false,
// Naming `types` at all switches off automatic `@types/*` inclusion, so
// every type package these tests need is named here.
// - `@testing-library/jest-dom` is a global augmentation, not an import,
// and does not live under `@types/`, so it is never picked up
// automatically (two test files here use its matchers).
// - `node` is what `ChartRenderer.catalogRender-6939.test.tsx` needs: it
// reads the schema-catalog chart fixtures off disk with `node:fs` /
// `node:path` / `node:url` and hashes their render with `node:crypto`.
// The pin has to cross a package boundary, and it cannot move to
// `examples/schema-catalog/test/` — `vi.mock('recharts')` does not
// intercept plugin-charts' import from there, because pnpm's strict
// layout gives the two packages different resolved paths for recharts,
// so the real `ResponsiveContainer` renders 0x0 and nothing paints.
"types": ["@testing-library/jest-dom", "node"],
// Drop the root tsconfig's source-tree `paths` so `@object-ui/*` resolves
// through the workspace dependency's built `.d.ts` instead of pulling
// sibling sources in as program inputs (TS6059).
"paths": {}
},
"include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.d.ts"]
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .changeset/7113-chart-data-model.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
---
'@object-ui/types': minor
---

`ChartSchema` declares the data model it renders — chart-level `data` and `xAxisKey`, with
the bare-string `xAxis` folded onto the latter — and `ChartDataSeries` accepts both binding
dialects (objectui#7113 option B, 项目总监席 总监批 #28 2026-09-01 「同意」; and
objectui#6939's `chart` row, maintainer ruling 2026-09-02 「同意」 — both rulings
independently instructed declaring these two keys, so they land as one change).

⚠️ Shipped as `minor`, not `patch`, because two document classes that validated before now
REFUSE. objectui#6939 grades this class "patch where the accept set only widens toward what
already renders"; this change is not a pure widening, so it takes the level objectui#6896
set for the same transition in this same file — the mirror starting to refuse — and for the
same reason: this repository's `major` is a cross-repo pin to `@objectstack`'s major rather
than a severity dial, so the break is announced here, which is the channel that carries it.

## What now refuses (the narrowing, named)

**Three** classes validated before and refuse now. The first two survived only on
`BaseSchema`'s `.passthrough()`; the third was silently STRIPPED by the non-strict
`ChartDataSeriesSchema` object.

```jsonc
// 1. chart-level `data` that is not an array of row objects
{ "type": "chart", "chartType": "bar", "data": "oops" } // now: [data] expected array
{ "type": "chart", "chartType": "bar", "data": [1,2,3] } // now: [data.0] expected object

// 2. a non-string `xAxisKey`
{ "type": "chart", "chartType": "bar", "xAxisKey": 123 } // now: [xAxisKey] expected string

// 3. a non-string `series[].dataKey` ⚠️ THIS ONE DRAWS A REAL CHART TODAY
{ "type": "chart", "chartType": "bar",
"series": [{ "name": "a", "dataKey": 123 }] } // now: [series.0.dataKey] expected string
```

⚠️ **Class 3 is the sharp one and is called out separately.** Classes 1 and 2 are malformed
documents whose chart was already broken. Class 3 is not: at base it parsed to
`series: [{ name: 'a' }]` (the non-string `dataKey` stripped in silence) and
`normalizeChartSchema` renders it — `str(123)` is `undefined`, so the read falls back to
`name` and yields `series: [{ dataKey: 'a' }]` (`normalizeChartSchema.ts:239`). So this is a
narrowing away from a document that **renders today**, which is precisely the distinction
objectui#6939's grading language turns on. `dataKey: null` behaves identically. Measured on
both states; the declaration itself is right, and this note is the disclosure it was owed.

## Corrected: what class 2 actually did

An earlier draft of this changeset said `xAxisKey: 123` "drew an EMPTY CHART". The read
sites do not support that: `ChartRenderer.tsx:133` takes `schema.xAxisKey` raw and the rows
still reach `data` at `:164`, while the normaliser drops the key (`str(123)` is `undefined`).
Measured through `normalizeChartSchema`, the result keeps the series and loses only the
category binding — **a drawn chart with a broken category axis**, not an empty one. Class 1
(`data` malformed) is the one that leaves nothing to plot.

## Also changed on the published surface: combinators

Both consts now carry a check (`ChartSchema` the `xAxis` fold, `ChartDataSeriesSchema` the
at-least-one-binding refinement), and on zod 4.4.3 that makes three combinators **throw**
where they previously returned a schema:

```
ChartSchema.pick(…) / .omit(…) / .partial() -> throws "cannot be used on object
ChartDataSeriesSchema.pick(…) / .omit(…) / … schemas containing refinements"
```

`.extend()` with a NEW key still works and preserves the fold and the refinement;
`.optional()`, `z.discriminatedUnion`, `z.toJSONSchema` and `safeValidateSchema` are all
unaffected. Nothing in this repository calls the throwing combinators on either const, and
the published surface already ships refined mirrors (`objectql.zod.ts`, `complex.zod.ts`,
`form.zod.ts`, `app.zod.ts`), so the class is not new — but it is a real behaviour change on
a published export and it belongs in the release note rather than in a reviewer's file.

## What now validates (the widening)

`series: [{ dataKey: 'revenue' }]`. `normalizeSeries` reads
`str(raw.dataKey) ?? str(raw.name)`, so `dataKey` alone has always been a complete binding
— but the mirror REQUIRED `name` and refused it. That is why both catalog chart fixtures
(`advanced-line-chart.json`, `area-chart.json`) failed validation: they are the `chart: 2`
entry in `objectui check`'s 28-file census. `name` is now optional, `dataKey` is declared,
and a series binding to NEITHER is refused by name at `series.N.name` — the same path the
required flag used to report, so the diagnostic did not move.

## `xAxis` folds; it does not become a second name

`xAxis: 'month'` is accepted at input and is ABSENT from the output, having landed on
`xAxisKey`. When both are written the canonical key is kept and the alias dropped — not a
precedence rule minted here, but the one already running at `normalizeChartSchema.ts:292`,
where `xAxisKey` is the first limb of `str(schema.xAxisKey) ?? xAxisSpec?.field ??
str(xAxisRaw)`. No chart that renders today changes what it renders.

⚠️ The `xAxis` **config object** (`{ field, format, title, showGridLines }`) is NOT folded.
Only the bare string is a sibling spelling of `xAxisKey`; the object's presentation keys
survive separately into `out.xAxis` (`normalizeChartSchema.ts:289-291`), and folding it
would discard them.

## Not done, deliberately

objectui#6939's `chart` row also says "`series[].data` stops being required". On this base
it already is not: objectui#6896 replaced it with `retirementTombstone(...)` —
`z.never({ error }).optional()` — which is optional AND refuses any authored value by name.
Implementing the clause literally would re-widen a retired key and reverse a landed ruling,
so it is not done.

## FROM → TO

```ts
// ChartDataSeries
- name: string;
+ name?: string;
+ dataKey?: string;

// ChartSchema
+ data?: Array<Record<string, any>>;
+ xAxisKey?: string;
```
2 changes: 1 addition & 1 deletion packages/plugin-charts/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "eslint ."
},
"dependencies": {
Expand Down
175 changes: 175 additions & 0 deletions packages/plugin-charts/src/ChartRenderer.catalogRender-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
/**
* 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#6939, the `chart` group — the RENDER half. The validator-side
* contract is pinned in
* `packages/types/src/__tests__/chart-data-model-7113.test.ts`.
*
* Ruling 5510084784 (maintainer 2026-09-02, verbatim 「同意」) sets the bar per
* group, from objectui#6318's triage: "the catalog entry validates, **and its
* render is byte-identical in element count and text before and after**". The
* validator half alone cannot make the claim — a "repair" that also changes
* what is drawn has not proved the SCHEMA was wrong, it has changed the
* product. Both landed sibling groups carry this half
* (`plugin-map/src/ObjectMap.catalogRecordSource-6939.test.tsx`,
* `examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx`), and
* this file is the chart group's.
*
* ## The asymmetry this group has and the siblings do not
*
* At BASE both fixtures **FAIL** validation (`series.N.name`: they are authored
* in the `dataKey` dialect, which the mirror required `name` instead of) while
* **drawing correctly**. So the before/after identity cannot be measured through
* a parse-then-render path — there is no parse at base. It is measured through
* the renderer directly, which is also honest about how charts actually reach
* the screen: `ChartRenderer` consumes the AUTHORED schema and calls
* `normalizeChartSchema` on it; it never sees the mirror's parse output.
*
* That also bounds what this file can regress on: the objectui#7113 diff touches
* no file under `packages/plugin-charts`, so the renderer is byte-identical
* across the change. The pin's job is to keep it that way as the mirror moves.
*
* ## PRE_REPAIR — measured, not transcribed
*
* Captured on `origin/main` @ `98d4108a2` (the merge-base), both faces
* untouched, through THIS file's `measure()` in a worktree at that commit.
*
* ⚠️ Element counts are HARNESS-BOUND and must never be carried over from
* another run. The contract review of PR #7545 measured this same property on
* its own harness and read `advanced-line-chart` 136 / `area-chart` 132 with 11
* x-axis ticks; this harness reads 136 and **127** with **6** ticks. Both are
* correct about their own harness — `ResponsiveContainer` is mocked to a fixed
* 480x320 here, and tick density is a function of that width
* (`AdvancedChartImpl`'s categorical axis thins labels by available space). The
* claim that discriminates is IDENTITY WITHIN ONE HARNESS, which is why the
* numbers below were re-derived here rather than copied.
*
* Three readings per fixture, because a count alone cannot tell a swapped
* element from an equal one: element count, a tag census, and a SHA-256 of the
* text.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0x0
// under the headless DOM, so nothing paints. Fix its size — the same shim the
// other render tests in this package use.
vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import { ChartRenderer } from './ChartRenderer';
import { safeValidateSchema } from '@object-ui/types/zod';

afterEach(cleanup);

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const NAMES = ['advanced-line-chart', 'area-chart'] as const;

function catalogEntry(name: (typeof NAMES)[number]): Record<string, unknown> {
const file = path.join(REPO_ROOT, 'examples/schema-catalog/src/schemas/plugin-charts', `${name}.json`);
return JSON.parse(fs.readFileSync(file, 'utf8'));
}

interface Reading {
elements: number;
tags: Record<string, number>;
lines: number;
areas: number;
xTicks: number;
sha256: string;
}

/** Measured at `98d4108a2` through `measure()` below. See the header. */
const PRE_REPAIR: Record<(typeof NAMES)[number], Reading> = {
'advanced-line-chart': {
elements: 136,
tags: { DIV: 9, STYLE: 1, svg: 1, title: 1, desc: 1, g: 48, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 14, stop: 28, path: 2, text: 11, tspan: 11 },
lines: 2,
areas: 0,
xTicks: 6,
sha256: 'dd56a5f8c25242bb737db18308f2952c3f5dabbe1dcb6eb9e0b71cdb3a3bbccd',
},
'area-chart': {
elements: 127,
tags: { DIV: 7, STYLE: 1, svg: 1, title: 1, desc: 1, g: 47, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 12, stop: 24, path: 2, text: 11, tspan: 11 },
lines: 0,
areas: 1,
xTicks: 6,
sha256: 'c1270f6053d2dd82c9da87b57607ae04896bf3fc317c9c15357632b48fa05386',
},
};

async function measure(schema: unknown): Promise<Reading> {
const { container } = render(
<ChartRenderer schema={{ ...(schema as any), isAnimationActive: false }} />,
);
// `AdvancedChartImpl` is lazy — wait for the real plot, not the skeleton. A
// fixture that stopped drawing fails HERE, loudly, rather than reporting a
// tidy zero further down.
await waitFor(() => {
if (!container.querySelector('.recharts-surface')) throw new Error('nothing drew');
});
const nodes = Array.from(container.querySelectorAll('*'));
// React's `useId` lands in the injected <style> block (`chart-_r_0_`), so it
// varies with render ORDER inside the file. Normalise it, or the hash pins
// the test order rather than the drawing.
const text = (container.textContent ?? '').replace(/chart-_r_[0-9a-z]+_/g, 'chart-ID');
return {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((h, el) => ((h[el.tagName] = (h[el.tagName] ?? 0) + 1), h), {}),
lines: container.querySelectorAll('.recharts-line').length,
areas: container.querySelectorAll('.recharts-area').length,
xTicks: container.querySelectorAll('.recharts-xAxis .recharts-cartesian-axis-tick').length,
sha256: createHash('sha256').update(text).digest('hex'),
};
}

describe('objectui#6939 `chart` — the catalog fixtures draw exactly what they drew before', () => {
it.each(NAMES)('%s renders identically to BASE', async (name) => {
expect(await measure(catalogEntry(name))).toEqual(PRE_REPAIR[name]);
});

/*
* The verdict half — the thing that DID change. At `98d4108a2` both of these
* reported `series.N.name: Invalid input: expected string, received undefined`
* from `safeValidateSchema` while drawing the readings pinned above. Together
* with the identity above, that is objectui#6318's bar: the validator's
* verdict moves, the drawing does not.
*/
it.each(NAMES)('%s now VALIDATES, which is the half that changed', (name) => {
const r = safeValidateSchema(catalogEntry(name));
expect(r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`)).toEqual([]);
});

/*
* LIT CONTROL for the identity assertions. Without it, `toEqual(PRE_REPAIR)`
* passing proves only that two things matched — it cannot show the instrument
* would have NOTICED a difference. Perturb the authored rows and the same
* measurement must move.
*/
it('CONTROL — the measurement detects a changed drawing', async () => {
const doc = catalogEntry('area-chart') as { data: Record<string, unknown>[] };
const perturbed = { ...doc, data: [...doc.data, { month: 'Jul', users: 2600 }] };
const reading = await measure(perturbed);
expect(reading.sha256).not.toBe(PRE_REPAIR['area-chart'].sha256);
expect(reading.elements).not.toBe(PRE_REPAIR['area-chart'].elements);
});
});
19 changes: 18 additions & 1 deletion packages/plugin-charts/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,5 +13,22 @@
"composite": true,
"skipLibCheck": true
},
"include": ["src"]
"include": ["src"],
// Tooling is excluded by DIRECTORY, not just by file NAME — the arrangement
// 34 of this repo's 38 test-bearing packages already use (see
// `packages/plugin-map/tsconfig.json`, whose comment carries the history).
// Until objectui#7113 this package had no exclusion at all, so its 45 test
// files were inputs to THIS program — which emits (`declaration`, `composite`,
// `outDir: dist`). That is the defect objectui#4006 / #4836 / #6943 hit three
// times and `pnpm check:published-tsconfig-exclude` exists to stop. The tests
// are still type-checked, by `tsconfig.test.json`, which `type-check` chains.
"exclude": [
"node_modules",
"dist",
"**/__tests__/**",
"**/__mocks__/**",
"**/__benchmarks__/**",
"**/*.test.ts",
"**/*.test.tsx"
]
}
31 changes: 31 additions & 0 deletions packages/plugin-charts/tsconfig.test.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
{
// Type-checks this package's TESTS, which `tsconfig.json` excludes.
// Modelled on `packages/plugin-map/tsconfig.test.json` — the sibling that
// already reads schema-catalog fixtures off disk from a `*-6939` render pin.
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
// The package build emits `dist`; this project emits nothing, so it must
// not inherit `composite` / `declaration` from the build config.
"composite": false,
// Naming `types` at all switches off automatic `@types/*` inclusion, so
// every type package these tests need is named here.
// - `@testing-library/jest-dom` is a global augmentation, not an import,
// and does not live under `@types/`, so it is never picked up
// automatically (two test files here use its matchers).
// - `node` is what `ChartRenderer.catalogRender-6939.test.tsx` needs: it
// reads the schema-catalog chart fixtures off disk with `node:fs` /
// `node:path` / `node:url` and hashes their render with `node:crypto`.
// The pin has to cross a package boundary, and it cannot move to
// `examples/schema-catalog/test/` — `vi.mock('recharts')` does not
// intercept plugin-charts' import from there, because pnpm's strict
// layout gives the two packages different resolved paths for recharts,
// so the real `ResponsiveContainer` renders 0x0 and nothing paints.
"types": ["@testing-library/jest-dom", "node"],
// Drop the root tsconfig's source-tree `paths` so `@object-ui/*` resolves
// through the workspace dependency's built `.d.ts` instead of pulling
// sibling sources in as program inputs (TS6059).
"paths": {}
},
"include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.d.ts"]
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .changeset/7113-chart-data-model.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
---
'@object-ui/types': minor
---

`ChartSchema` declares the data model it renders — chart-level `data` and `xAxisKey`, with
the bare-string `xAxis` folded onto the latter — and `ChartDataSeries` accepts both binding
dialects (objectui#7113 option B, 项目总监席 总监批 #28 2026-09-01 「同意」; and
objectui#6939's `chart` row, maintainer ruling 2026-09-02 「同意」 — both rulings
independently instructed declaring these two keys, so they land as one change).

⚠️ Shipped as `minor`, not `patch`, because two document classes that validated before now
REFUSE. objectui#6939 grades this class "patch where the accept set only widens toward what
already renders"; this change is not a pure widening, so it takes the level objectui#6896
set for the same transition in this same file — the mirror starting to refuse — and for the
same reason: this repository's `major` is a cross-repo pin to `@objectstack`'s major rather
than a severity dial, so the break is announced here, which is the channel that carries it.

## What now refuses (the narrowing, named)

**Three** classes validated before and refuse now. The first two survived only on
`BaseSchema`'s `.passthrough()`; the third was silently STRIPPED by the non-strict
`ChartDataSeriesSchema` object.

```jsonc
// 1. chart-level `data` that is not an array of row objects
{ "type": "chart", "chartType": "bar", "data": "oops" } // now: [data] expected array
{ "type": "chart", "chartType": "bar", "data": [1,2,3] } // now: [data.0] expected object

// 2. a non-string `xAxisKey`
{ "type": "chart", "chartType": "bar", "xAxisKey": 123 } // now: [xAxisKey] expected string

// 3. a non-string `series[].dataKey` ⚠️ THIS ONE DRAWS A REAL CHART TODAY
{ "type": "chart", "chartType": "bar",
"series": [{ "name": "a", "dataKey": 123 }] } // now: [series.0.dataKey] expected string
```

⚠️ **Class 3 is the sharp one and is called out separately.** Classes 1 and 2 are malformed
documents whose chart was already broken. Class 3 is not: at base it parsed to
`series: [{ name: 'a' }]` (the non-string `dataKey` stripped in silence) and
`normalizeChartSchema` renders it — `str(123)` is `undefined`, so the read falls back to
`name` and yields `series: [{ dataKey: 'a' }]` (`normalizeChartSchema.ts:239`). So this is a
narrowing away from a document that **renders today**, which is precisely the distinction
objectui#6939's grading language turns on. `dataKey: null` behaves identically. Measured on
both states; the declaration itself is right, and this note is the disclosure it was owed.

## Corrected: what class 2 actually did

An earlier draft of this changeset said `xAxisKey: 123` "drew an EMPTY CHART". The read
sites do not support that: `ChartRenderer.tsx:133` takes `schema.xAxisKey` raw and the rows
still reach `data` at `:164`, while the normaliser drops the key (`str(123)` is `undefined`).
Measured through `normalizeChartSchema`, the result keeps the series and loses only the
category binding — **a drawn chart with a broken category axis**, not an empty one. Class 1
(`data` malformed) is the one that leaves nothing to plot.

## Also changed on the published surface: combinators

Both consts now carry a check (`ChartSchema` the `xAxis` fold, `ChartDataSeriesSchema` the
at-least-one-binding refinement), and on zod 4.4.3 that makes three combinators **throw**
where they previously returned a schema:

```
ChartSchema.pick(…) / .omit(…) / .partial() -> throws "cannot be used on object
ChartDataSeriesSchema.pick(…) / .omit(…) / … schemas containing refinements"
```

`.extend()` with a NEW key still works and preserves the fold and the refinement;
`.optional()`, `z.discriminatedUnion`, `z.toJSONSchema` and `safeValidateSchema` are all
unaffected. Nothing in this repository calls the throwing combinators on either const, and
the published surface already ships refined mirrors (`objectql.zod.ts`, `complex.zod.ts`,
`form.zod.ts`, `app.zod.ts`), so the class is not new — but it is a real behaviour change on
a published export and it belongs in the release note rather than in a reviewer's file.

## What now validates (the widening)

`series: [{ dataKey: 'revenue' }]`. `normalizeSeries` reads
`str(raw.dataKey) ?? str(raw.name)`, so `dataKey` alone has always been a complete binding
— but the mirror REQUIRED `name` and refused it. That is why both catalog chart fixtures
(`advanced-line-chart.json`, `area-chart.json`) failed validation: they are the `chart: 2`
entry in `objectui check`'s 28-file census. `name` is now optional, `dataKey` is declared,
and a series binding to NEITHER is refused by name at `series.N.name` — the same path the
required flag used to report, so the diagnostic did not move.

## `xAxis` folds; it does not become a second name

`xAxis: 'month'` is accepted at input and is ABSENT from the output, having landed on
`xAxisKey`. When both are written the canonical key is kept and the alias dropped — not a
precedence rule minted here, but the one already running at `normalizeChartSchema.ts:292`,
where `xAxisKey` is the first limb of `str(schema.xAxisKey) ?? xAxisSpec?.field ??
str(xAxisRaw)`. No chart that renders today changes what it renders.

⚠️ The `xAxis` **config object** (`{ field, format, title, showGridLines }`) is NOT folded.
Only the bare string is a sibling spelling of `xAxisKey`; the object's presentation keys
survive separately into `out.xAxis` (`normalizeChartSchema.ts:289-291`), and folding it
would discard them.

## Not done, deliberately

objectui#6939's `chart` row also says "`series[].data` stops being required". On this base
it already is not: objectui#6896 replaced it with `retirementTombstone(...)` —
`z.never({ error }).optional()` — which is optional AND refuses any authored value by name.
Implementing the clause literally would re-widen a retired key and reverse a landed ruling,
so it is not done.

## FROM → TO

```ts
// ChartDataSeries
- name: string;
+ name?: string;
+ dataKey?: string;

// ChartSchema
+ data?: Array<Record<string, any>>;
+ xAxisKey?: string;
```
2 changes: 1 addition & 1 deletion packages/plugin-charts/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "eslint ."
},
"dependencies": {
Expand Down
175 changes: 175 additions & 0 deletions packages/plugin-charts/src/ChartRenderer.catalogRender-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
/**
* 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#6939, the `chart` group — the RENDER half. The validator-side
* contract is pinned in
* `packages/types/src/__tests__/chart-data-model-7113.test.ts`.
*
* Ruling 5510084784 (maintainer 2026-09-02, verbatim 「同意」) sets the bar per
* group, from objectui#6318's triage: "the catalog entry validates, **and its
* render is byte-identical in element count and text before and after**". The
* validator half alone cannot make the claim — a "repair" that also changes
* what is drawn has not proved the SCHEMA was wrong, it has changed the
* product. Both landed sibling groups carry this half
* (`plugin-map/src/ObjectMap.catalogRecordSource-6939.test.tsx`,
* `examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx`), and
* this file is the chart group's.
*
* ## The asymmetry this group has and the siblings do not
*
* At BASE both fixtures **FAIL** validation (`series.N.name`: they are authored
* in the `dataKey` dialect, which the mirror required `name` instead of) while
* **drawing correctly**. So the before/after identity cannot be measured through
* a parse-then-render path — there is no parse at base. It is measured through
* the renderer directly, which is also honest about how charts actually reach
* the screen: `ChartRenderer` consumes the AUTHORED schema and calls
* `normalizeChartSchema` on it; it never sees the mirror's parse output.
*
* That also bounds what this file can regress on: the objectui#7113 diff touches
* no file under `packages/plugin-charts`, so the renderer is byte-identical
* across the change. The pin's job is to keep it that way as the mirror moves.
*
* ## PRE_REPAIR — measured, not transcribed
*
* Captured on `origin/main` @ `98d4108a2` (the merge-base), both faces
* untouched, through THIS file's `measure()` in a worktree at that commit.
*
* ⚠️ Element counts are HARNESS-BOUND and must never be carried over from
* another run. The contract review of PR #7545 measured this same property on
* its own harness and read `advanced-line-chart` 136 / `area-chart` 132 with 11
* x-axis ticks; this harness reads 136 and **127** with **6** ticks. Both are
* correct about their own harness — `ResponsiveContainer` is mocked to a fixed
* 480x320 here, and tick density is a function of that width
* (`AdvancedChartImpl`'s categorical axis thins labels by available space). The
* claim that discriminates is IDENTITY WITHIN ONE HARNESS, which is why the
* numbers below were re-derived here rather than copied.
*
* Three readings per fixture, because a count alone cannot tell a swapped
* element from an equal one: element count, a tag census, and a SHA-256 of the
* text.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0x0
// under the headless DOM, so nothing paints. Fix its size — the same shim the
// other render tests in this package use.
vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import { ChartRenderer } from './ChartRenderer';
import { safeValidateSchema } from '@object-ui/types/zod';

afterEach(cleanup);

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const NAMES = ['advanced-line-chart', 'area-chart'] as const;

function catalogEntry(name: (typeof NAMES)[number]): Record<string, unknown> {
const file = path.join(REPO_ROOT, 'examples/schema-catalog/src/schemas/plugin-charts', `${name}.json`);
return JSON.parse(fs.readFileSync(file, 'utf8'));
}

interface Reading {
elements: number;
tags: Record<string, number>;
lines: number;
areas: number;
xTicks: number;
sha256: string;
}

/** Measured at `98d4108a2` through `measure()` below. See the header. */
const PRE_REPAIR: Record<(typeof NAMES)[number], Reading> = {
'advanced-line-chart': {
elements: 136,
tags: { DIV: 9, STYLE: 1, svg: 1, title: 1, desc: 1, g: 48, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 14, stop: 28, path: 2, text: 11, tspan: 11 },
lines: 2,
areas: 0,
xTicks: 6,
sha256: 'dd56a5f8c25242bb737db18308f2952c3f5dabbe1dcb6eb9e0b71cdb3a3bbccd',
},
'area-chart': {
elements: 127,
tags: { DIV: 7, STYLE: 1, svg: 1, title: 1, desc: 1, g: 47, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 12, stop: 24, path: 2, text: 11, tspan: 11 },
lines: 0,
areas: 1,
xTicks: 6,
sha256: 'c1270f6053d2dd82c9da87b57607ae04896bf3fc317c9c15357632b48fa05386',
},
};

async function measure(schema: unknown): Promise<Reading> {
const { container } = render(
<ChartRenderer schema={{ ...(schema as any), isAnimationActive: false }} />,
);
// `AdvancedChartImpl` is lazy — wait for the real plot, not the skeleton. A
// fixture that stopped drawing fails HERE, loudly, rather than reporting a
// tidy zero further down.
await waitFor(() => {
if (!container.querySelector('.recharts-surface')) throw new Error('nothing drew');
});
const nodes = Array.from(container.querySelectorAll('*'));
// React's `useId` lands in the injected <style> block (`chart-_r_0_`), so it
// varies with render ORDER inside the file. Normalise it, or the hash pins
// the test order rather than the drawing.
const text = (container.textContent ?? '').replace(/chart-_r_[0-9a-z]+_/g, 'chart-ID');
return {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((h, el) => ((h[el.tagName] = (h[el.tagName] ?? 0) + 1), h), {}),
lines: container.querySelectorAll('.recharts-line').length,
areas: container.querySelectorAll('.recharts-area').length,
xTicks: container.querySelectorAll('.recharts-xAxis .recharts-cartesian-axis-tick').length,
sha256: createHash('sha256').update(text).digest('hex'),
};
}

describe('objectui#6939 `chart` — the catalog fixtures draw exactly what they drew before', () => {
it.each(NAMES)('%s renders identically to BASE', async (name) => {
expect(await measure(catalogEntry(name))).toEqual(PRE_REPAIR[name]);
});

/*
* The verdict half — the thing that DID change. At `98d4108a2` both of these
* reported `series.N.name: Invalid input: expected string, received undefined`
* from `safeValidateSchema` while drawing the readings pinned above. Together
* with the identity above, that is objectui#6318's bar: the validator's
* verdict moves, the drawing does not.
*/
it.each(NAMES)('%s now VALIDATES, which is the half that changed', (name) => {
const r = safeValidateSchema(catalogEntry(name));
expect(r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`)).toEqual([]);
});

/*
* LIT CONTROL for the identity assertions. Without it, `toEqual(PRE_REPAIR)`
* passing proves only that two things matched — it cannot show the instrument
* would have NOTICED a difference. Perturb the authored rows and the same
* measurement must move.
*/
it('CONTROL — the measurement detects a changed drawing', async () => {
const doc = catalogEntry('area-chart') as { data: Record<string, unknown>[] };
const perturbed = { ...doc, data: [...doc.data, { month: 'Jul', users: 2600 }] };
const reading = await measure(perturbed);
expect(reading.sha256).not.toBe(PRE_REPAIR['area-chart'].sha256);
expect(reading.elements).not.toBe(PRE_REPAIR['area-chart'].elements);
});
});
19 changes: 18 additions & 1 deletion packages/plugin-charts/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,5 +13,22 @@
"composite": true,
"skipLibCheck": true
},
"include": ["src"]
"include": ["src"],
// Tooling is excluded by DIRECTORY, not just by file NAME — the arrangement
// 34 of this repo's 38 test-bearing packages already use (see
// `packages/plugin-map/tsconfig.json`, whose comment carries the history).
// Until objectui#7113 this package had no exclusion at all, so its 45 test
// files were inputs to THIS program — which emits (`declaration`, `composite`,
// `outDir: dist`). That is the defect objectui#4006 / #4836 / #6943 hit three
// times and `pnpm check:published-tsconfig-exclude` exists to stop. The tests
// are still type-checked, by `tsconfig.test.json`, which `type-check` chains.
"exclude": [
"node_modules",
"dist",
"**/__tests__/**",
"**/__mocks__/**",
"**/__benchmarks__/**",
"**/*.test.ts",
"**/*.test.tsx"
]
}
31 changes: 31 additions & 0 deletions packages/plugin-charts/tsconfig.test.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
{
// Type-checks this package's TESTS, which `tsconfig.json` excludes.
// Modelled on `packages/plugin-map/tsconfig.test.json` — the sibling that
// already reads schema-catalog fixtures off disk from a `*-6939` render pin.
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
// The package build emits `dist`; this project emits nothing, so it must
// not inherit `composite` / `declaration` from the build config.
"composite": false,
// Naming `types` at all switches off automatic `@types/*` inclusion, so
// every type package these tests need is named here.
// - `@testing-library/jest-dom` is a global augmentation, not an import,
// and does not live under `@types/`, so it is never picked up
// automatically (two test files here use its matchers).
// - `node` is what `ChartRenderer.catalogRender-6939.test.tsx` needs: it
// reads the schema-catalog chart fixtures off disk with `node:fs` /
// `node:path` / `node:url` and hashes their render with `node:crypto`.
// The pin has to cross a package boundary, and it cannot move to
// `examples/schema-catalog/test/` — `vi.mock('recharts')` does not
// intercept plugin-charts' import from there, because pnpm's strict
// layout gives the two packages different resolved paths for recharts,
// so the real `ResponsiveContainer` renders 0x0 and nothing paints.
"types": ["@testing-library/jest-dom", "node"],
// Drop the root tsconfig's source-tree `paths` so `@object-ui/*` resolves
// through the workspace dependency's built `.d.ts` instead of pulling
// sibling sources in as program inputs (TS6059).
"paths": {}
},
"include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.d.ts"]
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .changeset/7113-chart-data-model.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
---
'@object-ui/types': minor
---

`ChartSchema` declares the data model it renders — chart-level `data` and `xAxisKey`, with
the bare-string `xAxis` folded onto the latter — and `ChartDataSeries` accepts both binding
dialects (objectui#7113 option B, 项目总监席 总监批 #28 2026-09-01 「同意」; and
objectui#6939's `chart` row, maintainer ruling 2026-09-02 「同意」 — both rulings
independently instructed declaring these two keys, so they land as one change).

⚠️ Shipped as `minor`, not `patch`, because two document classes that validated before now
REFUSE. objectui#6939 grades this class "patch where the accept set only widens toward what
already renders"; this change is not a pure widening, so it takes the level objectui#6896
set for the same transition in this same file — the mirror starting to refuse — and for the
same reason: this repository's `major` is a cross-repo pin to `@objectstack`'s major rather
than a severity dial, so the break is announced here, which is the channel that carries it.

## What now refuses (the narrowing, named)

**Three** classes validated before and refuse now. The first two survived only on
`BaseSchema`'s `.passthrough()`; the third was silently STRIPPED by the non-strict
`ChartDataSeriesSchema` object.

```jsonc
// 1. chart-level `data` that is not an array of row objects
{ "type": "chart", "chartType": "bar", "data": "oops" } // now: [data] expected array
{ "type": "chart", "chartType": "bar", "data": [1,2,3] } // now: [data.0] expected object

// 2. a non-string `xAxisKey`
{ "type": "chart", "chartType": "bar", "xAxisKey": 123 } // now: [xAxisKey] expected string

// 3. a non-string `series[].dataKey` ⚠️ THIS ONE DRAWS A REAL CHART TODAY
{ "type": "chart", "chartType": "bar",
"series": [{ "name": "a", "dataKey": 123 }] } // now: [series.0.dataKey] expected string
```

⚠️ **Class 3 is the sharp one and is called out separately.** Classes 1 and 2 are malformed
documents whose chart was already broken. Class 3 is not: at base it parsed to
`series: [{ name: 'a' }]` (the non-string `dataKey` stripped in silence) and
`normalizeChartSchema` renders it — `str(123)` is `undefined`, so the read falls back to
`name` and yields `series: [{ dataKey: 'a' }]` (`normalizeChartSchema.ts:239`). So this is a
narrowing away from a document that **renders today**, which is precisely the distinction
objectui#6939's grading language turns on. `dataKey: null` behaves identically. Measured on
both states; the declaration itself is right, and this note is the disclosure it was owed.

## Corrected: what class 2 actually did

An earlier draft of this changeset said `xAxisKey: 123` "drew an EMPTY CHART". The read
sites do not support that: `ChartRenderer.tsx:133` takes `schema.xAxisKey` raw and the rows
still reach `data` at `:164`, while the normaliser drops the key (`str(123)` is `undefined`).
Measured through `normalizeChartSchema`, the result keeps the series and loses only the
category binding — **a drawn chart with a broken category axis**, not an empty one. Class 1
(`data` malformed) is the one that leaves nothing to plot.

## Also changed on the published surface: combinators

Both consts now carry a check (`ChartSchema` the `xAxis` fold, `ChartDataSeriesSchema` the
at-least-one-binding refinement), and on zod 4.4.3 that makes three combinators **throw**
where they previously returned a schema:

```
ChartSchema.pick(…) / .omit(…) / .partial() -> throws "cannot be used on object
ChartDataSeriesSchema.pick(…) / .omit(…) / … schemas containing refinements"
```

`.extend()` with a NEW key still works and preserves the fold and the refinement;
`.optional()`, `z.discriminatedUnion`, `z.toJSONSchema` and `safeValidateSchema` are all
unaffected. Nothing in this repository calls the throwing combinators on either const, and
the published surface already ships refined mirrors (`objectql.zod.ts`, `complex.zod.ts`,
`form.zod.ts`, `app.zod.ts`), so the class is not new — but it is a real behaviour change on
a published export and it belongs in the release note rather than in a reviewer's file.

## What now validates (the widening)

`series: [{ dataKey: 'revenue' }]`. `normalizeSeries` reads
`str(raw.dataKey) ?? str(raw.name)`, so `dataKey` alone has always been a complete binding
— but the mirror REQUIRED `name` and refused it. That is why both catalog chart fixtures
(`advanced-line-chart.json`, `area-chart.json`) failed validation: they are the `chart: 2`
entry in `objectui check`'s 28-file census. `name` is now optional, `dataKey` is declared,
and a series binding to NEITHER is refused by name at `series.N.name` — the same path the
required flag used to report, so the diagnostic did not move.

## `xAxis` folds; it does not become a second name

`xAxis: 'month'` is accepted at input and is ABSENT from the output, having landed on
`xAxisKey`. When both are written the canonical key is kept and the alias dropped — not a
precedence rule minted here, but the one already running at `normalizeChartSchema.ts:292`,
where `xAxisKey` is the first limb of `str(schema.xAxisKey) ?? xAxisSpec?.field ??
str(xAxisRaw)`. No chart that renders today changes what it renders.

⚠️ The `xAxis` **config object** (`{ field, format, title, showGridLines }`) is NOT folded.
Only the bare string is a sibling spelling of `xAxisKey`; the object's presentation keys
survive separately into `out.xAxis` (`normalizeChartSchema.ts:289-291`), and folding it
would discard them.

## Not done, deliberately

objectui#6939's `chart` row also says "`series[].data` stops being required". On this base
it already is not: objectui#6896 replaced it with `retirementTombstone(...)` —
`z.never({ error }).optional()` — which is optional AND refuses any authored value by name.
Implementing the clause literally would re-widen a retired key and reverse a landed ruling,
so it is not done.

## FROM → TO

```ts
// ChartDataSeries
- name: string;
+ name?: string;
+ dataKey?: string;

// ChartSchema
+ data?: Array<Record<string, any>>;
+ xAxisKey?: string;
```
2 changes: 1 addition & 1 deletion packages/plugin-charts/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "eslint ."
},
"dependencies": {
Expand Down
175 changes: 175 additions & 0 deletions packages/plugin-charts/src/ChartRenderer.catalogRender-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
/**
* 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#6939, the `chart` group — the RENDER half. The validator-side
* contract is pinned in
* `packages/types/src/__tests__/chart-data-model-7113.test.ts`.
*
* Ruling 5510084784 (maintainer 2026-09-02, verbatim 「同意」) sets the bar per
* group, from objectui#6318's triage: "the catalog entry validates, **and its
* render is byte-identical in element count and text before and after**". The
* validator half alone cannot make the claim — a "repair" that also changes
* what is drawn has not proved the SCHEMA was wrong, it has changed the
* product. Both landed sibling groups carry this half
* (`plugin-map/src/ObjectMap.catalogRecordSource-6939.test.tsx`,
* `examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx`), and
* this file is the chart group's.
*
* ## The asymmetry this group has and the siblings do not
*
* At BASE both fixtures **FAIL** validation (`series.N.name`: they are authored
* in the `dataKey` dialect, which the mirror required `name` instead of) while
* **drawing correctly**. So the before/after identity cannot be measured through
* a parse-then-render path — there is no parse at base. It is measured through
* the renderer directly, which is also honest about how charts actually reach
* the screen: `ChartRenderer` consumes the AUTHORED schema and calls
* `normalizeChartSchema` on it; it never sees the mirror's parse output.
*
* That also bounds what this file can regress on: the objectui#7113 diff touches
* no file under `packages/plugin-charts`, so the renderer is byte-identical
* across the change. The pin's job is to keep it that way as the mirror moves.
*
* ## PRE_REPAIR — measured, not transcribed
*
* Captured on `origin/main` @ `98d4108a2` (the merge-base), both faces
* untouched, through THIS file's `measure()` in a worktree at that commit.
*
* ⚠️ Element counts are HARNESS-BOUND and must never be carried over from
* another run. The contract review of PR #7545 measured this same property on
* its own harness and read `advanced-line-chart` 136 / `area-chart` 132 with 11
* x-axis ticks; this harness reads 136 and **127** with **6** ticks. Both are
* correct about their own harness — `ResponsiveContainer` is mocked to a fixed
* 480x320 here, and tick density is a function of that width
* (`AdvancedChartImpl`'s categorical axis thins labels by available space). The
* claim that discriminates is IDENTITY WITHIN ONE HARNESS, which is why the
* numbers below were re-derived here rather than copied.
*
* Three readings per fixture, because a count alone cannot tell a swapped
* element from an equal one: element count, a tag census, and a SHA-256 of the
* text.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0x0
// under the headless DOM, so nothing paints. Fix its size — the same shim the
// other render tests in this package use.
vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import { ChartRenderer } from './ChartRenderer';
import { safeValidateSchema } from '@object-ui/types/zod';

afterEach(cleanup);

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const NAMES = ['advanced-line-chart', 'area-chart'] as const;

function catalogEntry(name: (typeof NAMES)[number]): Record<string, unknown> {
const file = path.join(REPO_ROOT, 'examples/schema-catalog/src/schemas/plugin-charts', `${name}.json`);
return JSON.parse(fs.readFileSync(file, 'utf8'));
}

interface Reading {
elements: number;
tags: Record<string, number>;
lines: number;
areas: number;
xTicks: number;
sha256: string;
}

/** Measured at `98d4108a2` through `measure()` below. See the header. */
const PRE_REPAIR: Record<(typeof NAMES)[number], Reading> = {
'advanced-line-chart': {
elements: 136,
tags: { DIV: 9, STYLE: 1, svg: 1, title: 1, desc: 1, g: 48, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 14, stop: 28, path: 2, text: 11, tspan: 11 },
lines: 2,
areas: 0,
xTicks: 6,
sha256: 'dd56a5f8c25242bb737db18308f2952c3f5dabbe1dcb6eb9e0b71cdb3a3bbccd',
},
'area-chart': {
elements: 127,
tags: { DIV: 7, STYLE: 1, svg: 1, title: 1, desc: 1, g: 47, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 12, stop: 24, path: 2, text: 11, tspan: 11 },
lines: 0,
areas: 1,
xTicks: 6,
sha256: 'c1270f6053d2dd82c9da87b57607ae04896bf3fc317c9c15357632b48fa05386',
},
};

async function measure(schema: unknown): Promise<Reading> {
const { container } = render(
<ChartRenderer schema={{ ...(schema as any), isAnimationActive: false }} />,
);
// `AdvancedChartImpl` is lazy — wait for the real plot, not the skeleton. A
// fixture that stopped drawing fails HERE, loudly, rather than reporting a
// tidy zero further down.
await waitFor(() => {
if (!container.querySelector('.recharts-surface')) throw new Error('nothing drew');
});
const nodes = Array.from(container.querySelectorAll('*'));
// React's `useId` lands in the injected <style> block (`chart-_r_0_`), so it
// varies with render ORDER inside the file. Normalise it, or the hash pins
// the test order rather than the drawing.
const text = (container.textContent ?? '').replace(/chart-_r_[0-9a-z]+_/g, 'chart-ID');
return {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((h, el) => ((h[el.tagName] = (h[el.tagName] ?? 0) + 1), h), {}),
lines: container.querySelectorAll('.recharts-line').length,
areas: container.querySelectorAll('.recharts-area').length,
xTicks: container.querySelectorAll('.recharts-xAxis .recharts-cartesian-axis-tick').length,
sha256: createHash('sha256').update(text).digest('hex'),
};
}

describe('objectui#6939 `chart` — the catalog fixtures draw exactly what they drew before', () => {
it.each(NAMES)('%s renders identically to BASE', async (name) => {
expect(await measure(catalogEntry(name))).toEqual(PRE_REPAIR[name]);
});

/*
* The verdict half — the thing that DID change. At `98d4108a2` both of these
* reported `series.N.name: Invalid input: expected string, received undefined`
* from `safeValidateSchema` while drawing the readings pinned above. Together
* with the identity above, that is objectui#6318's bar: the validator's
* verdict moves, the drawing does not.
*/
it.each(NAMES)('%s now VALIDATES, which is the half that changed', (name) => {
const r = safeValidateSchema(catalogEntry(name));
expect(r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`)).toEqual([]);
});

/*
* LIT CONTROL for the identity assertions. Without it, `toEqual(PRE_REPAIR)`
* passing proves only that two things matched — it cannot show the instrument
* would have NOTICED a difference. Perturb the authored rows and the same
* measurement must move.
*/
it('CONTROL — the measurement detects a changed drawing', async () => {
const doc = catalogEntry('area-chart') as { data: Record<string, unknown>[] };
const perturbed = { ...doc, data: [...doc.data, { month: 'Jul', users: 2600 }] };
const reading = await measure(perturbed);
expect(reading.sha256).not.toBe(PRE_REPAIR['area-chart'].sha256);
expect(reading.elements).not.toBe(PRE_REPAIR['area-chart'].elements);
});
});
19 changes: 18 additions & 1 deletion packages/plugin-charts/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,5 +13,22 @@
"composite": true,
"skipLibCheck": true
},
"include": ["src"]
"include": ["src"],
// Tooling is excluded by DIRECTORY, not just by file NAME — the arrangement
// 34 of this repo's 38 test-bearing packages already use (see
// `packages/plugin-map/tsconfig.json`, whose comment carries the history).
// Until objectui#7113 this package had no exclusion at all, so its 45 test
// files were inputs to THIS program — which emits (`declaration`, `composite`,
// `outDir: dist`). That is the defect objectui#4006 / #4836 / #6943 hit three
// times and `pnpm check:published-tsconfig-exclude` exists to stop. The tests
// are still type-checked, by `tsconfig.test.json`, which `type-check` chains.
"exclude": [
"node_modules",
"dist",
"**/__tests__/**",
"**/__mocks__/**",
"**/__benchmarks__/**",
"**/*.test.ts",
"**/*.test.tsx"
]
}
31 changes: 31 additions & 0 deletions packages/plugin-charts/tsconfig.test.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
{
// Type-checks this package's TESTS, which `tsconfig.json` excludes.
// Modelled on `packages/plugin-map/tsconfig.test.json` — the sibling that
// already reads schema-catalog fixtures off disk from a `*-6939` render pin.
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
// The package build emits `dist`; this project emits nothing, so it must
// not inherit `composite` / `declaration` from the build config.
"composite": false,
// Naming `types` at all switches off automatic `@types/*` inclusion, so
// every type package these tests need is named here.
// - `@testing-library/jest-dom` is a global augmentation, not an import,
// and does not live under `@types/`, so it is never picked up
// automatically (two test files here use its matchers).
// - `node` is what `ChartRenderer.catalogRender-6939.test.tsx` needs: it
// reads the schema-catalog chart fixtures off disk with `node:fs` /
// `node:path` / `node:url` and hashes their render with `node:crypto`.
// The pin has to cross a package boundary, and it cannot move to
// `examples/schema-catalog/test/` — `vi.mock('recharts')` does not
// intercept plugin-charts' import from there, because pnpm's strict
// layout gives the two packages different resolved paths for recharts,
// so the real `ResponsiveContainer` renders 0x0 and nothing paints.
"types": ["@testing-library/jest-dom", "node"],
// Drop the root tsconfig's source-tree `paths` so `@object-ui/*` resolves
// through the workspace dependency's built `.d.ts` instead of pulling
// sibling sources in as program inputs (TS6059).
"paths": {}
},
"include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.d.ts"]
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .changeset/7113-chart-data-model.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
---
'@object-ui/types': minor
---

`ChartSchema` declares the data model it renders — chart-level `data` and `xAxisKey`, with
the bare-string `xAxis` folded onto the latter — and `ChartDataSeries` accepts both binding
dialects (objectui#7113 option B, 项目总监席 总监批 #28 2026-09-01 「同意」; and
objectui#6939's `chart` row, maintainer ruling 2026-09-02 「同意」 — both rulings
independently instructed declaring these two keys, so they land as one change).

⚠️ Shipped as `minor`, not `patch`, because two document classes that validated before now
REFUSE. objectui#6939 grades this class "patch where the accept set only widens toward what
already renders"; this change is not a pure widening, so it takes the level objectui#6896
set for the same transition in this same file — the mirror starting to refuse — and for the
same reason: this repository's `major` is a cross-repo pin to `@objectstack`'s major rather
than a severity dial, so the break is announced here, which is the channel that carries it.

## What now refuses (the narrowing, named)

**Three** classes validated before and refuse now. The first two survived only on
`BaseSchema`'s `.passthrough()`; the third was silently STRIPPED by the non-strict
`ChartDataSeriesSchema` object.

```jsonc
// 1. chart-level `data` that is not an array of row objects
{ "type": "chart", "chartType": "bar", "data": "oops" } // now: [data] expected array
{ "type": "chart", "chartType": "bar", "data": [1,2,3] } // now: [data.0] expected object

// 2. a non-string `xAxisKey`
{ "type": "chart", "chartType": "bar", "xAxisKey": 123 } // now: [xAxisKey] expected string

// 3. a non-string `series[].dataKey` ⚠️ THIS ONE DRAWS A REAL CHART TODAY
{ "type": "chart", "chartType": "bar",
"series": [{ "name": "a", "dataKey": 123 }] } // now: [series.0.dataKey] expected string
```

⚠️ **Class 3 is the sharp one and is called out separately.** Classes 1 and 2 are malformed
documents whose chart was already broken. Class 3 is not: at base it parsed to
`series: [{ name: 'a' }]` (the non-string `dataKey` stripped in silence) and
`normalizeChartSchema` renders it — `str(123)` is `undefined`, so the read falls back to
`name` and yields `series: [{ dataKey: 'a' }]` (`normalizeChartSchema.ts:239`). So this is a
narrowing away from a document that **renders today**, which is precisely the distinction
objectui#6939's grading language turns on. `dataKey: null` behaves identically. Measured on
both states; the declaration itself is right, and this note is the disclosure it was owed.

## Corrected: what class 2 actually did

An earlier draft of this changeset said `xAxisKey: 123` "drew an EMPTY CHART". The read
sites do not support that: `ChartRenderer.tsx:133` takes `schema.xAxisKey` raw and the rows
still reach `data` at `:164`, while the normaliser drops the key (`str(123)` is `undefined`).
Measured through `normalizeChartSchema`, the result keeps the series and loses only the
category binding — **a drawn chart with a broken category axis**, not an empty one. Class 1
(`data` malformed) is the one that leaves nothing to plot.

## Also changed on the published surface: combinators

Both consts now carry a check (`ChartSchema` the `xAxis` fold, `ChartDataSeriesSchema` the
at-least-one-binding refinement), and on zod 4.4.3 that makes three combinators **throw**
where they previously returned a schema:

```
ChartSchema.pick(…) / .omit(…) / .partial() -> throws "cannot be used on object
ChartDataSeriesSchema.pick(…) / .omit(…) / … schemas containing refinements"
```

`.extend()` with a NEW key still works and preserves the fold and the refinement;
`.optional()`, `z.discriminatedUnion`, `z.toJSONSchema` and `safeValidateSchema` are all
unaffected. Nothing in this repository calls the throwing combinators on either const, and
the published surface already ships refined mirrors (`objectql.zod.ts`, `complex.zod.ts`,
`form.zod.ts`, `app.zod.ts`), so the class is not new — but it is a real behaviour change on
a published export and it belongs in the release note rather than in a reviewer's file.

## What now validates (the widening)

`series: [{ dataKey: 'revenue' }]`. `normalizeSeries` reads
`str(raw.dataKey) ?? str(raw.name)`, so `dataKey` alone has always been a complete binding
— but the mirror REQUIRED `name` and refused it. That is why both catalog chart fixtures
(`advanced-line-chart.json`, `area-chart.json`) failed validation: they are the `chart: 2`
entry in `objectui check`'s 28-file census. `name` is now optional, `dataKey` is declared,
and a series binding to NEITHER is refused by name at `series.N.name` — the same path the
required flag used to report, so the diagnostic did not move.

## `xAxis` folds; it does not become a second name

`xAxis: 'month'` is accepted at input and is ABSENT from the output, having landed on
`xAxisKey`. When both are written the canonical key is kept and the alias dropped — not a
precedence rule minted here, but the one already running at `normalizeChartSchema.ts:292`,
where `xAxisKey` is the first limb of `str(schema.xAxisKey) ?? xAxisSpec?.field ??
str(xAxisRaw)`. No chart that renders today changes what it renders.

⚠️ The `xAxis` **config object** (`{ field, format, title, showGridLines }`) is NOT folded.
Only the bare string is a sibling spelling of `xAxisKey`; the object's presentation keys
survive separately into `out.xAxis` (`normalizeChartSchema.ts:289-291`), and folding it
would discard them.

## Not done, deliberately

objectui#6939's `chart` row also says "`series[].data` stops being required". On this base
it already is not: objectui#6896 replaced it with `retirementTombstone(...)` —
`z.never({ error }).optional()` — which is optional AND refuses any authored value by name.
Implementing the clause literally would re-widen a retired key and reverse a landed ruling,
so it is not done.

## FROM → TO

```ts
// ChartDataSeries
- name: string;
+ name?: string;
+ dataKey?: string;

// ChartSchema
+ data?: Array<Record<string, any>>;
+ xAxisKey?: string;
```
2 changes: 1 addition & 1 deletion packages/plugin-charts/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "eslint ."
},
"dependencies": {
Expand Down
175 changes: 175 additions & 0 deletions packages/plugin-charts/src/ChartRenderer.catalogRender-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
/**
* 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#6939, the `chart` group — the RENDER half. The validator-side
* contract is pinned in
* `packages/types/src/__tests__/chart-data-model-7113.test.ts`.
*
* Ruling 5510084784 (maintainer 2026-09-02, verbatim 「同意」) sets the bar per
* group, from objectui#6318's triage: "the catalog entry validates, **and its
* render is byte-identical in element count and text before and after**". The
* validator half alone cannot make the claim — a "repair" that also changes
* what is drawn has not proved the SCHEMA was wrong, it has changed the
* product. Both landed sibling groups carry this half
* (`plugin-map/src/ObjectMap.catalogRecordSource-6939.test.tsx`,
* `examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx`), and
* this file is the chart group's.
*
* ## The asymmetry this group has and the siblings do not
*
* At BASE both fixtures **FAIL** validation (`series.N.name`: they are authored
* in the `dataKey` dialect, which the mirror required `name` instead of) while
* **drawing correctly**. So the before/after identity cannot be measured through
* a parse-then-render path — there is no parse at base. It is measured through
* the renderer directly, which is also honest about how charts actually reach
* the screen: `ChartRenderer` consumes the AUTHORED schema and calls
* `normalizeChartSchema` on it; it never sees the mirror's parse output.
*
* That also bounds what this file can regress on: the objectui#7113 diff touches
* no file under `packages/plugin-charts`, so the renderer is byte-identical
* across the change. The pin's job is to keep it that way as the mirror moves.
*
* ## PRE_REPAIR — measured, not transcribed
*
* Captured on `origin/main` @ `98d4108a2` (the merge-base), both faces
* untouched, through THIS file's `measure()` in a worktree at that commit.
*
* ⚠️ Element counts are HARNESS-BOUND and must never be carried over from
* another run. The contract review of PR #7545 measured this same property on
* its own harness and read `advanced-line-chart` 136 / `area-chart` 132 with 11
* x-axis ticks; this harness reads 136 and **127** with **6** ticks. Both are
* correct about their own harness — `ResponsiveContainer` is mocked to a fixed
* 480x320 here, and tick density is a function of that width
* (`AdvancedChartImpl`'s categorical axis thins labels by available space). The
* claim that discriminates is IDENTITY WITHIN ONE HARNESS, which is why the
* numbers below were re-derived here rather than copied.
*
* Three readings per fixture, because a count alone cannot tell a swapped
* element from an equal one: element count, a tag census, and a SHA-256 of the
* text.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0x0
// under the headless DOM, so nothing paints. Fix its size — the same shim the
// other render tests in this package use.
vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import { ChartRenderer } from './ChartRenderer';
import { safeValidateSchema } from '@object-ui/types/zod';

afterEach(cleanup);

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const NAMES = ['advanced-line-chart', 'area-chart'] as const;

function catalogEntry(name: (typeof NAMES)[number]): Record<string, unknown> {
const file = path.join(REPO_ROOT, 'examples/schema-catalog/src/schemas/plugin-charts', `${name}.json`);
return JSON.parse(fs.readFileSync(file, 'utf8'));
}

interface Reading {
elements: number;
tags: Record<string, number>;
lines: number;
areas: number;
xTicks: number;
sha256: string;
}

/** Measured at `98d4108a2` through `measure()` below. See the header. */
const PRE_REPAIR: Record<(typeof NAMES)[number], Reading> = {
'advanced-line-chart': {
elements: 136,
tags: { DIV: 9, STYLE: 1, svg: 1, title: 1, desc: 1, g: 48, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 14, stop: 28, path: 2, text: 11, tspan: 11 },
lines: 2,
areas: 0,
xTicks: 6,
sha256: 'dd56a5f8c25242bb737db18308f2952c3f5dabbe1dcb6eb9e0b71cdb3a3bbccd',
},
'area-chart': {
elements: 127,
tags: { DIV: 7, STYLE: 1, svg: 1, title: 1, desc: 1, g: 47, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 12, stop: 24, path: 2, text: 11, tspan: 11 },
lines: 0,
areas: 1,
xTicks: 6,
sha256: 'c1270f6053d2dd82c9da87b57607ae04896bf3fc317c9c15357632b48fa05386',
},
};

async function measure(schema: unknown): Promise<Reading> {
const { container } = render(
<ChartRenderer schema={{ ...(schema as any), isAnimationActive: false }} />,
);
// `AdvancedChartImpl` is lazy — wait for the real plot, not the skeleton. A
// fixture that stopped drawing fails HERE, loudly, rather than reporting a
// tidy zero further down.
await waitFor(() => {
if (!container.querySelector('.recharts-surface')) throw new Error('nothing drew');
});
const nodes = Array.from(container.querySelectorAll('*'));
// React's `useId` lands in the injected <style> block (`chart-_r_0_`), so it
// varies with render ORDER inside the file. Normalise it, or the hash pins
// the test order rather than the drawing.
const text = (container.textContent ?? '').replace(/chart-_r_[0-9a-z]+_/g, 'chart-ID');
return {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((h, el) => ((h[el.tagName] = (h[el.tagName] ?? 0) + 1), h), {}),
lines: container.querySelectorAll('.recharts-line').length,
areas: container.querySelectorAll('.recharts-area').length,
xTicks: container.querySelectorAll('.recharts-xAxis .recharts-cartesian-axis-tick').length,
sha256: createHash('sha256').update(text).digest('hex'),
};
}

describe('objectui#6939 `chart` — the catalog fixtures draw exactly what they drew before', () => {
it.each(NAMES)('%s renders identically to BASE', async (name) => {
expect(await measure(catalogEntry(name))).toEqual(PRE_REPAIR[name]);
});

/*
* The verdict half — the thing that DID change. At `98d4108a2` both of these
* reported `series.N.name: Invalid input: expected string, received undefined`
* from `safeValidateSchema` while drawing the readings pinned above. Together
* with the identity above, that is objectui#6318's bar: the validator's
* verdict moves, the drawing does not.
*/
it.each(NAMES)('%s now VALIDATES, which is the half that changed', (name) => {
const r = safeValidateSchema(catalogEntry(name));
expect(r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`)).toEqual([]);
});

/*
* LIT CONTROL for the identity assertions. Without it, `toEqual(PRE_REPAIR)`
* passing proves only that two things matched — it cannot show the instrument
* would have NOTICED a difference. Perturb the authored rows and the same
* measurement must move.
*/
it('CONTROL — the measurement detects a changed drawing', async () => {
const doc = catalogEntry('area-chart') as { data: Record<string, unknown>[] };
const perturbed = { ...doc, data: [...doc.data, { month: 'Jul', users: 2600 }] };
const reading = await measure(perturbed);
expect(reading.sha256).not.toBe(PRE_REPAIR['area-chart'].sha256);
expect(reading.elements).not.toBe(PRE_REPAIR['area-chart'].elements);
});
});
19 changes: 18 additions & 1 deletion packages/plugin-charts/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,5 +13,22 @@
"composite": true,
"skipLibCheck": true
},
"include": ["src"]
"include": ["src"],
// Tooling is excluded by DIRECTORY, not just by file NAME — the arrangement
// 34 of this repo's 38 test-bearing packages already use (see
// `packages/plugin-map/tsconfig.json`, whose comment carries the history).
// Until objectui#7113 this package had no exclusion at all, so its 45 test
// files were inputs to THIS program — which emits (`declaration`, `composite`,
// `outDir: dist`). That is the defect objectui#4006 / #4836 / #6943 hit three
// times and `pnpm check:published-tsconfig-exclude` exists to stop. The tests
// are still type-checked, by `tsconfig.test.json`, which `type-check` chains.
"exclude": [
"node_modules",
"dist",
"**/__tests__/**",
"**/__mocks__/**",
"**/__benchmarks__/**",
"**/*.test.ts",
"**/*.test.tsx"
]
}
31 changes: 31 additions & 0 deletions packages/plugin-charts/tsconfig.test.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
{
// Type-checks this package's TESTS, which `tsconfig.json` excludes.
// Modelled on `packages/plugin-map/tsconfig.test.json` — the sibling that
// already reads schema-catalog fixtures off disk from a `*-6939` render pin.
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
// The package build emits `dist`; this project emits nothing, so it must
// not inherit `composite` / `declaration` from the build config.
"composite": false,
// Naming `types` at all switches off automatic `@types/*` inclusion, so
// every type package these tests need is named here.
// - `@testing-library/jest-dom` is a global augmentation, not an import,
// and does not live under `@types/`, so it is never picked up
// automatically (two test files here use its matchers).
// - `node` is what `ChartRenderer.catalogRender-6939.test.tsx` needs: it
// reads the schema-catalog chart fixtures off disk with `node:fs` /
// `node:path` / `node:url` and hashes their render with `node:crypto`.
// The pin has to cross a package boundary, and it cannot move to
// `examples/schema-catalog/test/` — `vi.mock('recharts')` does not
// intercept plugin-charts' import from there, because pnpm's strict
// layout gives the two packages different resolved paths for recharts,
// so the real `ResponsiveContainer` renders 0x0 and nothing paints.
"types": ["@testing-library/jest-dom", "node"],
// Drop the root tsconfig's source-tree `paths` so `@object-ui/*` resolves
// through the workspace dependency's built `.d.ts` instead of pulling
// sibling sources in as program inputs (TS6059).
"paths": {}
},
"include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.d.ts"]
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .changeset/7113-chart-data-model.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
---
'@object-ui/types': minor
---

`ChartSchema` declares the data model it renders — chart-level `data` and `xAxisKey`, with
the bare-string `xAxis` folded onto the latter — and `ChartDataSeries` accepts both binding
dialects (objectui#7113 option B, 项目总监席 总监批 #28 2026-09-01 「同意」; and
objectui#6939's `chart` row, maintainer ruling 2026-09-02 「同意」 — both rulings
independently instructed declaring these two keys, so they land as one change).

⚠️ Shipped as `minor`, not `patch`, because two document classes that validated before now
REFUSE. objectui#6939 grades this class "patch where the accept set only widens toward what
already renders"; this change is not a pure widening, so it takes the level objectui#6896
set for the same transition in this same file — the mirror starting to refuse — and for the
same reason: this repository's `major` is a cross-repo pin to `@objectstack`'s major rather
than a severity dial, so the break is announced here, which is the channel that carries it.

## What now refuses (the narrowing, named)

**Three** classes validated before and refuse now. The first two survived only on
`BaseSchema`'s `.passthrough()`; the third was silently STRIPPED by the non-strict
`ChartDataSeriesSchema` object.

```jsonc
// 1. chart-level `data` that is not an array of row objects
{ "type": "chart", "chartType": "bar", "data": "oops" } // now: [data] expected array
{ "type": "chart", "chartType": "bar", "data": [1,2,3] } // now: [data.0] expected object

// 2. a non-string `xAxisKey`
{ "type": "chart", "chartType": "bar", "xAxisKey": 123 } // now: [xAxisKey] expected string

// 3. a non-string `series[].dataKey` ⚠️ THIS ONE DRAWS A REAL CHART TODAY
{ "type": "chart", "chartType": "bar",
"series": [{ "name": "a", "dataKey": 123 }] } // now: [series.0.dataKey] expected string
```

⚠️ **Class 3 is the sharp one and is called out separately.** Classes 1 and 2 are malformed
documents whose chart was already broken. Class 3 is not: at base it parsed to
`series: [{ name: 'a' }]` (the non-string `dataKey` stripped in silence) and
`normalizeChartSchema` renders it — `str(123)` is `undefined`, so the read falls back to
`name` and yields `series: [{ dataKey: 'a' }]` (`normalizeChartSchema.ts:239`). So this is a
narrowing away from a document that **renders today**, which is precisely the distinction
objectui#6939's grading language turns on. `dataKey: null` behaves identically. Measured on
both states; the declaration itself is right, and this note is the disclosure it was owed.

## Corrected: what class 2 actually did

An earlier draft of this changeset said `xAxisKey: 123` "drew an EMPTY CHART". The read
sites do not support that: `ChartRenderer.tsx:133` takes `schema.xAxisKey` raw and the rows
still reach `data` at `:164`, while the normaliser drops the key (`str(123)` is `undefined`).
Measured through `normalizeChartSchema`, the result keeps the series and loses only the
category binding — **a drawn chart with a broken category axis**, not an empty one. Class 1
(`data` malformed) is the one that leaves nothing to plot.

## Also changed on the published surface: combinators

Both consts now carry a check (`ChartSchema` the `xAxis` fold, `ChartDataSeriesSchema` the
at-least-one-binding refinement), and on zod 4.4.3 that makes three combinators **throw**
where they previously returned a schema:

```
ChartSchema.pick(…) / .omit(…) / .partial() -> throws "cannot be used on object
ChartDataSeriesSchema.pick(…) / .omit(…) / … schemas containing refinements"
```

`.extend()` with a NEW key still works and preserves the fold and the refinement;
`.optional()`, `z.discriminatedUnion`, `z.toJSONSchema` and `safeValidateSchema` are all
unaffected. Nothing in this repository calls the throwing combinators on either const, and
the published surface already ships refined mirrors (`objectql.zod.ts`, `complex.zod.ts`,
`form.zod.ts`, `app.zod.ts`), so the class is not new — but it is a real behaviour change on
a published export and it belongs in the release note rather than in a reviewer's file.

## What now validates (the widening)

`series: [{ dataKey: 'revenue' }]`. `normalizeSeries` reads
`str(raw.dataKey) ?? str(raw.name)`, so `dataKey` alone has always been a complete binding
— but the mirror REQUIRED `name` and refused it. That is why both catalog chart fixtures
(`advanced-line-chart.json`, `area-chart.json`) failed validation: they are the `chart: 2`
entry in `objectui check`'s 28-file census. `name` is now optional, `dataKey` is declared,
and a series binding to NEITHER is refused by name at `series.N.name` — the same path the
required flag used to report, so the diagnostic did not move.

## `xAxis` folds; it does not become a second name

`xAxis: 'month'` is accepted at input and is ABSENT from the output, having landed on
`xAxisKey`. When both are written the canonical key is kept and the alias dropped — not a
precedence rule minted here, but the one already running at `normalizeChartSchema.ts:292`,
where `xAxisKey` is the first limb of `str(schema.xAxisKey) ?? xAxisSpec?.field ??
str(xAxisRaw)`. No chart that renders today changes what it renders.

⚠️ The `xAxis` **config object** (`{ field, format, title, showGridLines }`) is NOT folded.
Only the bare string is a sibling spelling of `xAxisKey`; the object's presentation keys
survive separately into `out.xAxis` (`normalizeChartSchema.ts:289-291`), and folding it
would discard them.

## Not done, deliberately

objectui#6939's `chart` row also says "`series[].data` stops being required". On this base
it already is not: objectui#6896 replaced it with `retirementTombstone(...)` —
`z.never({ error }).optional()` — which is optional AND refuses any authored value by name.
Implementing the clause literally would re-widen a retired key and reverse a landed ruling,
so it is not done.

## FROM → TO

```ts
// ChartDataSeries
- name: string;
+ name?: string;
+ dataKey?: string;

// ChartSchema
+ data?: Array<Record<string, any>>;
+ xAxisKey?: string;
```
2 changes: 1 addition & 1 deletion packages/plugin-charts/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "eslint ."
},
"dependencies": {
Expand Down
175 changes: 175 additions & 0 deletions packages/plugin-charts/src/ChartRenderer.catalogRender-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
/**
* 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#6939, the `chart` group — the RENDER half. The validator-side
* contract is pinned in
* `packages/types/src/__tests__/chart-data-model-7113.test.ts`.
*
* Ruling 5510084784 (maintainer 2026-09-02, verbatim 「同意」) sets the bar per
* group, from objectui#6318's triage: "the catalog entry validates, **and its
* render is byte-identical in element count and text before and after**". The
* validator half alone cannot make the claim — a "repair" that also changes
* what is drawn has not proved the SCHEMA was wrong, it has changed the
* product. Both landed sibling groups carry this half
* (`plugin-map/src/ObjectMap.catalogRecordSource-6939.test.tsx`,
* `examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx`), and
* this file is the chart group's.
*
* ## The asymmetry this group has and the siblings do not
*
* At BASE both fixtures **FAIL** validation (`series.N.name`: they are authored
* in the `dataKey` dialect, which the mirror required `name` instead of) while
* **drawing correctly**. So the before/after identity cannot be measured through
* a parse-then-render path — there is no parse at base. It is measured through
* the renderer directly, which is also honest about how charts actually reach
* the screen: `ChartRenderer` consumes the AUTHORED schema and calls
* `normalizeChartSchema` on it; it never sees the mirror's parse output.
*
* That also bounds what this file can regress on: the objectui#7113 diff touches
* no file under `packages/plugin-charts`, so the renderer is byte-identical
* across the change. The pin's job is to keep it that way as the mirror moves.
*
* ## PRE_REPAIR — measured, not transcribed
*
* Captured on `origin/main` @ `98d4108a2` (the merge-base), both faces
* untouched, through THIS file's `measure()` in a worktree at that commit.
*
* ⚠️ Element counts are HARNESS-BOUND and must never be carried over from
* another run. The contract review of PR #7545 measured this same property on
* its own harness and read `advanced-line-chart` 136 / `area-chart` 132 with 11
* x-axis ticks; this harness reads 136 and **127** with **6** ticks. Both are
* correct about their own harness — `ResponsiveContainer` is mocked to a fixed
* 480x320 here, and tick density is a function of that width
* (`AdvancedChartImpl`'s categorical axis thins labels by available space). The
* claim that discriminates is IDENTITY WITHIN ONE HARNESS, which is why the
* numbers below were re-derived here rather than copied.
*
* Three readings per fixture, because a count alone cannot tell a swapped
* element from an equal one: element count, a tag census, and a SHA-256 of the
* text.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0x0
// under the headless DOM, so nothing paints. Fix its size — the same shim the
// other render tests in this package use.
vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import { ChartRenderer } from './ChartRenderer';
import { safeValidateSchema } from '@object-ui/types/zod';

afterEach(cleanup);

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const NAMES = ['advanced-line-chart', 'area-chart'] as const;

function catalogEntry(name: (typeof NAMES)[number]): Record<string, unknown> {
const file = path.join(REPO_ROOT, 'examples/schema-catalog/src/schemas/plugin-charts', `${name}.json`);
return JSON.parse(fs.readFileSync(file, 'utf8'));
}

interface Reading {
elements: number;
tags: Record<string, number>;
lines: number;
areas: number;
xTicks: number;
sha256: string;
}

/** Measured at `98d4108a2` through `measure()` below. See the header. */
const PRE_REPAIR: Record<(typeof NAMES)[number], Reading> = {
'advanced-line-chart': {
elements: 136,
tags: { DIV: 9, STYLE: 1, svg: 1, title: 1, desc: 1, g: 48, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 14, stop: 28, path: 2, text: 11, tspan: 11 },
lines: 2,
areas: 0,
xTicks: 6,
sha256: 'dd56a5f8c25242bb737db18308f2952c3f5dabbe1dcb6eb9e0b71cdb3a3bbccd',
},
'area-chart': {
elements: 127,
tags: { DIV: 7, STYLE: 1, svg: 1, title: 1, desc: 1, g: 47, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 12, stop: 24, path: 2, text: 11, tspan: 11 },
lines: 0,
areas: 1,
xTicks: 6,
sha256: 'c1270f6053d2dd82c9da87b57607ae04896bf3fc317c9c15357632b48fa05386',
},
};

async function measure(schema: unknown): Promise<Reading> {
const { container } = render(
<ChartRenderer schema={{ ...(schema as any), isAnimationActive: false }} />,
);
// `AdvancedChartImpl` is lazy — wait for the real plot, not the skeleton. A
// fixture that stopped drawing fails HERE, loudly, rather than reporting a
// tidy zero further down.
await waitFor(() => {
if (!container.querySelector('.recharts-surface')) throw new Error('nothing drew');
});
const nodes = Array.from(container.querySelectorAll('*'));
// React's `useId` lands in the injected <style> block (`chart-_r_0_`), so it
// varies with render ORDER inside the file. Normalise it, or the hash pins
// the test order rather than the drawing.
const text = (container.textContent ?? '').replace(/chart-_r_[0-9a-z]+_/g, 'chart-ID');
return {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((h, el) => ((h[el.tagName] = (h[el.tagName] ?? 0) + 1), h), {}),
lines: container.querySelectorAll('.recharts-line').length,
areas: container.querySelectorAll('.recharts-area').length,
xTicks: container.querySelectorAll('.recharts-xAxis .recharts-cartesian-axis-tick').length,
sha256: createHash('sha256').update(text).digest('hex'),
};
}

describe('objectui#6939 `chart` — the catalog fixtures draw exactly what they drew before', () => {
it.each(NAMES)('%s renders identically to BASE', async (name) => {
expect(await measure(catalogEntry(name))).toEqual(PRE_REPAIR[name]);
});

/*
* The verdict half — the thing that DID change. At `98d4108a2` both of these
* reported `series.N.name: Invalid input: expected string, received undefined`
* from `safeValidateSchema` while drawing the readings pinned above. Together
* with the identity above, that is objectui#6318's bar: the validator's
* verdict moves, the drawing does not.
*/
it.each(NAMES)('%s now VALIDATES, which is the half that changed', (name) => {
const r = safeValidateSchema(catalogEntry(name));
expect(r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`)).toEqual([]);
});

/*
* LIT CONTROL for the identity assertions. Without it, `toEqual(PRE_REPAIR)`
* passing proves only that two things matched — it cannot show the instrument
* would have NOTICED a difference. Perturb the authored rows and the same
* measurement must move.
*/
it('CONTROL — the measurement detects a changed drawing', async () => {
const doc = catalogEntry('area-chart') as { data: Record<string, unknown>[] };
const perturbed = { ...doc, data: [...doc.data, { month: 'Jul', users: 2600 }] };
const reading = await measure(perturbed);
expect(reading.sha256).not.toBe(PRE_REPAIR['area-chart'].sha256);
expect(reading.elements).not.toBe(PRE_REPAIR['area-chart'].elements);
});
});
19 changes: 18 additions & 1 deletion packages/plugin-charts/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,5 +13,22 @@
"composite": true,
"skipLibCheck": true
},
"include": ["src"]
"include": ["src"],
// Tooling is excluded by DIRECTORY, not just by file NAME — the arrangement
// 34 of this repo's 38 test-bearing packages already use (see
// `packages/plugin-map/tsconfig.json`, whose comment carries the history).
// Until objectui#7113 this package had no exclusion at all, so its 45 test
// files were inputs to THIS program — which emits (`declaration`, `composite`,
// `outDir: dist`). That is the defect objectui#4006 / #4836 / #6943 hit three
// times and `pnpm check:published-tsconfig-exclude` exists to stop. The tests
// are still type-checked, by `tsconfig.test.json`, which `type-check` chains.
"exclude": [
"node_modules",
"dist",
"**/__tests__/**",
"**/__mocks__/**",
"**/__benchmarks__/**",
"**/*.test.ts",
"**/*.test.tsx"
]
}
31 changes: 31 additions & 0 deletions packages/plugin-charts/tsconfig.test.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
{
// Type-checks this package's TESTS, which `tsconfig.json` excludes.
// Modelled on `packages/plugin-map/tsconfig.test.json` — the sibling that
// already reads schema-catalog fixtures off disk from a `*-6939` render pin.
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
// The package build emits `dist`; this project emits nothing, so it must
// not inherit `composite` / `declaration` from the build config.
"composite": false,
// Naming `types` at all switches off automatic `@types/*` inclusion, so
// every type package these tests need is named here.
// - `@testing-library/jest-dom` is a global augmentation, not an import,
// and does not live under `@types/`, so it is never picked up
// automatically (two test files here use its matchers).
// - `node` is what `ChartRenderer.catalogRender-6939.test.tsx` needs: it
// reads the schema-catalog chart fixtures off disk with `node:fs` /
// `node:path` / `node:url` and hashes their render with `node:crypto`.
// The pin has to cross a package boundary, and it cannot move to
// `examples/schema-catalog/test/` — `vi.mock('recharts')` does not
// intercept plugin-charts' import from there, because pnpm's strict
// layout gives the two packages different resolved paths for recharts,
// so the real `ResponsiveContainer` renders 0x0 and nothing paints.
"types": ["@testing-library/jest-dom", "node"],
// Drop the root tsconfig's source-tree `paths` so `@object-ui/*` resolves
// through the workspace dependency's built `.d.ts` instead of pulling
// sibling sources in as program inputs (TS6059).
"paths": {}
},
"include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.d.ts"]
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .changeset/7113-chart-data-model.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
---
'@object-ui/types': minor
---

`ChartSchema` declares the data model it renders — chart-level `data` and `xAxisKey`, with
the bare-string `xAxis` folded onto the latter — and `ChartDataSeries` accepts both binding
dialects (objectui#7113 option B, 项目总监席 总监批 #28 2026-09-01 「同意」; and
objectui#6939's `chart` row, maintainer ruling 2026-09-02 「同意」 — both rulings
independently instructed declaring these two keys, so they land as one change).

⚠️ Shipped as `minor`, not `patch`, because two document classes that validated before now
REFUSE. objectui#6939 grades this class "patch where the accept set only widens toward what
already renders"; this change is not a pure widening, so it takes the level objectui#6896
set for the same transition in this same file — the mirror starting to refuse — and for the
same reason: this repository's `major` is a cross-repo pin to `@objectstack`'s major rather
than a severity dial, so the break is announced here, which is the channel that carries it.

## What now refuses (the narrowing, named)

**Three** classes validated before and refuse now. The first two survived only on
`BaseSchema`'s `.passthrough()`; the third was silently STRIPPED by the non-strict
`ChartDataSeriesSchema` object.

```jsonc
// 1. chart-level `data` that is not an array of row objects
{ "type": "chart", "chartType": "bar", "data": "oops" } // now: [data] expected array
{ "type": "chart", "chartType": "bar", "data": [1,2,3] } // now: [data.0] expected object

// 2. a non-string `xAxisKey`
{ "type": "chart", "chartType": "bar", "xAxisKey": 123 } // now: [xAxisKey] expected string

// 3. a non-string `series[].dataKey` ⚠️ THIS ONE DRAWS A REAL CHART TODAY
{ "type": "chart", "chartType": "bar",
"series": [{ "name": "a", "dataKey": 123 }] } // now: [series.0.dataKey] expected string
```

⚠️ **Class 3 is the sharp one and is called out separately.** Classes 1 and 2 are malformed
documents whose chart was already broken. Class 3 is not: at base it parsed to
`series: [{ name: 'a' }]` (the non-string `dataKey` stripped in silence) and
`normalizeChartSchema` renders it — `str(123)` is `undefined`, so the read falls back to
`name` and yields `series: [{ dataKey: 'a' }]` (`normalizeChartSchema.ts:239`). So this is a
narrowing away from a document that **renders today**, which is precisely the distinction
objectui#6939's grading language turns on. `dataKey: null` behaves identically. Measured on
both states; the declaration itself is right, and this note is the disclosure it was owed.

## Corrected: what class 2 actually did

An earlier draft of this changeset said `xAxisKey: 123` "drew an EMPTY CHART". The read
sites do not support that: `ChartRenderer.tsx:133` takes `schema.xAxisKey` raw and the rows
still reach `data` at `:164`, while the normaliser drops the key (`str(123)` is `undefined`).
Measured through `normalizeChartSchema`, the result keeps the series and loses only the
category binding — **a drawn chart with a broken category axis**, not an empty one. Class 1
(`data` malformed) is the one that leaves nothing to plot.

## Also changed on the published surface: combinators

Both consts now carry a check (`ChartSchema` the `xAxis` fold, `ChartDataSeriesSchema` the
at-least-one-binding refinement), and on zod 4.4.3 that makes three combinators **throw**
where they previously returned a schema:

```
ChartSchema.pick(…) / .omit(…) / .partial() -> throws "cannot be used on object
ChartDataSeriesSchema.pick(…) / .omit(…) / … schemas containing refinements"
```

`.extend()` with a NEW key still works and preserves the fold and the refinement;
`.optional()`, `z.discriminatedUnion`, `z.toJSONSchema` and `safeValidateSchema` are all
unaffected. Nothing in this repository calls the throwing combinators on either const, and
the published surface already ships refined mirrors (`objectql.zod.ts`, `complex.zod.ts`,
`form.zod.ts`, `app.zod.ts`), so the class is not new — but it is a real behaviour change on
a published export and it belongs in the release note rather than in a reviewer's file.

## What now validates (the widening)

`series: [{ dataKey: 'revenue' }]`. `normalizeSeries` reads
`str(raw.dataKey) ?? str(raw.name)`, so `dataKey` alone has always been a complete binding
— but the mirror REQUIRED `name` and refused it. That is why both catalog chart fixtures
(`advanced-line-chart.json`, `area-chart.json`) failed validation: they are the `chart: 2`
entry in `objectui check`'s 28-file census. `name` is now optional, `dataKey` is declared,
and a series binding to NEITHER is refused by name at `series.N.name` — the same path the
required flag used to report, so the diagnostic did not move.

## `xAxis` folds; it does not become a second name

`xAxis: 'month'` is accepted at input and is ABSENT from the output, having landed on
`xAxisKey`. When both are written the canonical key is kept and the alias dropped — not a
precedence rule minted here, but the one already running at `normalizeChartSchema.ts:292`,
where `xAxisKey` is the first limb of `str(schema.xAxisKey) ?? xAxisSpec?.field ??
str(xAxisRaw)`. No chart that renders today changes what it renders.

⚠️ The `xAxis` **config object** (`{ field, format, title, showGridLines }`) is NOT folded.
Only the bare string is a sibling spelling of `xAxisKey`; the object's presentation keys
survive separately into `out.xAxis` (`normalizeChartSchema.ts:289-291`), and folding it
would discard them.

## Not done, deliberately

objectui#6939's `chart` row also says "`series[].data` stops being required". On this base
it already is not: objectui#6896 replaced it with `retirementTombstone(...)` —
`z.never({ error }).optional()` — which is optional AND refuses any authored value by name.
Implementing the clause literally would re-widen a retired key and reverse a landed ruling,
so it is not done.

## FROM → TO

```ts
// ChartDataSeries
- name: string;
+ name?: string;
+ dataKey?: string;

// ChartSchema
+ data?: Array<Record<string, any>>;
+ xAxisKey?: string;
```
2 changes: 1 addition & 1 deletion packages/plugin-charts/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "eslint ."
},
"dependencies": {
Expand Down
175 changes: 175 additions & 0 deletions packages/plugin-charts/src/ChartRenderer.catalogRender-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
/**
* 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#6939, the `chart` group — the RENDER half. The validator-side
* contract is pinned in
* `packages/types/src/__tests__/chart-data-model-7113.test.ts`.
*
* Ruling 5510084784 (maintainer 2026-09-02, verbatim 「同意」) sets the bar per
* group, from objectui#6318's triage: "the catalog entry validates, **and its
* render is byte-identical in element count and text before and after**". The
* validator half alone cannot make the claim — a "repair" that also changes
* what is drawn has not proved the SCHEMA was wrong, it has changed the
* product. Both landed sibling groups carry this half
* (`plugin-map/src/ObjectMap.catalogRecordSource-6939.test.tsx`,
* `examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx`), and
* this file is the chart group's.
*
* ## The asymmetry this group has and the siblings do not
*
* At BASE both fixtures **FAIL** validation (`series.N.name`: they are authored
* in the `dataKey` dialect, which the mirror required `name` instead of) while
* **drawing correctly**. So the before/after identity cannot be measured through
* a parse-then-render path — there is no parse at base. It is measured through
* the renderer directly, which is also honest about how charts actually reach
* the screen: `ChartRenderer` consumes the AUTHORED schema and calls
* `normalizeChartSchema` on it; it never sees the mirror's parse output.
*
* That also bounds what this file can regress on: the objectui#7113 diff touches
* no file under `packages/plugin-charts`, so the renderer is byte-identical
* across the change. The pin's job is to keep it that way as the mirror moves.
*
* ## PRE_REPAIR — measured, not transcribed
*
* Captured on `origin/main` @ `98d4108a2` (the merge-base), both faces
* untouched, through THIS file's `measure()` in a worktree at that commit.
*
* ⚠️ Element counts are HARNESS-BOUND and must never be carried over from
* another run. The contract review of PR #7545 measured this same property on
* its own harness and read `advanced-line-chart` 136 / `area-chart` 132 with 11
* x-axis ticks; this harness reads 136 and **127** with **6** ticks. Both are
* correct about their own harness — `ResponsiveContainer` is mocked to a fixed
* 480x320 here, and tick density is a function of that width
* (`AdvancedChartImpl`'s categorical axis thins labels by available space). The
* claim that discriminates is IDENTITY WITHIN ONE HARNESS, which is why the
* numbers below were re-derived here rather than copied.
*
* Three readings per fixture, because a count alone cannot tell a swapped
* element from an equal one: element count, a tag census, and a SHA-256 of the
* text.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0x0
// under the headless DOM, so nothing paints. Fix its size — the same shim the
// other render tests in this package use.
vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import { ChartRenderer } from './ChartRenderer';
import { safeValidateSchema } from '@object-ui/types/zod';

afterEach(cleanup);

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const NAMES = ['advanced-line-chart', 'area-chart'] as const;

function catalogEntry(name: (typeof NAMES)[number]): Record<string, unknown> {
const file = path.join(REPO_ROOT, 'examples/schema-catalog/src/schemas/plugin-charts', `${name}.json`);
return JSON.parse(fs.readFileSync(file, 'utf8'));
}

interface Reading {
elements: number;
tags: Record<string, number>;
lines: number;
areas: number;
xTicks: number;
sha256: string;
}

/** Measured at `98d4108a2` through `measure()` below. See the header. */
const PRE_REPAIR: Record<(typeof NAMES)[number], Reading> = {
'advanced-line-chart': {
elements: 136,
tags: { DIV: 9, STYLE: 1, svg: 1, title: 1, desc: 1, g: 48, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 14, stop: 28, path: 2, text: 11, tspan: 11 },
lines: 2,
areas: 0,
xTicks: 6,
sha256: 'dd56a5f8c25242bb737db18308f2952c3f5dabbe1dcb6eb9e0b71cdb3a3bbccd',
},
'area-chart': {
elements: 127,
tags: { DIV: 7, STYLE: 1, svg: 1, title: 1, desc: 1, g: 47, line: 5, defs: 2, clipPath: 1, rect: 1, linearGradient: 12, stop: 24, path: 2, text: 11, tspan: 11 },
lines: 0,
areas: 1,
xTicks: 6,
sha256: 'c1270f6053d2dd82c9da87b57607ae04896bf3fc317c9c15357632b48fa05386',
},
};

async function measure(schema: unknown): Promise<Reading> {
const { container } = render(
<ChartRenderer schema={{ ...(schema as any), isAnimationActive: false }} />,
);
// `AdvancedChartImpl` is lazy — wait for the real plot, not the skeleton. A
// fixture that stopped drawing fails HERE, loudly, rather than reporting a
// tidy zero further down.
await waitFor(() => {
if (!container.querySelector('.recharts-surface')) throw new Error('nothing drew');
});
const nodes = Array.from(container.querySelectorAll('*'));
// React's `useId` lands in the injected <style> block (`chart-_r_0_`), so it
// varies with render ORDER inside the file. Normalise it, or the hash pins
// the test order rather than the drawing.
const text = (container.textContent ?? '').replace(/chart-_r_[0-9a-z]+_/g, 'chart-ID');
return {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((h, el) => ((h[el.tagName] = (h[el.tagName] ?? 0) + 1), h), {}),
lines: container.querySelectorAll('.recharts-line').length,
areas: container.querySelectorAll('.recharts-area').length,
xTicks: container.querySelectorAll('.recharts-xAxis .recharts-cartesian-axis-tick').length,
sha256: createHash('sha256').update(text).digest('hex'),
};
}

describe('objectui#6939 `chart` — the catalog fixtures draw exactly what they drew before', () => {
it.each(NAMES)('%s renders identically to BASE', async (name) => {
expect(await measure(catalogEntry(name))).toEqual(PRE_REPAIR[name]);
});

/*
* The verdict half — the thing that DID change. At `98d4108a2` both of these
* reported `series.N.name: Invalid input: expected string, received undefined`
* from `safeValidateSchema` while drawing the readings pinned above. Together
* with the identity above, that is objectui#6318's bar: the validator's
* verdict moves, the drawing does not.
*/
it.each(NAMES)('%s now VALIDATES, which is the half that changed', (name) => {
const r = safeValidateSchema(catalogEntry(name));
expect(r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`)).toEqual([]);
});

/*
* LIT CONTROL for the identity assertions. Without it, `toEqual(PRE_REPAIR)`
* passing proves only that two things matched — it cannot show the instrument
* would have NOTICED a difference. Perturb the authored rows and the same
* measurement must move.
*/
it('CONTROL — the measurement detects a changed drawing', async () => {
const doc = catalogEntry('area-chart') as { data: Record<string, unknown>[] };
const perturbed = { ...doc, data: [...doc.data, { month: 'Jul', users: 2600 }] };
const reading = await measure(perturbed);
expect(reading.sha256).not.toBe(PRE_REPAIR['area-chart'].sha256);
expect(reading.elements).not.toBe(PRE_REPAIR['area-chart'].elements);
});
});
19 changes: 18 additions & 1 deletion packages/plugin-charts/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,5 +13,22 @@
"composite": true,
"skipLibCheck": true
},
"include": ["src"]
"include": ["src"],
// Tooling is excluded by DIRECTORY, not just by file NAME — the arrangement
// 34 of this repo's 38 test-bearing packages already use (see
// `packages/plugin-map/tsconfig.json`, whose comment carries the history).
// Until objectui#7113 this package had no exclusion at all, so its 45 test
// files were inputs to THIS program — which emits (`declaration`, `composite`,
// `outDir: dist`). That is the defect objectui#4006 / #4836 / #6943 hit three
// times and `pnpm check:published-tsconfig-exclude` exists to stop. The tests
// are still type-checked, by `tsconfig.test.json`, which `type-check` chains.
"exclude": [
"node_modules",
"dist",
"**/__tests__/**",
"**/__mocks__/**",
"**/__benchmarks__/**",
"**/*.test.ts",
"**/*.test.tsx"
]
}
31 changes: 31 additions & 0 deletions packages/plugin-charts/tsconfig.test.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
{
// Type-checks this package's TESTS, which `tsconfig.json` excludes.
// Modelled on `packages/plugin-map/tsconfig.test.json` — the sibling that
// already reads schema-catalog fixtures off disk from a `*-6939` render pin.
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
// The package build emits `dist`; this project emits nothing, so it must
// not inherit `composite` / `declaration` from the build config.
"composite": false,
// Naming `types` at all switches off automatic `@types/*` inclusion, so
// every type package these tests need is named here.
// - `@testing-library/jest-dom` is a global augmentation, not an import,
// and does not live under `@types/`, so it is never picked up
// automatically (two test files here use its matchers).
// - `node` is what `ChartRenderer.catalogRender-6939.test.tsx` needs: it
// reads the schema-catalog chart fixtures off disk with `node:fs` /
// `node:path` / `node:url` and hashes their render with `node:crypto`.
// The pin has to cross a package boundary, and it cannot move to
// `examples/schema-catalog/test/` — `vi.mock('recharts')` does not
// intercept plugin-charts' import from there, because pnpm's strict
// layout gives the two packages different resolved paths for recharts,
// so the real `ResponsiveContainer` renders 0x0 and nothing paints.
"types": ["@testing-library/jest-dom", "node"],
// Drop the root tsconfig's source-tree `paths` so `@object-ui/*` resolves
// through the workspace dependency's built `.d.ts` instead of pulling
// sibling sources in as program inputs (TS6059).
"paths": {}
},
"include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.d.ts"]
}
Loading
Loading