|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#22178] A picklist-bound select dimension renders its category labels in |
| 5 | + * the request's locale, through the plugin's own `translateSelectOptions` |
| 6 | + * bridge. |
| 7 | + * |
| 8 | + * ## The shape this closes |
| 9 | + * |
| 10 | + * A select field may author `picklist: '<name>'` in place of inline `options` |
| 11 | + * (`FieldSchema.picklist`). The registry serves it with the list's options |
| 12 | + * resolved onto it AND `picklist` kept (`PicklistServedFieldSchema`), and its |
| 13 | + * option labels translate under `picklists.<name>.options.<value>`, which |
| 14 | + * every referencing field inherits. `translateObject` consults that address |
| 15 | + * only for a field that carries `picklist`; the plugin built its synthetic |
| 16 | + * field from `options` alone, so a picklist-bound dimension rendered its |
| 17 | + * authored (English) labels under every `Accept-Language`, while |
| 18 | + * `GET /meta/object/:name` relabelled the identical field. Measured on |
| 19 | + * `POST /api/v1/analytics/dataset/query` in hotcrm: `zh-CN` and `ja-JP` |
| 20 | + * answered English for the picklist-bound `industry`, and translated labels |
| 21 | + * for the inline-options `type` beside it. |
| 22 | + * |
| 23 | + * ## What these pins hold |
| 24 | + * |
| 25 | + * - **The precondition the fix reads.** The real engine serves the |
| 26 | + * picklist-bound field with `picklist` beside its resolved `options`, and |
| 27 | + * the inline field with no `picklist`. |
| 28 | + * - **The defect.** Under `zh-CN` and `ja-JP` the picklist-bound dimension |
| 29 | + * renders the list's translated labels, on both strategies. A field-level |
| 30 | + * entry for one value is the more specific address and wins over the |
| 31 | + * list's, exactly as on the object-metadata door: every label equals what |
| 32 | + * `translateObject` gives the served object for the same locale. |
| 33 | + * - **The control.** The inline-options dimension renders its field-level |
| 34 | + * translations, exactly as before; with no locale both dimensions render |
| 35 | + * their authored labels. |
| 36 | + * - **The sort-key pass.** Once the picklist-bound dimension renders |
| 37 | + * translated labels, an ascending `order` on it must sort, and a `limit` |
| 38 | + * window it, by those labels rather than the authored ones it sorted by |
| 39 | + * before; the same holds for the inline dimension, whose sort keys were |
| 40 | + * never translated either. With no locale the order is the authored one. |
| 41 | + * |
| 42 | + * `AnalyticsService.queryDataset` is what `POST /api/v1/analytics/dataset/query` |
| 43 | + * calls, with the request's ExecutionContext (its `locale` read from |
| 44 | + * `Accept-Language`). The door lives in `@objectstack/rest`, which this package |
| 45 | + * does not depend on, so the pins stand at the service call it makes, over the |
| 46 | + * plugin's real composition: a real `ObjectQL` engine as `data` and an i18n |
| 47 | + * service as `i18n`. |
| 48 | + */ |
| 49 | + |
| 50 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 51 | +import { ObjectQL } from '@objectstack/objectql'; |
| 52 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 53 | +import { DatasetSchema } from '@objectstack/spec/ui'; |
| 54 | +import { translateObject, type TranslationBundle } from '@objectstack/spec/system'; |
| 55 | +import type { AnalyticsService } from '../analytics-service.js'; |
| 56 | +import { AnalyticsServicePlugin } from '../plugin.js'; |
| 57 | + |
| 58 | +const OBJECT = 'os22178_account'; |
| 59 | +const PICKLIST = 'os22178_industry'; |
| 60 | + |
| 61 | +const INDUSTRY = { |
| 62 | + name: PICKLIST, |
| 63 | + label: 'Industry', |
| 64 | + options: [ |
| 65 | + { label: 'Education', value: 'education' }, |
| 66 | + { label: 'Energy & Utilities', value: 'energy' }, |
| 67 | + { label: 'Logistics', value: 'logistics' }, |
| 68 | + ], |
| 69 | +}; |
| 70 | + |
| 71 | +const ACCOUNT = { |
| 72 | + name: OBJECT, |
| 73 | + label: 'Account', |
| 74 | + fields: { |
| 75 | + name: { name: 'name', label: 'Name', type: 'text' as const }, |
| 76 | + industry: { name: 'industry', label: 'Industry', type: 'select' as const, picklist: PICKLIST }, |
| 77 | + type: { |
| 78 | + name: 'type', |
| 79 | + label: 'Type', |
| 80 | + type: 'select' as const, |
| 81 | + options: [ |
| 82 | + { label: 'Customer', value: 'customer' }, |
| 83 | + { label: 'Partner', value: 'partner' }, |
| 84 | + ], |
| 85 | + }, |
| 86 | + }, |
| 87 | +}; |
| 88 | + |
| 89 | +const ROWS = [ |
| 90 | + { name: 'a1', industry: 'education', type: 'customer' }, |
| 91 | + { name: 'a2', industry: 'education', type: 'partner' }, |
| 92 | + { name: 'a3', industry: 'energy', type: 'customer' }, |
| 93 | + { name: 'a4', industry: 'logistics', type: 'customer' }, |
| 94 | +] as const; |
| 95 | + |
| 96 | +/** |
| 97 | + * The bundle an i18n service holds: the list's labels under `picklists`, the |
| 98 | + * inline field's under `objects`. `zh-CN` also carries a FIELD-level entry for |
| 99 | + * one value of the picklist-bound field — the more specific address. |
| 100 | + */ |
| 101 | +const TRANSLATIONS: Record<string, Record<string, unknown>> = { |
| 102 | + en: {}, |
| 103 | + 'zh-CN': { |
| 104 | + picklists: { [PICKLIST]: { label: '行业', options: { education: '教育', energy: '能源公用事业', logistics: '物流' } } }, |
| 105 | + objects: { |
| 106 | + [OBJECT]: { |
| 107 | + fields: { |
| 108 | + industry: { options: { logistics: '物流运输' } }, |
| 109 | + type: { options: { customer: '正式客户', partner: '合作伙伴' } }, |
| 110 | + }, |
| 111 | + }, |
| 112 | + }, |
| 113 | + }, |
| 114 | + 'ja-JP': { |
| 115 | + picklists: { [PICKLIST]: { label: '業種', options: { education: '教育・学術', energy: 'エネルギー・公益', logistics: 'ロジスティクス' } } }, |
| 116 | + objects: { [OBJECT]: { fields: { type: { options: { customer: '顧客', partner: 'パートナー' } } } } }, |
| 117 | + }, |
| 118 | +}; |
| 119 | + |
| 120 | +const i18n = { |
| 121 | + getLocales: () => Object.keys(TRANSLATIONS), |
| 122 | + getTranslations: (locale: string) => TRANSLATIONS[locale] ?? {}, |
| 123 | + getDefaultLocale: () => 'en', |
| 124 | + getFallbackLocale: () => 'en', |
| 125 | + t: (key: string) => key, |
| 126 | + loadTranslations: () => {}, |
| 127 | +}; |
| 128 | + |
| 129 | +/** Count per rendered label, from the rows the service answered. */ |
| 130 | +const EXPECTED: Record<string, { industry: Record<string, number>; type: Record<string, number> }> = { |
| 131 | + 'zh-CN': { |
| 132 | + industry: { 教育: 2, 能源公用事业: 1, 物流运输: 1 }, |
| 133 | + type: { 正式客户: 3, 合作伙伴: 1 }, |
| 134 | + }, |
| 135 | + 'ja-JP': { |
| 136 | + industry: { '教育・学術': 2, 'エネルギー・公益': 1, ロジスティクス: 1 }, |
| 137 | + type: { 顧客: 3, パートナー: 1 }, |
| 138 | + }, |
| 139 | +}; |
| 140 | + |
| 141 | +const AUTHORED = { |
| 142 | + industry: { Education: 2, 'Energy & Utilities': 1, Logistics: 1 }, |
| 143 | + type: { Customer: 3, Partner: 1 }, |
| 144 | +}; |
| 145 | + |
| 146 | +/** |
| 147 | + * Each dimension's rendered labels in ascending order of the rendered label |
| 148 | + * (the executor compares sort labels with `localeCompare`), and — the vacuity |
| 149 | + * guard — the same labels in ascending order of their AUTHORED labels, which |
| 150 | + * is what an `order` on a select dimension sorted by before. The two differ |
| 151 | + * for every locale and dimension here, so a sort-key pass that still read the |
| 152 | + * authored label cannot satisfy a pin on the first. |
| 153 | + */ |
| 154 | +const ORDERED = { |
| 155 | + 'zh-CN': { |
| 156 | + industry: { byRendered: ['教育', '物流运输', '能源公用事业'], byAuthored: ['教育', '能源公用事业', '物流运输'] }, |
| 157 | + type: { byRendered: ['合作伙伴', '正式客户'], byAuthored: ['正式客户', '合作伙伴'] }, |
| 158 | + }, |
| 159 | + 'ja-JP': { |
| 160 | + industry: { byRendered: ['エネルギー・公益', 'ロジスティクス', '教育・学術'], byAuthored: ['教育・学術', 'エネルギー・公益', 'ロジスティクス'] }, |
| 161 | + type: { byRendered: ['パートナー', '顧客'], byAuthored: ['顧客', 'パートナー'] }, |
| 162 | + }, |
| 163 | +} as const; |
| 164 | + |
| 165 | +const AUTHORED_ORDER = { industry: ['Education', 'Energy & Utilities', 'Logistics'], type: ['Customer', 'Partner'] } as const; |
| 166 | + |
| 167 | +const DATASET = DatasetSchema.parse({ |
| 168 | + name: 'os22178_account_metrics', |
| 169 | + label: 'Account metrics', |
| 170 | + object: OBJECT, |
| 171 | + dimensions: [ |
| 172 | + { name: 'industry', field: 'industry', type: 'string' }, |
| 173 | + { name: 'type', field: 'type', type: 'string' }, |
| 174 | + ], |
| 175 | + measures: [{ name: 'account_count', aggregate: 'count' }], |
| 176 | +}); |
| 177 | + |
| 178 | +const FACES = ['native', 'objectql'] as const; |
| 179 | +type Face = (typeof FACES)[number]; |
| 180 | + |
| 181 | +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; |
| 182 | + |
| 183 | +describe('[#22178] a picklist-bound select dimension renders its labels in the request locale', () => { |
| 184 | + let engine: ObjectQL; |
| 185 | + const services: Partial<Record<Face, AnalyticsService>> = {}; |
| 186 | + |
| 187 | + /** `{ label: count }` for one dimension, as the dataset door would answer it. */ |
| 188 | + const labelsOf = async (face: Face, dimension: 'industry' | 'type', locale?: string) => { |
| 189 | + const res = await services[face]!.queryDataset( |
| 190 | + DATASET, |
| 191 | + { dimensions: [dimension], measures: ['account_count'] }, |
| 192 | + (locale ? { locale } : {}) as never, |
| 193 | + ); |
| 194 | + const out: Record<string, number> = {}; |
| 195 | + for (const row of res.rows) out[String(row[dimension])] = Number(row.account_count); |
| 196 | + return out; |
| 197 | + }; |
| 198 | + |
| 199 | + /** The rendered labels in the order the service answered an ascending `order` on the dimension. */ |
| 200 | + const orderedLabelsOf = async (face: Face, dimension: 'industry' | 'type', locale?: string, limit?: number) => { |
| 201 | + const res = await services[face]!.queryDataset( |
| 202 | + DATASET, |
| 203 | + { dimensions: [dimension], measures: ['account_count'], order: { [dimension]: 'asc' as const }, ...(limit ? { limit } : {}) }, |
| 204 | + (locale ? { locale } : {}) as never, |
| 205 | + ); |
| 206 | + return res.rows.map((row) => String(row[dimension])); |
| 207 | + }; |
| 208 | + |
| 209 | + beforeAll(async () => { |
| 210 | + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never); |
| 211 | + engine = new ObjectQL({ logger: quiet } as never); |
| 212 | + engine.registerDriver(driver, true); |
| 213 | + await engine.init(); |
| 214 | + engine.registerApp({ id: 'com.test.os22178', name: 'os22178', picklists: [INDUSTRY], objects: [ACCOUNT] } as never); |
| 215 | + await engine.syncSchemas(); |
| 216 | + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as never); |
| 217 | + |
| 218 | + for (const [face, caps] of [ |
| 219 | + ['native', undefined], |
| 220 | + ['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })], |
| 221 | + ] as const) { |
| 222 | + const registered: Record<string, unknown> = {}; |
| 223 | + await new AnalyticsServicePlugin({ ...(caps ? { queryCapabilities: caps } : {}) } as never).init({ |
| 224 | + getService: (name: string) => (name === 'data' ? engine : name === 'i18n' ? i18n : registered[name]), |
| 225 | + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, |
| 226 | + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, |
| 227 | + hook: () => {}, |
| 228 | + logger: quiet, |
| 229 | + } as never); |
| 230 | + services[face] = registered.analytics as AnalyticsService; |
| 231 | + } |
| 232 | + }); |
| 233 | + |
| 234 | + afterAll(async () => { |
| 235 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 236 | + }); |
| 237 | + |
| 238 | + it('the engine serves the picklist-bound field with `picklist` beside its resolved options, and the inline field with none', () => { |
| 239 | + const fields = engine.getObject(OBJECT)!.fields as Record<string, { picklist?: unknown; options?: Array<{ value: unknown }> }>; |
| 240 | + expect(fields.industry.picklist).toBe(PICKLIST); |
| 241 | + expect(fields.industry.options!.map((o) => o.value)).toEqual(['education', 'energy', 'logistics']); |
| 242 | + expect(fields.type.picklist).toBeUndefined(); |
| 243 | + expect(fields.type.options!.map((o) => o.value)).toEqual(['customer', 'partner']); |
| 244 | + }); |
| 245 | + |
| 246 | + it('the ordering fixture discriminates: rendered-label order is the comparator\'s order and differs from authored-label order', () => { |
| 247 | + for (const locale of ['zh-CN', 'ja-JP'] as const) { |
| 248 | + for (const dimension of ['industry', 'type'] as const) { |
| 249 | + const { byRendered, byAuthored } = ORDERED[locale][dimension]; |
| 250 | + expect([...byRendered].sort((a, b) => a.localeCompare(b)), `${locale} ${dimension}`).toEqual(byRendered); |
| 251 | + expect(byAuthored, `${locale} ${dimension}`).not.toEqual(byRendered); |
| 252 | + expect([...byAuthored].sort(), `${locale} ${dimension}`).toEqual([...byRendered].sort()); |
| 253 | + } |
| 254 | + } |
| 255 | + }); |
| 256 | + |
| 257 | + for (const face of FACES) { |
| 258 | + describe(`${face} strategy`, () => { |
| 259 | + for (const locale of ['zh-CN', 'ja-JP'] as const) { |
| 260 | + it(`${locale}: the picklist-bound dimension renders the list's translated labels`, async () => { |
| 261 | + expect(await labelsOf(face, 'industry', locale)).toEqual(EXPECTED[locale].industry); |
| 262 | + }); |
| 263 | + |
| 264 | + it(`${locale}: every picklist-bound label equals the object-metadata door's label for the same field`, async () => { |
| 265 | + const bundle = TRANSLATIONS as unknown as TranslationBundle; |
| 266 | + const served = translateObject(engine.getObject(OBJECT)! as never, bundle, { |
| 267 | + locale, |
| 268 | + fallbackChain: ['en'], |
| 269 | + defaultLocale: 'en', |
| 270 | + }) as { fields: Record<string, { options: Array<{ value: string; label: string }> }> }; |
| 271 | + const counts: Record<string, number> = { education: 2, energy: 1, logistics: 1 }; |
| 272 | + const fromMetaDoor: Record<string, number> = {}; |
| 273 | + for (const opt of served.fields.industry.options) fromMetaDoor[opt.label] = counts[opt.value]; |
| 274 | + expect(await labelsOf(face, 'industry', locale)).toEqual(fromMetaDoor); |
| 275 | + }); |
| 276 | + |
| 277 | + it(`${locale}: control — the inline-options dimension renders its field-level translations, as before`, async () => { |
| 278 | + expect(await labelsOf(face, 'type', locale)).toEqual(EXPECTED[locale].type); |
| 279 | + }); |
| 280 | + } |
| 281 | + |
| 282 | + it('control — with no locale, both dimensions render their authored labels', async () => { |
| 283 | + expect(await labelsOf(face, 'industry')).toEqual(AUTHORED.industry); |
| 284 | + expect(await labelsOf(face, 'type')).toEqual(AUTHORED.type); |
| 285 | + }); |
| 286 | + |
| 287 | + // The sort-key pass reads the same translated options as the display |
| 288 | + // pass, so an `order` sorts, and a `limit` windows, by the label the |
| 289 | + // row renders — for the picklist-bound and the inline dimension alike. |
| 290 | + for (const locale of ['zh-CN', 'ja-JP'] as const) { |
| 291 | + for (const dimension of ['industry', 'type'] as const) { |
| 292 | + it(`${locale}: an ascending order on ${dimension} sorts and windows by the rendered label`, async () => { |
| 293 | + const { byRendered } = ORDERED[locale][dimension]; |
| 294 | + expect(await orderedLabelsOf(face, dimension, locale)).toEqual(byRendered); |
| 295 | + expect(await orderedLabelsOf(face, dimension, locale, 1)).toEqual([byRendered[0]]); |
| 296 | + }); |
| 297 | + } |
| 298 | + } |
| 299 | + |
| 300 | + it('control — with no locale, an ascending order sorts by the authored label', async () => { |
| 301 | + expect(await orderedLabelsOf(face, 'industry')).toEqual(AUTHORED_ORDER.industry); |
| 302 | + expect(await orderedLabelsOf(face, 'type')).toEqual(AUTHORED_ORDER.type); |
| 303 | + }); |
| 304 | + }); |
| 305 | + } |
| 306 | +}); |
0 commit comments