@malloydata/render 0.0.423 → 0.0.425

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ # Testing the renderer
2
+
3
+ The renderer's behavior splits into layers with very different testing costs.
4
+ Test at the cheapest layer that can observe the behavior; escalate only when a
5
+ lower layer can't see it.
6
+
7
+ | Layer | What it answers | Harness | Needs |
8
+ |---|---|---|---|
9
+ | Dispatch & tag config | "Does this markup select the right renderer, with the right resolved settings?" | `src/render-field-metadata.spec.ts` (compile-only, see below) | schema only — no query run, no data, no DOM |
10
+ | Chart spec generation | "Does the generated Vega spec encode the data and tags correctly?" | `src/plugins/spec-test-support/harness.ts` (`runChartQuery`) | runs a real DuckDB query, no DOM |
11
+ | Tag validation contract | "Do bad tags produce the right diagnostics?" | `test/src/render/render-validator.spec.ts` | the **built UMD bundle** — run a full `npm run build` first, `npm run dev` is not enough |
12
+ | Pixels | "Does it look right?" | Storybook (`npm run storybook`), stories in `src/stories/*.stories.malloy` | a browser and your eyes |
13
+
14
+ ## The compile-only harness (dispatch & tag config)
15
+
16
+ Everything the renderer decides at setup time — which renderer a field gets
17
+ (`renderAs()`), what the tag resolvers in `tag-configs.ts` produce, what chart
18
+ settings resolve to — is a function of the result **schema**, not the data.
19
+ That means these tests never need to execute a query: compile the model with
20
+ `getPreparedResult()` and construct `RenderFieldMetadata` directly on the
21
+ stable result. DuckDB is used only to describe an inline `duckdb.sql(...)`
22
+ table at compile time.
23
+
24
+ ```typescript
25
+ async function metadataFor(malloySource: string): Promise<RenderFieldMetadata> {
26
+ const pr = await runtime
27
+ .loadModel(malloySource)
28
+ .loadQueryByName('q')
29
+ .getPreparedResult();
30
+ return new RenderFieldMetadata(pr.toStableResult());
31
+ }
32
+ ```
33
+
34
+ From the metadata you can assert on `field.renderAs()` (dispatch),
35
+ `field.getTagConfig<T>()` (setup-time resolvers), and per-plugin settings
36
+ functions like `getBarChartSettings(field)`. These tests run in plain Node in
37
+ under a second — no Solid, no Vega runtime, no DOM stubs.
38
+
39
+ Prefer this harness for any new tag, any change to dispatch precedence, and
40
+ any resolver in `tag-configs.ts`. If a behavior can be pinned here, a
41
+ Storybook story for it is documentation, not the test.
42
+
43
+ ## The chart-query harness (spec generation)
44
+
45
+ When the behavior lives in a Vega spec generator (axis titles, legend scales,
46
+ tooltip shapes), use `runChartQuery` from `src/plugins/spec-test-support/`.
47
+ It runs a real query, builds the full render metadata, and hands back what the
48
+ spec generators consume. Costs a query execution per test but still no DOM.
49
+
50
+ ## Pinning known bugs
51
+
52
+ Some tests deliberately pin behavior that is known to be wrong (they are
53
+ labeled `KNOWN BUG` / `PINNED` in the test name, with a comment explaining
54
+ the actual behavior). Their job is to make a behavior change *loud*: if a
55
+ change flips one, update it knowingly — don't "fix" the test to keep it
56
+ green without understanding what changed.
57
+
58
+ ## Running
59
+
60
+ Renderer unit tests live in the `malloy-render` jest project:
61
+
62
+ ```
63
+ npx jest --selectProjects malloy-render
64
+ ```
65
+
66
+ Do not run the repo's full test suite unrestricted; use targeted projects or
67
+ paths.
@@ -0,0 +1,139 @@
1
+ # Renderer Validation
2
+
3
+ ## What validation is
4
+
5
+ Renderer tags (`# bar_chart`, `# viz.x = ...`, `# currency=usd`, etc.) are a DSL embedded in Malloy annotations. The renderer interprets that DSL to decide what to draw and how. Validation is the layer that catches DSL mistakes — wrong tag on wrong field type, invalid enum values, bad field references, structural conflicts — and surfaces them as errors with source locations the user can act on.
6
+
7
+ Two things consume validation:
8
+
9
+ - **The VS Code Problems panel.** Validation runs during `setResult()` so logs are available synchronously after the result lands.
10
+ - **`@malloydata/render-validator`.** A headless wrapper that runs the validator in Node.js (no DOM, no Solid.js) so MCP pipelines and CI can validate a query's renderer tags without a browser.
11
+
12
+ Validation only needs the result **schema** (field names, types, annotations). It does not need data rows. Anything that requires inspecting rows (e.g., "transpose column limit exceeded") cannot be validated this way and must remain a render-time check.
13
+
14
+ ## The design principle
15
+
16
+ > Push tag interpretation as far toward setup-time as possible. Anything resolved at setup-time can be validated at setup-time.
17
+
18
+ Concretely, this means:
19
+
20
+ - **Do tag reads in `create()` or in a `tag-configs.ts` resolver**, not in `renderComponent()`, `beforeRender()`, or any shared code they call into. Setup-time reads are automatically marked-as-read by the tag API, and their results are available for validation.
21
+ - **Store resolved values on the plugin instance or via `setTagConfig` / `setColumnConfig`**, and have render-time code consume the resolved values. Render-time code should never call `field.tag.*` directly.
22
+ - **When a render-time read is unavoidable** (e.g., a context-sensitive per-child tag like dashboard's `break`, where setup-time resolution would be materially worse than ownership), declare the path in `getValidationSpec().ownedPaths` or `childOwnedPaths` so unread-tag detection doesn't warn on valid uses.
23
+
24
+ If you internalize one thing from this document: **setup-time is the default; render-time reads are exceptions that must be declared**.
25
+
26
+ ## How it works
27
+
28
+ ### Pipeline
29
+
30
+ ```
31
+ setResult(result)
32
+ → new RenderFieldMetadata(result, plugins)
33
+ → registerFields(rootField) // recursive, depth-first
34
+ for each field:
35
+ instantiatePluginsForField(field) // factory.matches + factory.create — may throw
36
+ resolveBuiltInTags(field) // typed config resolvers (image, link, ...)
37
+ validateFieldTags(field) // semantic checks → logCollector
38
+ markOwnedTagPaths(field, ...) // mark renderer-owned tag paths as read
39
+ getLogs()
40
+ → semantic logs (from validateFieldTags + resolvers + plugin-error wrappers)
41
+ → unread-tag warnings (collected once, idempotent)
42
+ ```
43
+
44
+ ### The four producers of log entries
45
+
46
+ 1. **Semantic validation in `validateFieldTags()`** (`src/render-field-metadata.ts`) — The central place for "this tag is wrong" checks. Calls `log.error(msg, tagRef)` / `log.warn(msg, tagRef)`. The `tagRef` carries a source location so VS Code can underline the offending tag.
47
+
48
+ 2. **Built-in tag config resolvers in `tag-configs.ts`** — Read tags at setup time, return typed configs, optionally log issues with the same `tagRef` pattern. Components consume the typed config rather than re-reading the tag.
49
+
50
+ 3. **Plugin throws from `matches()` / `create()`** — Caught in `instantiatePluginsForField` and routed to `ErrorPlugin`, which renders a red error tile in place of the visualization with the thrown message. This is the correct UX when the user asked for a specific renderer (e.g., `# bar_chart`) and the renderer literally cannot produce output (wrong field shape, missing required structure). The thrown message gets wrapped as `"Plugin <name> failed for field '<key>': <msg>"` so the source location of the offending tag is **not** preserved — see "Throw vs log" below for when this trade-off is right.
51
+
52
+ 4. **Unread-tag detection** — After validation, any tag property the user wrote that no resolver, validator, or ownership spec touched becomes a warning. This is the safety net for misspellings and unknown tags.
53
+
54
+ **Render-time surfacing.** A producer-#1 error on a field that then ends up with no matching renderer (it would otherwise stringify to `[object Object]` in the result panel) is also shown inline at render time, using the same `ErrorMessage` UI as producer #3's red tile (`apply-renderer`'s `default` branch reads `field.getValidationErrors()`). This is the decline-side counterpart to a plugin throw: when a renderer *declines* a field a validator has flagged, the user sees the error in the render, not only in the Problems panel. Fields that render fine (scalars, the `none` chart-child case, built-in renderers) are untouched.
55
+
56
+ ### Tag ownership
57
+
58
+ A renderer "owns" a tag when removing the renderer would make the tag meaningless (e.g., `viz.x` only matters when a chart is active). Ownership is declared on the factory:
59
+
60
+ ```typescript
61
+ getValidationSpec: (): RendererValidationSpec => ({
62
+ renderer: 'bar',
63
+ ownedPaths: [['viz', 'x'], ['viz', 'y'], ['bar_chart'], ...],
64
+ childOwnedPaths: [['tooltip']], // claimed on direct children
65
+ }),
66
+ ```
67
+
68
+ `markOwnedTagPaths` walks these declarations and calls `tag.find(path)` on each one, marking the path as read so unread-tag detection won't false-warn. Built-in renderers use the same mechanism via `renderer-validation-specs.ts`.
69
+
70
+ Ownership is **bookkeeping**, not validation. It tells the framework "these tag paths are legitimate when this renderer is active." It does not check whether the values are sane — that is `validateFieldTags`'s job.
71
+
72
+ Ownership is a manual list with no compile-time check. Adding a new render-time tag read without updating `getValidationSpec()` produces false unread-tag warnings on valid input. Keeping the list in sync with actual render-time reads is the author's responsibility.
73
+
74
+ ### Why a tag path needs ownership
75
+
76
+ A tag path needs to be in an ownership spec **only if it is read after `setResult()` returns** (i.e., at render time or interaction time) and is not consumed by a setup-time resolver. Setup-time reads already mark the tag as read; ownership specs cover the gap for tags that are still render-time-only.
77
+
78
+ When a tag migrates from render-time to a setup-time typed config, its entry in `ownedPaths` becomes redundant and should be removed.
79
+
80
+ ## How to validate well
81
+
82
+ ### Throw vs log — when you actually want each
83
+
84
+ This is the rule that matters most and is easiest to get wrong.
85
+
86
+ **Throw** from `matches()` / `create()` **when the user asked for a specific renderer and that renderer cannot produce output for this field.** The thrown message replaces the visualization with a red error tile (via `ErrorPlugin`). **That red tile is the feature, not a workaround** — it tells the author, visibly and unmissably: "you asked for X, here's why X can't happen." Examples:
87
+
88
+ - `# bar_chart` on a scalar field → throw. Bar chart was requested; can't deliver.
89
+ - `# big_value` on a non-record → throw. Big value was requested; can't deliver.
90
+ - `# bar_chart` on a nested query with zero dimensions → throw. Bar chart needs an x axis; can't deliver.
91
+
92
+ **Log** via `validateFieldTags()` with `log.error(msg, tagRef)` **when a renderer is still going to render, but one tag or setting is wrong.** The render proceeds with defaults; the log tells the author their setting was ignored. Examples:
93
+
94
+ - `# currency=yen` on a number → log. Table still renders, currency tag ignored.
95
+ - `# big_value { comparison_format=yuh }` → log. Big value still renders, bad format defaults to `pct`.
96
+ - `# viz.x = nonexistent_field` on a bar chart → log. Chart still renders, chooses x differently.
97
+ - `# y` on a non-numeric dimension → log. Chart still renders, chooses y from measures instead.
98
+
99
+ **The hinge:** *is the plugin unable to produce output at all, or just unable to apply this one setting?*
100
+
101
+ - Unable at all → red box. Throw.
102
+ - One setting wrong, main render still works → log + default.
103
+
104
+ A second way to check: **would the author prefer a red box, or a silently-defaulted render with a line in the Problems panel?** A whole failed viz deserves a red box. One ignored setting deserves a log.
105
+
106
+ ### The source-location caveat
107
+
108
+ Thrown errors do not carry their `Tag` reference through `instantiatePluginsForField`, so red-box messages do not get a clickable source location. The red-box UX is still the right choice when the plugin can't produce output — do not route a whole-renderer failure through `validateFieldTags()` just to get a source location, since that produces a warning in the Problems panel while rendering the broken chart, which is worse UX than the red box.
109
+
110
+ ### The ownership test
111
+
112
+ Separately from throw-vs-log: **if a tag gets read after `setResult()` returns**, its path must be in `getValidationSpec().ownedPaths` (or `childOwnedPaths` for direct children). Otherwise the unread-tag detector will warn about it on every valid use. Better still: move the read into a setup-time resolver (see `tag-configs.ts`) and drop the ownership entry.
113
+
114
+ ### What the validator can't catch
115
+
116
+ - Anything that needs actual data rows (transpose limit exceeded, empty result handling).
117
+ - Anything that depends on layout measurements (sizing fallbacks).
118
+ - Logic errors in renderer code that produce a wrong-but-rendered chart.
119
+
120
+ For these, a Storybook case is the regression net. If you are adding a validation rule, also add a story exercising the bad input — the story exercises the validator path, and visual regressions cover what validation can't.
121
+
122
+ ### Pre-PR checklist
123
+
124
+ - [ ] New tag or property a renderer reads → it is consumed by a setup-time resolver in `tag-configs.ts` **or** declared in the relevant plugin's `getValidationSpec().ownedPaths` / `childOwnedPaths`.
125
+ - [ ] New constraint on a tag value (enum members, type compatibility, structural rule) → encoded in `validateFieldTags()` with `log.error(msg, tagRef)` so the user sees a source-located error.
126
+ - [ ] New throw in `matches()` / `create()` → the failure is "this plugin cannot produce output for this field," and a red-box tile is the UX you want. If the main renderer would still work fine and only one tag-setting is wrong, use `validateFieldTags()` with a logged error instead.
127
+ - [ ] New plugin factory → defines `getValidationSpec()` on the factory (not on the instance).
128
+ - [ ] Storybook case added or updated to exercise the new tag/validation path.
129
+
130
+ ## Key files
131
+
132
+ | File | Role |
133
+ |---|---|
134
+ | `src/render-field-metadata.ts` | Validation engine — `validateFieldTags`, `markOwnedTagPaths`, plugin instantiation |
135
+ | `src/component/render-log-collector.ts` | Log collection, unread-tag walking |
136
+ | `src/component/tag-configs.ts` | Setup-time resolvers for built-in renderers (image, link, list, cell format, table nest, dashboard) |
137
+ | `src/component/renderer-validation-specs.ts` | Built-in renderer ownership specs |
138
+ | `src/api/plugin-types.ts` | `RenderPluginFactory`, `RendererValidationSpec` interfaces |
139
+ | `packages/malloy-render-validator/src/index.ts` | Headless wrapper — `validateRenderTags(result)` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@malloydata/render",
3
- "version": "0.0.423",
3
+ "version": "0.0.425",
4
4
  "license": "MIT",
5
5
  "main": "dist/module/index.umd.js",
6
6
  "types": "dist/module/index.d.ts",
@@ -31,12 +31,12 @@
31
31
  "generate-flow": "ts-node ../../scripts/gen-flow.ts"
32
32
  },
33
33
  "dependencies": {
34
- "@malloydata/malloy-interfaces": "0.0.423",
35
- "@malloydata/malloy-tag": "0.0.423",
36
- "@tanstack/solid-virtual": "^3.13.29",
34
+ "@malloydata/malloy-interfaces": "0.0.425",
35
+ "@malloydata/malloy-tag": "0.0.425",
36
+ "@tanstack/solid-virtual": "^3.13.33",
37
37
  "lodash": "^4.18.1",
38
38
  "luxon": "^3.7.2",
39
- "solid-js": "^1.9.13",
39
+ "solid-js": "^1.9.14",
40
40
  "ssf": "^0.11.2",
41
41
  "us-atlas": "^3.0.1",
42
42
  "vega": "^6.2.0",
@@ -44,8 +44,8 @@
44
44
  "vega-lite": "^5.2.0"
45
45
  },
46
46
  "devDependencies": {
47
- "@malloydata/db-duckdb": "0.0.423",
48
- "@malloydata/malloy": "0.0.423",
47
+ "@malloydata/db-duckdb": "0.0.425",
48
+ "@malloydata/malloy": "0.0.425",
49
49
  "@storybook/addon-essentials": "^8.6.18",
50
50
  "@storybook/addon-interactions": "^8.6.18",
51
51
  "@storybook/addon-links": "^8.6.15",
@@ -61,7 +61,7 @@
61
61
  "esbuild": "0.28.1",
62
62
  "storybook": "^8.6.15",
63
63
  "vite": "^6.4.2",
64
- "vite-plugin-dts": "^5.0.2",
64
+ "vite-plugin-dts": "^5.0.3",
65
65
  "vite-plugin-solid": "^2.11.12",
66
66
  "vite-tsconfig-paths": "^5.1.4"
67
67
  }