Skip to content

Commit dc0a3ca

Browse files
committed
test(plugin-audit): read summaries through the reader overlay; pin the write locale to mention titles
Claude-Session: https://claude.ai/code/session_01UBPXQdapfjkjYdQck9dtTa Co-authored-by: Claude <noreply@anthropic.com>
1 parent f0a3fdb commit dc0a3ca

7 files changed

Lines changed: 372 additions & 124 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#26167] A stored `sys_activity` row's `summary` as a reader in `locale` is
5+
* served it, for suites that write through the audit writer and read the row
6+
* straight from their store (no engine read, so no middleware ran). It applies
7+
* the read side's own overlay (`localizeActivityRow`) to a copy of the row, so
8+
* what it answers is what `installActivitySummaryLocale` serves that reader.
9+
*/
10+
11+
import {
12+
localizeActivityRow,
13+
readerSummaryLocale,
14+
type ActivitySummaryI18n,
15+
} from '../activity-summary-message.js';
16+
17+
/** The engine slice a summary's labels are read from. */
18+
interface DefinitionSource {
19+
getSchema?(objectName: string): unknown;
20+
}
21+
22+
export function summaryAsRead(
23+
row: Record<string, unknown> | undefined,
24+
i18n: unknown,
25+
locale: string | undefined,
26+
engine: DefinitionSource,
27+
): unknown {
28+
if (!row) return undefined;
29+
const copy: Record<string, unknown> = { ...row };
30+
const reader = readerSummaryLocale(i18n as ActivitySummaryI18n | undefined, locale, (objectName) =>
31+
typeof engine.getSchema === 'function' ? engine.getSchema(objectName) : null,
32+
);
33+
localizeActivityRow(copy, reader, false);
34+
return copy.summary;
35+
}

‎packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { ObjectQL } from '@objectstack/objectql';
5252
import { createMemoryI18n } from '@objectstack/core';
5353
import { installAuditWriters } from './audit-writers.js';
5454
import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js';
55+
import { summaryAsRead } from './__tests__/summary-as-read.js';
5556

5657
// ---------------------------------------------------------------------------
5758
// Fixtures
@@ -274,6 +275,20 @@ const lastSummary = (storeFor: (o: string) => Map<string, Record<string, unknown
274275
return all.length > 0 ? all[all.length - 1] : undefined;
275276
};
276277

278+
/**
279+
* [#26167] …and as a reader in `locale` is served it: the summary is stored in
280+
* English, and its field labels are localized per reader.
281+
*/
282+
const lastSummaryAsRead = (
283+
storeFor: (o: string) => Map<string, Record<string, unknown>>,
284+
i18n: unknown,
285+
locale: string,
286+
engine: unknown,
287+
) => {
288+
const rows = Array.from(storeFor('sys_activity').values());
289+
return summaryAsRead(rows[rows.length - 1], i18n, locale, engine as any);
290+
};
291+
277292
/**
278293
* Seed the two accounts, the region, the user, and TWO deals:
279294
*
@@ -372,24 +387,30 @@ describe('[#7230] value half — a tracked reference renders a title, not a raw
372387
// ---------------------------------------------------------------------------
373388

374389
describe('[#7230] label half — the tracked-change branch gets the locale-bound translator', () => {
375-
it('localizes the tracked field label to the workspace locale (zh-CN)', async () => {
376-
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
390+
// [#26167] The label is localized for the READER: the row stores the English
391+
// sentence and the field's key, and a zh-CN reader is served the zh-CN label.
392+
it('localizes the tracked field label to the reader locale (zh-CN)', async () => {
393+
const i18n = makeI18n();
394+
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n });
377395
await seed(engine);
378396

379397
await engine.update('biz_deal', { owner: 'usr_1' }, { where: { id: 'deal_1' } } as any);
380398

381399
// Both halves at once, which is the reported string's actual shape:
382400
// localized label + resolved title, where it read `Owner: ∅ → usr_1`.
383-
expect(lastSummary(storeFor)).toBe('负责人: ∅ → 张伟');
401+
expect(lastSummary(storeFor)).toBe('Owner: ∅ → 张伟');
402+
expect(lastSummaryAsRead(storeFor, i18n, 'zh-CN', engine)).toBe('负责人: ∅ → 张伟');
384403
});
385404

386405
it('localizes a non-reference tracked label too (the label half is not lookup-only)', async () => {
387-
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
406+
const i18n = makeI18n();
407+
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n });
388408
await seed(engine);
389409

390410
await engine.update('biz_deal', { stage: 'won' }, { where: { id: 'deal_1' } } as any);
391411

392-
expect(lastSummary(storeFor)).toBe('阶段: open → won');
412+
expect(lastSummary(storeFor)).toBe('Stage: open → won');
413+
expect(lastSummaryAsRead(storeFor, i18n, 'zh-CN', engine)).toBe('阶段: open → won');
393414
});
394415

395416
it('falls back to the authored field label when the bundle has no entry for it', async () => {
@@ -400,6 +421,7 @@ describe('[#7230] label half — the tracked-change branch gets the locale-bound
400421
await engine.update('biz_deal', { partner_id: 'acc_2' }, { where: { id: 'deal_1' } } as any);
401422

402423
expect(lastSummary(storeFor)).toBe('Partner: ∅ → Globex');
424+
expect(lastSummaryAsRead(storeFor, makeI18n(), 'zh-CN', engine)).toBe('Partner: ∅ → Globex');
403425
});
404426

405427
it('keeps the authored label when no i18n service is resolvable (status quo)', async () => {

‎packages/plugins/plugin-audit/src/audit-milestone-summary.test.ts‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import { ObjectQL } from '@objectstack/objectql';
5757
import { createMemoryI18n } from '@objectstack/core';
5858
import { installAuditWriters } from './audit-writers.js';
5959
import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js';
60+
import { summaryAsRead } from './__tests__/summary-as-read.js';
6061

6162
// ---------------------------------------------------------------------------
6263
// Fixtures
@@ -343,6 +344,17 @@ const lastSummary = (storeFor: (o: string) => Map<string, Record<string, unknown
343344
return all.length > 0 ? String(all[all.length - 1].summary) : undefined;
344345
};
345346

347+
/** [#26167] The last summary as a reader in `locale` is served it (`summary-as-read.ts`). */
348+
const lastSummaryAsRead = (
349+
storeFor: (o: string) => Map<string, Record<string, unknown>>,
350+
i18n: unknown,
351+
locale: string,
352+
engine: unknown,
353+
) => {
354+
const all = Array.from(storeFor('sys_activity').values());
355+
return summaryAsRead(all[all.length - 1], i18n, locale, engine as any);
356+
};
357+
346358
const lastType = (storeFor: (o: string) => Map<string, Record<string, unknown>>) => {
347359
const all = Array.from(storeFor('sys_activity').values());
348360
return all.length > 0 ? String(all[all.length - 1].type) : undefined;
@@ -534,7 +546,8 @@ describe('[#7290] precedence and locale behaviour are unchanged', () => {
534546
});
535547

536548
it('a milestone template is the author’s sentence and is NOT translated', async () => {
537-
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
549+
const i18n = makeI18n();
550+
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n });
538551
await seed(engine);
539552

540553
await moveStage(engine, 'deal_2', 'closed_won');
@@ -543,18 +556,23 @@ describe('[#7290] precedence and locale behaviour are unchanged', () => {
543556
// and it must NOT leak into the milestone sentence: milestone summaries have
544557
// never been localized, and this card does not change what a template says.
545558
expect(lastSummary(storeFor)).toBe('Deal won by 张伟');
559+
// [#26167] Nor for a zh-CN READER: a milestone row stores no summary code.
560+
expect(lastSummaryAsRead(storeFor, i18n, 'zh-CN', engine)).toBe('Deal won by 张伟');
546561
});
547562

548563
it('the generic verb branch still localizes around it (the bundle really is live)', async () => {
549-
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
564+
const i18n = makeI18n();
565+
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n });
550566
await seed(engine);
551567

552568
// A write that fires no milestone and moves no tracked field falls through
553-
// to `messages.activityUpdated`. If this stayed English the bundle above
554-
// would be inert and the previous case would prove nothing.
569+
// to the generic update summary. If a zh-CN reader were served English the
570+
// bundle above would be inert and the previous case would prove nothing.
571+
// [#26167] This bundle has only the field-less `messages.activityUpdated`,
572+
// so the reader is served that frame.
555573
await engine.update('biz_deal', { priority: 'p2' }, { where: { id: 'deal_2' } } as any);
556574

557-
expect(lastSummary(storeFor)).toBe('已更新商机"Already linked"');
575+
expect(lastSummaryAsRead(storeFor, i18n, 'zh-CN', engine)).toBe('已更新商机"Already linked"');
558576
});
559577
});
560578

‎packages/plugins/plugin-audit/src/audit-option-label-summary.test.ts‎

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import { createMemoryI18n } from '@objectstack/core';
103103
import { installAuditWriters } from './audit-writers.js';
104104
import { zhCNObjects } from './translations/zh-CN.objects.generated.js';
105105
import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js';
106+
import { summaryAsRead } from './__tests__/summary-as-read.js';
106107

107108
// ---------------------------------------------------------------------------
108109
// Fixtures
@@ -347,7 +348,16 @@ async function boot(opts: { locale?: string; i18n?: unknown } = {}) {
347348
getI18n: () => opts.i18n as any,
348349
getLocale: async () => opts.locale,
349350
});
350-
return { engine, ...stub };
351+
// [#26167] The summaries as a reader in `opts.locale` is served them: the
352+
// row stores the English sentence and its code, and the field and option
353+
// labels are localized per READER (`summary-as-read.ts`).
354+
const readerSummaries = () =>
355+
Array.from(stub.storeFor('sys_activity').values()).map((r) => summaryAsRead(r, opts.i18n, opts.locale, engine));
356+
const readerSummary = () => {
357+
const all = readerSummaries();
358+
return all[all.length - 1];
359+
};
360+
return { engine, readerSummaries, readerSummary, ...stub };
351361
}
352362

353363
const summariesOf = (storeFor: (o: string) => Map<string, Record<string, unknown>>) =>
@@ -387,40 +397,42 @@ const setStage = (engine: any, id: string, stage: string) =>
387397

388398
describe('[#7289] tracked-change branch — select option labels resolve through the bundle', () => {
389399
it('localizes the option label on BOTH sides of the diff (the reported string)', async () => {
390-
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
400+
const { engine, storeFor, readerSummary } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
391401
await seed(engine);
392402

393403
await setStage(engine, 'deal_1', 'closed_won');
394404

395405
// Was `阶段: Proposal → Closed Won` — localized frame, authored values.
396-
expect(lastSummary(storeFor)).toBe('阶段: 提案中 → 已赢单');
406+
// [#26167] Stored in English with the option VALUES; read in zh-CN.
407+
expect(lastSummary(storeFor)).toBe('Stage: Proposal → Closed Won');
408+
expect(readerSummary()).toBe('阶段: 提案中 → 已赢单');
397409
});
398410

399411
it('falls back to the AUTHORED option label per VALUE, not per field', async () => {
400-
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
412+
const { engine, readerSummary } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
401413
await seed(engine);
402414

403415
// `stage.options.on_hold` is absent from the bundle; `proposal` is present.
404416
// A mixed assertion on purpose: a single-sided one would survive deleting
405417
// the translate limb, which is the trap #7291 measured on its raw-id case.
406418
await setStage(engine, 'deal_1', 'on_hold');
407419

408-
expect(lastSummary(storeFor)).toBe('阶段: 提案中 → On Hold');
420+
expect(readerSummary()).toBe('阶段: 提案中 → On Hold');
409421
});
410422

411423
it('localizes the BARE-STRING option shape, whose authored label is the value', async () => {
412-
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
424+
const { engine, readerSummary } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
413425
await seed(engine);
414426

415427
// `options: ['p1', 'p2']` → the authored label chain has only `ov` in it.
416428
// `p1` is in the bundle, `p2` is not, so both directions are pinned here.
417429
await engine.update('biz_deal', { priority: 'p1' }, { where: { id: 'deal_1' } } as any);
418430

419-
expect(lastSummary(storeFor)).toBe('优先级: p2 → 最高');
431+
expect(readerSummary()).toBe('优先级: p2 → 最高');
420432
});
421433

422434
it('renders String(value) for a value matching NO declared option, bundle key or not', async () => {
423-
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
435+
const { engine, readerSummary } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
424436
await seed(engine);
425437

426438
// The bundle DOES declare `channel.options.phone_call = '电话'`, and
@@ -431,11 +443,11 @@ describe('[#7289] tracked-change branch — select option labels resolve through
431443
// by the translate limb being dead.
432444
await engine.update('biz_deal', { channel: 'phone_call' }, { where: { id: 'deal_1' } } as any);
433445

434-
expect(lastSummary(storeFor)).toBe('渠道: 网页 → phone_call');
446+
expect(readerSummary()).toBe('渠道: 网页 → phone_call');
435447
});
436448

437449
it('leaves the reference branch (#7230 / #7291) rendering titles beside the options', async () => {
438-
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
450+
const { engine, readerSummary } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
439451
await seed(engine);
440452

441453
await engine.update(
@@ -446,7 +458,7 @@ describe('[#7289] tracked-change branch — select option labels resolve through
446458

447459
// Option label from the bundle, record title from the read: the two
448460
// resolutions are independent and both must be present in one summary.
449-
expect(lastSummary(storeFor)).toBe('阶段: 提案中 → 已赢单; 负责人: ∅ → 张伟');
461+
expect(readerSummary()).toBe('阶段: 提案中 → 已赢单; 负责人: ∅ → 张伟');
450462
});
451463

452464
it('keeps the authored option label when no i18n service is resolvable', async () => {
@@ -474,7 +486,7 @@ describe('[#7289] tracked-change branch — select option labels resolve through
474486

475487
describe('[#7289] fired-milestone branch — option labels stay AUTHORED, on purpose', () => {
476488
it('renders the authored option label inside the untranslated author sentence', async () => {
477-
const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
489+
const { engine, storeFor, readerSummaries } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
478490
await seed(engine);
479491

480492
// Two writes, and the first one is the CONTROL: it proves the locale is
@@ -486,13 +498,15 @@ describe('[#7289] fired-milestone branch — option labels stay AUTHORED, on pur
486498
await setStage(engine, 'deal_2', 'archived');
487499

488500
const all = summariesOf(storeFor);
489-
expect(all[all.length - 2]).toBe('阶段: 提案中 → 已赢单');
501+
expect(readerSummaries()[all.length - 2]).toBe('阶段: 提案中 → 已赢单');
490502

491503
// `{stage}` → the AUTHORED `Archived`, although the bundle declares
492504
// `stage.options.archived = '已归档'`. `{owner}` → `张伟`, because #7290's
493505
// title resolution is locale-INDEPENDENT data, not a translation. The
494506
// author's sentence and the author's option label stay in one language.
495507
expect(all[all.length - 1]).toBe('Deal moved to Archived by 张伟');
508+
// [#26167] …and a zh-CN READER is served it as written: no summary code.
509+
expect(readerSummaries()[all.length - 1]).toBe('Deal moved to Archived by 张伟');
496510
});
497511

498512
// ⚠️ Status-quo pin, not coverage of this change: the locale is OFF here, so
@@ -517,7 +531,7 @@ describe('[#7289] fired-milestone branch — option labels stay AUTHORED, on pur
517531

518532
describe('[#7289] localizing an option label is a bundle lookup and adds no reads', () => {
519533
it('a tracked option-only change pays ZERO reads, on a row that HOLDS references', async () => {
520-
const { engine, reads, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
534+
const { engine, reads, readerSummary } = await boot({ locale: 'zh-CN', i18n: makeI18n() });
521535
await seed(engine);
522536
const before = { user: reads.findOn['sys_user'] ?? 0, acct: reads.findOn['crm_account'] ?? 0 };
523537

@@ -530,7 +544,7 @@ describe('[#7289] localizing an option label is a bundle lookup and adds no read
530544

531545
expect((reads.findOn['sys_user'] ?? 0) - before.user).toBe(0);
532546
expect((reads.findOn['crm_account'] ?? 0) - before.acct).toBe(0);
533-
expect(lastSummary(storeFor)).toBe('阶段: 提案中 → 已赢单');
547+
expect(readerSummary()).toBe('阶段: 提案中 → 已赢单');
534548
});
535549

536550
// ⚠️ Retained as a forward guard, not as coverage of this change: a create

0 commit comments

Comments
 (0)