|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #6761 — a dataset dimension/measure `label` written as an inline locale map |
| 5 | + * must reach the wire as a **resolved string**, not be dropped and not be |
| 6 | + * replaced by the machine name. |
| 7 | + * |
| 8 | + * `I18nLabelSchema` has authorized two forms of a display label since #5728: a |
| 9 | + * plain string, and an inline locale map `{ en: 'Owner', 'zh-CN': '负责人' }`. |
| 10 | + * Every producer in this service tested `typeof label === 'string'` and dropped |
| 11 | + * anything else, so a dataset written the way the schema documents shipped: |
| 12 | + * |
| 13 | + * ``` |
| 14 | + * label: 'Owner' → fields[] carries label: 'Owner' ✅ |
| 15 | + * label: { en: 'Owner', 'zh-CN': '负责人' } → fields[] carries NO label ❌ |
| 16 | + * (no label declared) → fields[] carries NO label ✅ (unchanged) |
| 17 | + * ``` |
| 18 | + * |
| 19 | + * measured identically on both strategies at `origin/main`. One layer up, |
| 20 | + * `dataset-compiler` substituted the machine NAME for the same map, so |
| 21 | + * `/analytics/meta` additionally published `title: 'owner'` as a display title — |
| 22 | + * a face that lies rather than one that is merely bare. |
| 23 | + * |
| 24 | + * ## What resolves it, and why it is imported rather than written here |
| 25 | + * |
| 26 | + * `resolveI18nLabel` (`@objectstack/spec/ui`, #6765) — the one shared |
| 27 | + * `I18nLabel → string` resolver, pinned in its own package to rule parity with |
| 28 | + * objectui's `pickLocalized`. Maintainer ruling B (#6761, 2026-08-08) chose a |
| 29 | + * shared resolver over a private twin inside this service precisely so the two |
| 30 | + * ends cannot answer the same authored map differently. These tests therefore |
| 31 | + * assert *that this service asks the resolver*, and assert the resolver's own |
| 32 | + * documented fallback rule where it applies — never a locally guessed rule. |
| 33 | + * |
| 34 | + * The wire is unchanged: `AnalyticsResult.fields[].label` is `string | undefined` |
| 35 | + * on both ends (`packages/spec/src/contracts/analytics-service.ts`, objectui's |
| 36 | + * `DatasetResultField`), and objectui's `headerLabel` feeds it into |
| 37 | + * `fieldLabel(...)` as a plain string with no `pickLocalized` on that path — a |
| 38 | + * raw map would render `[object Object]`. Widening the wire was option C and was |
| 39 | + * rejected. Every case below asserts `typeof label === 'string'`. |
| 40 | + * |
| 41 | + * ## Which locale each site uses |
| 42 | + * |
| 43 | + * * **`queryDataset`'s two enrichment sites** — `ExecutionContext.locale`, the |
| 44 | + * per-request BCP-47 tag `resolveExecutionContext` derives from the caller's |
| 45 | + * `Accept-Language` (falling back to the workspace `localization` setting). |
| 46 | + * Both sites are inside one method and read one hoisted `requestLocale`, so |
| 47 | + * a single response cannot mix two audiences. |
| 48 | + * * **`dataset-compiler`** — deliberately **no** locale (`REGISTRY_LOCALE`), |
| 49 | + * i.e. the resolver's documented nullish answer `en`. A compiled Cube is a |
| 50 | + * registry artifact shared by every later reader, and `getMeta()` — the |
| 51 | + * `/analytics/meta` face — takes no execution context at all. Baking a |
| 52 | + * request locale there would make `/analytics/meta` answer whoever queried |
| 53 | + * last; the last describe block pins that it does not. |
| 54 | + * |
| 55 | + * ## Reverse verification, direction predicted BEFORE running |
| 56 | + * |
| 57 | + * Unhooking the resolution (restoring `typeof … === 'string'` at both |
| 58 | + * enrichment sites and in the compiler) must turn RED exactly the cases whose |
| 59 | + * label is a MAP, and leave GREEN every plain-string, absent-label and |
| 60 | + * empty-map case — those pin the behaviour this change converges ON rather than |
| 61 | + * changes. Ordinary direction, no inversion and no count movement: the change |
| 62 | + * ADDS resolutions that were absent, narrows no rule and removes no `??` limb, |
| 63 | + * so nothing downstream can gain a finding from it. |
| 64 | + * |
| 65 | + * Per strategy, RED: the `zh-CN`, `en`, base-language, last-resort-limb and |
| 66 | + * no-locale cases (5); GREEN: plain string, no label, empty map (3). Plus, on |
| 67 | + * `/analytics/meta`: RED the resolved-title and no-locale-leak cases (2), GREEN |
| 68 | + * the plain-string/machine-name-fallback case (1). |
| 69 | + * |
| 70 | + * **Predicted 12 red / 7 green. Measured exactly that** — the run is quoted in |
| 71 | + * the PR body. |
| 72 | + */ |
| 73 | + |
| 74 | +import { describe, it, expect } from 'vitest'; |
| 75 | +import { DatasetSchema } from '@objectstack/spec/ui'; |
| 76 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 77 | +import { AnalyticsService } from '../analytics-service.js'; |
| 78 | + |
| 79 | +// ── the fixture ───────────────────────────────────────────────────────────── |
| 80 | + |
| 81 | +/** |
| 82 | + * One dataset carrying every label shape the schema authorizes, so a single |
| 83 | + * selection describes all of them in one response: |
| 84 | + * |
| 85 | + * * `owner` / `opp_count` — inline map with both `en` and `zh-CN`: the shape |
| 86 | + * the defect dropped, and the one the three renderers are waiting for. |
| 87 | + * * `stage` / `total_amount` — plain string: the control that must not move. |
| 88 | + * * `lead_source` / `bare_count` — no label at all: the control that must stay |
| 89 | + * key-less (an invented `label` would be worse than none — see #5537's note |
| 90 | + * on descriptors describing columns rather than minting them). |
| 91 | + * * `region` — a map that names NEITHER the requested locale nor `en`: the |
| 92 | + * resolver's last-resort limb, asserted as the resolver's rule. |
| 93 | + * * `blank` — an empty map: the only in-contract input on which the resolver |
| 94 | + * misses entirely, so it pins "a miss writes nothing". |
| 95 | + * |
| 96 | + * The dataset's own `label` is a map too, which is what `/analytics/meta` |
| 97 | + * published as `title: 'ownership'`. |
| 98 | + */ |
| 99 | +const dataset = DatasetSchema.parse({ |
| 100 | + name: 'ownership', |
| 101 | + label: { en: 'Ownership', 'zh-CN': '归属' }, |
| 102 | + object: 'opportunity', |
| 103 | + include: [], |
| 104 | + dimensions: [ |
| 105 | + { name: 'owner', field: 'owner_id', type: 'string', label: { en: 'Owner', 'zh-CN': '负责人' } }, |
| 106 | + { name: 'stage', field: 'stage', type: 'string', label: 'Stage' }, |
| 107 | + { name: 'lead_source', field: 'lead_source', type: 'string' }, |
| 108 | + { name: 'region', field: 'region', type: 'string', label: { 'ja-JP': '地域' } }, |
| 109 | + { name: 'blank', field: 'blank', type: 'string', label: {} }, |
| 110 | + ], |
| 111 | + measures: [ |
| 112 | + { name: 'opp_count', aggregate: 'count', label: { en: 'Opportunities', 'zh-CN': '商机数' } }, |
| 113 | + { name: 'total_amount', aggregate: 'sum', field: 'amount', label: 'Total Amount' }, |
| 114 | + { name: 'bare_count', aggregate: 'count' }, |
| 115 | + ], |
| 116 | +}); |
| 117 | + |
| 118 | +const ALL_DIMENSIONS = ['owner', 'stage', 'lead_source', 'region', 'blank']; |
| 119 | +const ALL_MEASURES = ['opp_count', 'total_amount', 'bare_count']; |
| 120 | + |
| 121 | +/** Every field descriptor keyed by name, with `label` present only when written. */ |
| 122 | +function labels(fields: { name: string; label?: string }[]): Record<string, unknown> { |
| 123 | + const out: Record<string, unknown> = {}; |
| 124 | + for (const f of fields) { |
| 125 | + // The wire carries a RESOLVED STRING or nothing. A map that reached this |
| 126 | + // point would render `[object Object]` in every consumer. |
| 127 | + if (f.label !== undefined) expect(typeof f.label).toBe('string'); |
| 128 | + out[f.name] = 'label' in f && f.label !== undefined ? f.label : '(no label key)'; |
| 129 | + } |
| 130 | + return out; |
| 131 | +} |
| 132 | + |
| 133 | +// ── the two strategies ────────────────────────────────────────────────────── |
| 134 | + |
| 135 | +/** NativeSQLStrategy — the raw-SQL path. */ |
| 136 | +function sqlService() { |
| 137 | + return new AnalyticsService({ |
| 138 | + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), |
| 139 | + executeRawSql: async () => [ |
| 140 | + { owner: 'usr_1', stage: 'open', lead_source: 'web', region: 'NA', blank: 'b', opp_count: 2, total_amount: 170, bare_count: 2 }, |
| 141 | + ], |
| 142 | + }); |
| 143 | +} |
| 144 | + |
| 145 | +/** ObjectQLStrategy — the aggregate-bridge path. */ |
| 146 | +function aggregateService() { |
| 147 | + return new AnalyticsService({ |
| 148 | + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), |
| 149 | + executeAggregate: async (_object: string, options: Record<string, unknown>) => { |
| 150 | + const groupBy = (options.groupBy ?? []) as Array<string | { field: string }>; |
| 151 | + const aggregations = (options.aggregations ?? []) as Array<{ alias: string }>; |
| 152 | + const row: Record<string, unknown> = {}; |
| 153 | + for (const g of groupBy) row[typeof g === 'string' ? g : g.field] = 'x'; |
| 154 | + for (const a of aggregations) row[a.alias] = 1; |
| 155 | + return [row]; |
| 156 | + }, |
| 157 | + }); |
| 158 | +} |
| 159 | + |
| 160 | +const STRATEGIES: [string, () => AnalyticsService][] = [ |
| 161 | + ['NativeSQLStrategy', sqlService], |
| 162 | + ['ObjectQLStrategy', aggregateService], |
| 163 | +]; |
| 164 | + |
| 165 | +async function describeColumns(svc: AnalyticsService, context?: ExecutionContext) { |
| 166 | + const result = await svc.queryDataset( |
| 167 | + dataset, |
| 168 | + { dimensions: ALL_DIMENSIONS, measures: ALL_MEASURES }, |
| 169 | + context, |
| 170 | + ); |
| 171 | + return labels(result.fields); |
| 172 | +} |
| 173 | + |
| 174 | +// ── the wire ──────────────────────────────────────────────────────────────── |
| 175 | + |
| 176 | +describe.each(STRATEGIES)( |
| 177 | + '#6761 — dataset field labels on the wire (%s)', |
| 178 | + (_name, service) => { |
| 179 | + it('resolves an inline locale map to the REQUESTED locale (zh-CN)', async () => { |
| 180 | + const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext); |
| 181 | + // The defect: both of these were absent entirely before this change. |
| 182 | + expect(cols.owner).toBe('负责人'); |
| 183 | + expect(cols.opp_count).toBe('商机数'); |
| 184 | + }); |
| 185 | + |
| 186 | + it('resolves the same map to `en` for an English request', async () => { |
| 187 | + const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'en-US' } as ExecutionContext); |
| 188 | + // `en-US` misses the exact tag and hits the base-language limb (`en`). |
| 189 | + expect(cols.owner).toBe('Owner'); |
| 190 | + expect(cols.opp_count).toBe('Opportunities'); |
| 191 | + }); |
| 192 | + |
| 193 | + it('resolves a bare base language to its region-qualified sibling (`zh` → `zh-CN`)', async () => { |
| 194 | + const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh' } as ExecutionContext); |
| 195 | + // Neither `zh` (limb 2) nor an exact `zh` key (limb 1) exists; limb 3 |
| 196 | + // takes the first region-qualified sibling sharing the base. |
| 197 | + expect(cols.owner).toBe('负责人'); |
| 198 | + expect(cols.opp_count).toBe('商机数'); |
| 199 | + }); |
| 200 | + |
| 201 | + it('leaves a plain-string label exactly as authored', async () => { |
| 202 | + const zh = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext); |
| 203 | + const en = await describeColumns(service(), { tenantId: 'org_A', locale: 'en' } as ExecutionContext); |
| 204 | + // A string is already the answer — the locale cannot change it. |
| 205 | + expect(zh.stage).toBe('Stage'); |
| 206 | + expect(zh.total_amount).toBe('Total Amount'); |
| 207 | + expect(en.stage).toBe('Stage'); |
| 208 | + expect(en.total_amount).toBe('Total Amount'); |
| 209 | + }); |
| 210 | + |
| 211 | + it('invents no `label` key when the dataset declares none', async () => { |
| 212 | + const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext); |
| 213 | + // Not `null`, not `''`, not the machine name — the key is simply absent, |
| 214 | + // exactly as before this change. |
| 215 | + expect(cols.lead_source).toBe('(no label key)'); |
| 216 | + expect(cols.bare_count).toBe('(no label key)'); |
| 217 | + }); |
| 218 | + |
| 219 | + it('writes nothing when the map itself resolves to nothing (empty map)', async () => { |
| 220 | + const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext); |
| 221 | + // The one in-contract input on which every limb misses. A placeholder |
| 222 | + // here would permanently pre-empt any later label under the downstream |
| 223 | + // `if (f.label == null)` guard (#5199 route A). |
| 224 | + expect(cols.blank).toBe('(no label key)'); |
| 225 | + }); |
| 226 | + |
| 227 | + it('follows the shipped resolver\'s last-resort limb for a map missing the requested locale', async () => { |
| 228 | + const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext); |
| 229 | + // `{ 'ja-JP': '地域' }` under `zh-CN` misses limbs 1–5 (exact, base, |
| 230 | + // regional sibling, `default`, `en`) and lands on limb 6: any string in |
| 231 | + // the map, in key insertion order. This asserts `resolveI18nLabel`'s |
| 232 | + // documented rule ("a label in the wrong language still beats a column |
| 233 | + // with no header"), not a locally invented preference. |
| 234 | + expect(cols.region).toBe('地域'); |
| 235 | + }); |
| 236 | + |
| 237 | + it('falls back to the platform source language when the request states no locale', async () => { |
| 238 | + // Anonymous requests skip localization, so `context.locale` is undefined. |
| 239 | + // The resolver documents nullish as "no locale known" ⇒ `en`; this |
| 240 | + // service passes it through rather than choosing its own default. |
| 241 | + const noLocale = await describeColumns(service(), { tenantId: 'org_A' } as ExecutionContext); |
| 242 | + const noContext = await describeColumns(service()); |
| 243 | + expect(noLocale.owner).toBe('Owner'); |
| 244 | + expect(noLocale.opp_count).toBe('Opportunities'); |
| 245 | + expect(noContext.owner).toBe('Owner'); |
| 246 | + expect(noContext.opp_count).toBe('Opportunities'); |
| 247 | + }); |
| 248 | + }, |
| 249 | +); |
| 250 | + |
| 251 | +// ── the `/analytics/meta` face ────────────────────────────────────────────── |
| 252 | + |
| 253 | +describe('#6761 — /analytics/meta no longer publishes the machine name as a display title', () => { |
| 254 | + /** `getMeta` reduced to `{ name → title }` for the one cube under test. */ |
| 255 | + async function meta(svc: AnalyticsService) { |
| 256 | + const [cube] = await svc.getMeta('ownership'); |
| 257 | + const titles: Record<string, unknown> = { '(cube)': cube.title }; |
| 258 | + for (const m of cube.measures) titles[m.name] = m.title; |
| 259 | + for (const d of cube.dimensions) titles[d.name] = d.title; |
| 260 | + return titles; |
| 261 | + } |
| 262 | + |
| 263 | + it('publishes the resolved label, not the machine name, for a map-labelled cube/dimension/measure', async () => { |
| 264 | + const svc = sqlService(); |
| 265 | + svc.registerDataset(dataset); |
| 266 | + const titles = await meta(svc); |
| 267 | + // Before: 'ownership' / 'owner' / 'opp_count' — the machine names, published |
| 268 | + // as display titles by `typeof … === 'string' ? … : d.name`. |
| 269 | + expect(titles['(cube)']).toBe('Ownership'); |
| 270 | + expect(titles['ownership.owner']).toBe('Owner'); |
| 271 | + expect(titles['ownership.opp_count']).toBe('Opportunities'); |
| 272 | + }); |
| 273 | + |
| 274 | + it('leaves plain-string labels and the no-label machine-name fallback unchanged', async () => { |
| 275 | + const svc = sqlService(); |
| 276 | + svc.registerDataset(dataset); |
| 277 | + const titles = await meta(svc); |
| 278 | + expect(titles['ownership.stage']).toBe('Stage'); |
| 279 | + expect(titles['ownership.total_amount']).toBe('Total Amount'); |
| 280 | + // `Metric.label` / `Dimension.label` are REQUIRED strings in the Cube |
| 281 | + // schema, so an unresolvable label must still produce one. The machine name |
| 282 | + // is what this compiler already wrote, and it stays — the map case is the |
| 283 | + // only one that moves. |
| 284 | + expect(titles['ownership.lead_source']).toBe('lead_source'); |
| 285 | + expect(titles['ownership.bare_count']).toBe('bare_count'); |
| 286 | + expect(titles['ownership.blank']).toBe('blank'); |
| 287 | + }); |
| 288 | + |
| 289 | + it('stays request-independent — a zh-CN query does not leak its locale into the registry', async () => { |
| 290 | + const svc = sqlService(); |
| 291 | + // `queryDataset` re-registers the cube on every call. If the compiler baked |
| 292 | + // the request locale in, this Chinese query would leave a Chinese-labelled |
| 293 | + // cube behind and `/analytics/meta` — which takes no execution context at |
| 294 | + // all — would answer whoever queried last. |
| 295 | + await svc.queryDataset( |
| 296 | + dataset, |
| 297 | + { dimensions: ALL_DIMENSIONS, measures: ALL_MEASURES }, |
| 298 | + { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext, |
| 299 | + ); |
| 300 | + const titles = await meta(svc); |
| 301 | + expect(titles['(cube)']).toBe('Ownership'); |
| 302 | + expect(titles['ownership.owner']).toBe('Owner'); |
| 303 | + expect(titles['ownership.opp_count']).toBe('Opportunities'); |
| 304 | + }); |
| 305 | +}); |
0 commit comments