Skip to content

Commit f0a3fdb

Browse files
committed
feat(plugin-audit): store the activity summary as a code and render it in the reader's language (WIP: write-time locale pins to migrate)
Claude-Session: https://claude.ai/code/session_01UBPXQdapfjkjYdQck9dtTa Co-authored-by: Claude <noreply@anthropic.com>
1 parent cb6b6af commit f0a3fdb

4 files changed

Lines changed: 699 additions & 144 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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

Comments
 (0)