|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#26167] The summary code's composer, and the read-side overlay, against a |
| 5 | + * REAL locale: `createMemoryI18n` (what the kernel registers as the `i18n` |
| 6 | + * fallback) loaded with this plugin's shipped bundle plus an app's labels, so |
| 7 | + * the key shapes asked are the bundles' own. The real read path is pinned in |
| 8 | + * `activity-summary-reader-locale.integration.test.ts`. |
| 9 | + */ |
| 10 | + |
| 11 | +import { describe, it, expect } from 'vitest'; |
| 12 | +import { createMemoryI18n } from '@objectstack/core'; |
| 13 | + |
| 14 | +import { AuditTranslations } from './translations/index.js'; |
| 15 | +import { |
| 16 | + ACTIVITY_SUMMARY_MESSAGE_KEY, |
| 17 | + composeActivitySummary, |
| 18 | + composeStoredSummary, |
| 19 | + localizeActivityRow, |
| 20 | + readActivitySummaryMessage, |
| 21 | + readerSummaryLocale, |
| 22 | + type ActivitySummaryMessage, |
| 23 | +} from './activity-summary-message.js'; |
| 24 | + |
| 25 | +const DEFS: Record<string, any> = { |
| 26 | + biz_deal: { |
| 27 | + name: 'biz_deal', |
| 28 | + label: 'Deal', |
| 29 | + fields: { |
| 30 | + stage: { label: 'Stage', options: [{ value: 'won', label: 'Won' }] }, |
| 31 | + amount: { label: 'Amount' }, |
| 32 | + owner: { label: 'Owner' }, |
| 33 | + }, |
| 34 | + }, |
| 35 | +}; |
| 36 | +const getObjectDef = (name: string) => DEFS[name] ?? null; |
| 37 | + |
| 38 | +function makeI18n() { |
| 39 | + const i18n = createMemoryI18n(); |
| 40 | + for (const [locale, data] of Object.entries(AuditTranslations)) { |
| 41 | + i18n.loadTranslations(locale, data as Record<string, any>); |
| 42 | + } |
| 43 | + i18n.loadTranslations('zh-CN', { |
| 44 | + objects: { |
| 45 | + biz_deal: { |
| 46 | + label: '商机', |
| 47 | + fields: { stage: { label: '阶段', options: { won: '赢单' } }, amount: { label: '金额' } }, |
| 48 | + }, |
| 49 | + }, |
| 50 | + }); |
| 51 | + return i18n; |
| 52 | +} |
| 53 | + |
| 54 | +const zh = () => readerSummaryLocale(makeI18n(), 'zh-CN', getObjectDef)!; |
| 55 | +const ja = () => readerSummaryLocale(makeI18n(), 'ja-JP', getObjectDef)!; |
| 56 | + |
| 57 | +const CREATED: ActivitySummaryMessage = { key: 'activityCreated', object: 'biz_deal', label: 'Acme' }; |
| 58 | +const NAMED: ActivitySummaryMessage = { |
| 59 | + key: 'activityUpdatedFields', |
| 60 | + object: 'biz_deal', |
| 61 | + label: 'Acme', |
| 62 | + fields: ['amount', 'owner'], |
| 63 | + more: 2, |
| 64 | +}; |
| 65 | +const CHANGES: ActivitySummaryMessage = { |
| 66 | + key: 'fieldChanges', |
| 67 | + object: 'biz_deal', |
| 68 | + changes: [ |
| 69 | + { field: 'stage', from: { text: '∅' }, to: { text: 'Won', option: 'won' } }, |
| 70 | + { field: 'owner', from: { text: '张伟' }, to: { text: 'Ana' } }, |
| 71 | + ], |
| 72 | +}; |
| 73 | + |
| 74 | +describe('composeActivitySummary — one code, each reader\'s language (#26167)', () => { |
| 75 | + it('composes every template in the reader locale, labels resolved per reader', () => { |
| 76 | + expect(composeActivitySummary(CREATED, zh())).toBe('创建了 商机 "Acme"'); |
| 77 | + expect(composeActivitySummary({ ...CREATED, key: 'activityDeleted' }, zh())).toBe('删除了 商机 "Acme"'); |
| 78 | + expect(composeActivitySummary({ ...CREATED, key: 'activityUpdated' }, zh())).toBe('更新了 商机 "Acme"'); |
| 79 | + // `owner` has no zh-CN label: the authored one answers; the list is zh-CN's. |
| 80 | + expect(composeActivitySummary(NAMED, zh())).toBe('更新了 商机 "Acme":金额、Owner和另外 2 个字段'); |
| 81 | + // An option value is the reader's option label; a title is the record's own datum. |
| 82 | + expect(composeActivitySummary(CHANGES, zh())).toBe('阶段: ∅ → 赢单; Owner: 张伟 → Ana'); |
| 83 | + }); |
| 84 | + |
| 85 | + it('falls back to the authored labels where the reader\'s pack has none', () => { |
| 86 | + expect(composeActivitySummary(CREATED, ja())).toBe('Deal「Acme」を作成しました'); |
| 87 | + expect(composeActivitySummary(CHANGES, ja())).toBe('Stage: ∅ → Won; Owner: 张伟 → Ana'); |
| 88 | + }); |
| 89 | + |
| 90 | + it('answers undefined for a locale with no template, and the field-less frame where only that exists', () => { |
| 91 | + const fr = createMemoryI18n(); |
| 92 | + const none = readerSummaryLocale(fr, 'fr-FR', getObjectDef)!; |
| 93 | + expect(composeActivitySummary(CREATED, none)).toBeUndefined(); |
| 94 | + fr.loadTranslations('fr-FR', { messages: { activityUpdated: 'A mis à jour {{object}} « {{label}} »' } }); |
| 95 | + const older = readerSummaryLocale(fr, 'fr-FR', getObjectDef)!; |
| 96 | + expect(composeActivitySummary(NAMED, older)).toBe('A mis à jour Deal « Acme »'); |
| 97 | + }); |
| 98 | + |
| 99 | + it('the stored sentence is the write locale\'s, else the English catalog\'s — never a second spelling', () => { |
| 100 | + expect(composeStoredSummary(NAMED, zh())).toBe('更新了 商机 "Acme":金额、Owner和另外 2 个字段'); |
| 101 | + const none = readerSummaryLocale(createMemoryI18n(), 'fr-FR', getObjectDef)!; |
| 102 | + expect(composeStoredSummary(CREATED, none)).toBe('Created Deal "Acme"'); |
| 103 | + expect(composeStoredSummary(NAMED, none)).toBe('Updated Deal "Acme": Amount, Owner, and 2 more'); |
| 104 | + }); |
| 105 | +}); |
| 106 | + |
| 107 | +describe('readerSummaryLocale — the reader\'s negotiated locale', () => { |
| 108 | + it('negotiates against the held packs: a bare `zh` reads the zh-CN pack', () => { |
| 109 | + expect(composeActivitySummary(CREATED, readerSummaryLocale(makeI18n(), 'zh', getObjectDef)!)).toBe( |
| 110 | + '创建了 商机 "Acme"', |
| 111 | + ); |
| 112 | + }); |
| 113 | + |
| 114 | + it('composes nothing without a locale or a `t()`', () => { |
| 115 | + expect(readerSummaryLocale(makeI18n(), undefined, getObjectDef)).toBeUndefined(); |
| 116 | + expect(readerSummaryLocale(makeI18n(), '', getObjectDef)).toBeUndefined(); |
| 117 | + expect(readerSummaryLocale(undefined, 'zh-CN', getObjectDef)).toBeUndefined(); |
| 118 | + }); |
| 119 | + |
| 120 | + it('a throwing service is a miss, never a failed read', () => { |
| 121 | + const throwing = readerSummaryLocale( |
| 122 | + { |
| 123 | + t: () => { |
| 124 | + throw new Error('boom'); |
| 125 | + }, |
| 126 | + }, |
| 127 | + 'zh-CN', |
| 128 | + getObjectDef, |
| 129 | + )!; |
| 130 | + expect(composeActivitySummary(CREATED, throwing)).toBeUndefined(); |
| 131 | + }); |
| 132 | +}); |
| 133 | + |
| 134 | +describe('readActivitySummaryMessage — only a code this version can compose', () => { |
| 135 | + it('accepts each stored shape and refuses anything else', () => { |
| 136 | + for (const m of [CREATED, NAMED, CHANGES]) expect(readActivitySummaryMessage(m)).toEqual(m); |
| 137 | + expect(readActivitySummaryMessage({ key: 'activityArchived', object: 'biz_deal', label: 'x' })).toBeNull(); |
| 138 | + expect(readActivitySummaryMessage({ key: 'activityCreated', object: 'biz_deal' })).toBeNull(); |
| 139 | + expect(readActivitySummaryMessage({ ...NAMED, fields: [1] })).toBeNull(); |
| 140 | + expect(readActivitySummaryMessage({ key: 'fieldChanges', object: 'biz_deal', changes: [{ field: 'x' }] })).toBeNull(); |
| 141 | + expect(readActivitySummaryMessage('activityCreated')).toBeNull(); |
| 142 | + }); |
| 143 | +}); |
| 144 | + |
| 145 | +describe('localizeActivityRow — the read-side overlay', () => { |
| 146 | + const row = (metadata: unknown, summary = 'Created Deal "Acme"') => ({ summary, metadata }); |
| 147 | + const stored = (message: unknown) => JSON.stringify({ old: null, new: { name: 'Acme' }, [ACTIVITY_SUMMARY_MESSAGE_KEY]: message }); |
| 148 | + |
| 149 | + it('overlays the summary and drops the code from what a caller is served', () => { |
| 150 | + const r: Record<string, unknown> = row(stored(CREATED)); |
| 151 | + localizeActivityRow(r, zh(), true); |
| 152 | + expect(r.summary).toBe('创建了 商机 "Acme"'); |
| 153 | + expect(JSON.parse(r.metadata as string)).toEqual({ old: null, new: { name: 'Acme' } }); |
| 154 | + }); |
| 155 | + |
| 156 | + it('keeps the code for the system, and works on a parsed `metadata` object too', () => { |
| 157 | + const r: Record<string, unknown> = row({ old: null, new: {}, [ACTIVITY_SUMMARY_MESSAGE_KEY]: CREATED }); |
| 158 | + localizeActivityRow(r, zh(), false); |
| 159 | + expect(r.summary).toBe('创建了 商机 "Acme"'); |
| 160 | + expect((r.metadata as Record<string, unknown>)[ACTIVITY_SUMMARY_MESSAGE_KEY]).toEqual(CREATED); |
| 161 | + }); |
| 162 | + |
| 163 | + it('keeps the stored sentence with no locale, an unknown code, or a missing template — and still drops the code', () => { |
| 164 | + for (const [message, locale] of [ |
| 165 | + [CREATED, undefined], |
| 166 | + [{ key: 'activityArchived', object: 'biz_deal', label: 'Acme' }, zh()], |
| 167 | + [CREATED, readerSummaryLocale(createMemoryI18n(), 'fr-FR', getObjectDef)], |
| 168 | + ] as const) { |
| 169 | + const r: Record<string, unknown> = row(stored(message)); |
| 170 | + localizeActivityRow(r, locale, true); |
| 171 | + expect(r.summary).toBe('Created Deal "Acme"'); |
| 172 | + expect(JSON.parse(r.metadata as string)).not.toHaveProperty(ACTIVITY_SUMMARY_MESSAGE_KEY); |
| 173 | + } |
| 174 | + }); |
| 175 | + |
| 176 | + it('a row written before the change (no code) and a row whose summary was not projected are left alone', () => { |
| 177 | + const legacy: Record<string, unknown> = row(JSON.stringify({ old: null, new: { name: 'Acme' } })); |
| 178 | + localizeActivityRow(legacy, zh(), true); |
| 179 | + expect(legacy).toEqual(row(JSON.stringify({ old: null, new: { name: 'Acme' } }))); |
| 180 | + |
| 181 | + const unprojected: Record<string, unknown> = { metadata: stored(CREATED) }; |
| 182 | + localizeActivityRow(unprojected, zh(), true); |
| 183 | + expect(unprojected).not.toHaveProperty('summary'); |
| 184 | + }); |
| 185 | +}); |
0 commit comments