|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `DatasetDimension.nullLabel` reaches the renderer on the dimension column's |
| 5 | + * descriptor, as `fields[].nullLabel`. |
| 6 | + * |
| 7 | + * The chart renderer (objectui `DatasetWidget`) reads a dataset answer's |
| 8 | + * `fields[]` for every column's display metadata and never fetches the dataset |
| 9 | + * document, so the null bucket's authored name has to ride that descriptor. It |
| 10 | + * is resolved to a string at the request locale by the same `resolveI18nLabel` |
| 11 | + * the column's `label` uses, so a viewer reads the bucket in their own |
| 12 | + * language and the wire never carries a raw locale map. |
| 13 | + * |
| 14 | + * The other half is the control: a dimension that declares no `nullLabel`, and |
| 15 | + * every measure column, carry no `nullLabel` key at all, so an answer over a |
| 16 | + * dataset that never declares one is byte-identical to before. |
| 17 | + */ |
| 18 | + |
| 19 | +import { describe, it, expect } from 'vitest'; |
| 20 | +import { DatasetSchema } from '@objectstack/spec/ui'; |
| 21 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 22 | +import { AnalyticsService } from '../analytics-service.js'; |
| 23 | + |
| 24 | +const dataset = DatasetSchema.parse({ |
| 25 | + name: 'audit_metrics', |
| 26 | + label: 'Audit Metrics', |
| 27 | + object: 'sys_audit_log', |
| 28 | + include: [], |
| 29 | + dimensions: [ |
| 30 | + { name: 'action', label: 'Action', field: 'action', type: 'string' }, |
| 31 | + { |
| 32 | + name: 'user_id', |
| 33 | + label: 'User', |
| 34 | + field: 'user_id', |
| 35 | + type: 'string', |
| 36 | + nullLabel: { en: 'System', 'zh-CN': '系统' }, |
| 37 | + }, |
| 38 | + { name: 'source', field: 'source', type: 'string', nullLabel: 'Unknown' }, |
| 39 | + ], |
| 40 | + // `user_id` is also a measure's field: the measure column must not inherit |
| 41 | + // the dimension's null label through the descriptor pass's field match. |
| 42 | + measures: [ |
| 43 | + { name: 'event_count', label: 'Events', aggregate: 'count' }, |
| 44 | + { name: 'user_id_count', aggregate: 'count_distinct', field: 'user_id' }, |
| 45 | + ], |
| 46 | +}); |
| 47 | + |
| 48 | +const DIMENSIONS = ['action', 'user_id', 'source']; |
| 49 | +const MEASURES = ['event_count', 'user_id_count']; |
| 50 | + |
| 51 | +function sqlService() { |
| 52 | + return new AnalyticsService({ |
| 53 | + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), |
| 54 | + executeRawSql: async () => [ |
| 55 | + { action: 'create', user_id: null, source: null, event_count: 740, user_id_count: 0 }, |
| 56 | + { action: 'login', user_id: 'usr_1', source: 'web', event_count: 3, user_id_count: 1 }, |
| 57 | + ], |
| 58 | + }); |
| 59 | +} |
| 60 | + |
| 61 | +function aggregateService() { |
| 62 | + return new AnalyticsService({ |
| 63 | + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), |
| 64 | + executeAggregate: async (_object: string, options: Record<string, unknown>) => { |
| 65 | + const groupBy = (options.groupBy ?? []) as Array<string | { field: string }>; |
| 66 | + const aggregations = (options.aggregations ?? []) as Array<{ alias: string }>; |
| 67 | + const row: Record<string, unknown> = {}; |
| 68 | + for (const g of groupBy) row[typeof g === 'string' ? g : g.field] = null; |
| 69 | + for (const a of aggregations) row[a.alias] = 1; |
| 70 | + return [row]; |
| 71 | + }, |
| 72 | + }); |
| 73 | +} |
| 74 | + |
| 75 | +const STRATEGIES: [string, () => AnalyticsService][] = [ |
| 76 | + ['NativeSQLStrategy', sqlService], |
| 77 | + ['ObjectQLStrategy', aggregateService], |
| 78 | +]; |
| 79 | + |
| 80 | +async function nullLabels(svc: AnalyticsService, context?: ExecutionContext) { |
| 81 | + const result = await svc.queryDataset(dataset, { dimensions: DIMENSIONS, measures: MEASURES }, context); |
| 82 | + const out: Record<string, unknown> = {}; |
| 83 | + for (const f of result.fields) { |
| 84 | + if (f.nullLabel !== undefined) expect(typeof f.nullLabel).toBe('string'); |
| 85 | + out[f.name] = 'nullLabel' in f ? f.nullLabel : '(no nullLabel key)'; |
| 86 | + } |
| 87 | + return out; |
| 88 | +} |
| 89 | + |
| 90 | +describe.each(STRATEGIES)('dataset dimension nullLabel on the wire (%s)', (_name, service) => { |
| 91 | + it('carries the declared null label, resolved to the request locale', async () => { |
| 92 | + const zh = await nullLabels(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext); |
| 93 | + const en = await nullLabels(service(), { tenantId: 'org_A', locale: 'en-US' } as ExecutionContext); |
| 94 | + expect(zh.user_id).toBe('系统'); |
| 95 | + expect(en.user_id).toBe('System'); |
| 96 | + }); |
| 97 | + |
| 98 | + it('carries a plain-string null label as authored, whatever the locale', async () => { |
| 99 | + const zh = await nullLabels(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext); |
| 100 | + expect(zh.source).toBe('Unknown'); |
| 101 | + }); |
| 102 | + |
| 103 | + it('falls back to the platform source language when the request states no locale', async () => { |
| 104 | + const noContext = await nullLabels(service()); |
| 105 | + expect(noContext.user_id).toBe('System'); |
| 106 | + }); |
| 107 | + |
| 108 | + it('writes no nullLabel key on a dimension that declares none, nor on any measure column', async () => { |
| 109 | + const cols = await nullLabels(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext); |
| 110 | + expect(cols.action).toBe('(no nullLabel key)'); |
| 111 | + expect(cols.event_count).toBe('(no nullLabel key)'); |
| 112 | + expect(cols.user_id_count).toBe('(no nullLabel key)'); |
| 113 | + }); |
| 114 | +}); |
0 commit comments