|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * A record-level action's success toast names the record it wrote |
| 5 | + * (`ux-conventions.write-feedback` acceptance[1]: a record-level write names |
| 6 | + * the record — its title — or the count). |
| 7 | + * |
| 8 | + * The console's action runner shows an authored `successMessage` as written |
| 9 | + * unless it carries a template hole; then it fills the holes through |
| 10 | + * `@objectstack/formula`'s `templateEngine` over exactly two roots, `result` |
| 11 | + * (the handler's payload) and `record` (the record the dispatch ran against: |
| 12 | + * the clicked row, else the page's record). A hole naming anything the roots |
| 13 | + * do not bind prints EMPTY (objectui `ActionRunner.composeSuccessMessage` → |
| 14 | + * `actionCopyTemplate.fillTemplateHoles`). So a sentence that names the |
| 15 | + * record must say what it prints for a record whose title the dispatch does |
| 16 | + * not carry (a list row whose view does not project the column): each one |
| 17 | + * here falls back, through the `default` formatter, to the object wording the |
| 18 | + * toast used before. |
| 19 | + * |
| 20 | + * `fillAsTheRunnerDoes` restates that one call (same engine, same roots, same |
| 21 | + * "no hole ⇒ as written" rule) so these assertions read the toast the user |
| 22 | + * would see. The engine is aliased to source in `vitest.config.ts`. |
| 23 | + */ |
| 24 | + |
| 25 | +import { describe, expect, it } from 'vitest'; |
| 26 | +import { templateEngine } from '@objectstack/formula'; |
| 27 | +import { |
| 28 | + ArchiveTaskAction, |
| 29 | + MarkDoneAction, |
| 30 | + RecalcEstimateAction, |
| 31 | + RecalcSelectionAction, |
| 32 | + SubmitForSignoffAction, |
| 33 | +} from '../src/ui/actions/index.js'; |
| 34 | +import { ShowcaseTranslationBundle } from '../src/system/translations/index.js'; |
| 35 | + |
| 36 | +/** The copy's text: a bare string, or the `template` envelope's `source`. */ |
| 37 | +function sourceOf(copy: unknown): string { |
| 38 | + if (typeof copy === 'string') return copy; |
| 39 | + const source = (copy as { source?: unknown } | undefined)?.source; |
| 40 | + if (typeof source !== 'string') throw new Error(`successMessage carries no text: ${JSON.stringify(copy)}`); |
| 41 | + return source; |
| 42 | +} |
| 43 | + |
| 44 | +/** The success toast the runner composes for `action` run against `record`. */ |
| 45 | +function fillAsTheRunnerDoes(action: { successMessage?: unknown }, record: Record<string, unknown>): string { |
| 46 | + const source = sourceOf(action.successMessage); |
| 47 | + if (!/\{\{[^}]*\}\}/.test(source)) return source; |
| 48 | + const filled = templateEngine.evaluate<string>( |
| 49 | + { dialect: 'template', source }, |
| 50 | + { extra: { result: { success: true }, record } } as never, |
| 51 | + ); |
| 52 | + if (!filled.ok) throw new Error(`template refused: ${filled.error.message}`); |
| 53 | + return filled.value; |
| 54 | +} |
| 55 | + |
| 56 | +const task = { id: 't1', title: 'PII access review', done: false }; |
| 57 | +/** A list row whose view does not project `title`. */ |
| 58 | +const sparseTask = { id: 't1', done: false }; |
| 59 | +const invoice = { id: 'i1', name: 'INV-1002', status: 'draft' }; |
| 60 | +const sparseInvoice = { id: 'i1', status: 'draft' }; |
| 61 | + |
| 62 | +describe('showcase record actions — the success toast names the record', () => { |
| 63 | + it.each([ |
| 64 | + ['Mark Done', MarkDoneAction, task, 'PII access review marked done.'], |
| 65 | + ['Recalculate Estimate', RecalcEstimateAction, task, 'Estimate recalculated for PII access review.'], |
| 66 | + ['Recalculate Selection (record overflow menu)', RecalcSelectionAction, task, 'Estimates recalculated for PII access review.'], |
| 67 | + ['Archive', ArchiveTaskAction, task, 'PII access review archived (demo — no data changed).'], |
| 68 | + ['Submit for Sign-off', SubmitForSignoffAction, invoice, 'INV-1002 submitted for finance + legal sign-off.'], |
| 69 | + ] as const)('%s names the record by its title', (_label, action, record, expected) => { |
| 70 | + expect(fillAsTheRunnerDoes(action, record)).toBe(expected); |
| 71 | + }); |
| 72 | + |
| 73 | + it.each([ |
| 74 | + ['Mark Done', MarkDoneAction, sparseTask, 'Task marked done.'], |
| 75 | + ['Recalculate Estimate', RecalcEstimateAction, sparseTask, 'Estimate recalculated for the task.'], |
| 76 | + ['Recalculate Selection', RecalcSelectionAction, sparseTask, 'Estimates recalculated for the whole selection.'], |
| 77 | + ['Archive', ArchiveTaskAction, sparseTask, 'Task archived (demo — no data changed).'], |
| 78 | + ['Submit for Sign-off', SubmitForSignoffAction, sparseInvoice, 'Invoice submitted for finance + legal sign-off.'], |
| 79 | + ] as const)('%s falls back to the object wording when the dispatch carries no title', (_label, action, record, expected) => { |
| 80 | + expect(fillAsTheRunnerDoes(action, record)).toBe(expected); |
| 81 | + }); |
| 82 | +}); |
| 83 | + |
| 84 | +/** |
| 85 | + * The zh-CN bundle's entry REPLACES the authored copy before dispatch (the |
| 86 | + * console's `useActionTextLocalizer` resolves |
| 87 | + * `objects.<object>._actions.<name>.successMessage`, and i18next leaves a |
| 88 | + * template hole it cannot bind untouched), and the runner then fills that |
| 89 | + * entry's holes exactly as it fills the authored ones. So a zh-CN viewer is |
| 90 | + * only told which record changed when the bundle entry carries the hole too. |
| 91 | + * Every one of the five actions that HAS a zh-CN entry is held here. |
| 92 | + */ |
| 93 | +describe('showcase record actions — the zh-CN success toast names the record', () => { |
| 94 | + const zhActions = (ShowcaseTranslationBundle['zh-CN'] as { |
| 95 | + objects: Record<string, { _actions?: Record<string, { successMessage?: unknown }> }>; |
| 96 | + }).objects.showcase_task._actions ?? {}; |
| 97 | + const zh = (name: string) => { |
| 98 | + const entry = zhActions[name]; |
| 99 | + if (!entry?.successMessage) throw new Error(`no zh-CN successMessage for ${name}`); |
| 100 | + return entry; |
| 101 | + }; |
| 102 | + const zhTask = { id: 't1', title: '个人信息访问审查', done: false }; |
| 103 | + |
| 104 | + it.each([ |
| 105 | + ['showcase_mark_done', zhTask, '个人信息访问审查已标记为完成。'], |
| 106 | + ['showcase_recalc_estimate', zhTask, '已重算个人信息访问审查的工时。'], |
| 107 | + ['showcase_recalc_selection', zhTask, '已为个人信息访问审查重算工时。'], |
| 108 | + ['showcase_archive_task', zhTask, '个人信息访问审查已归档(演示——未修改任何数据)。'], |
| 109 | + ] as const)('%s names the record by its title', (name, record, expected) => { |
| 110 | + expect(fillAsTheRunnerDoes(zh(name), record)).toBe(expected); |
| 111 | + }); |
| 112 | + |
| 113 | + it.each([ |
| 114 | + ['showcase_mark_done', sparseTask, '任务已标记为完成。'], |
| 115 | + ['showcase_recalc_estimate', sparseTask, '已重算该任务的工时。'], |
| 116 | + ['showcase_recalc_selection', sparseTask, '已为整个选中集重算工时。'], |
| 117 | + ['showcase_archive_task', sparseTask, '任务已归档(演示——未修改任何数据)。'], |
| 118 | + ] as const)('%s falls back to the object wording when the dispatch carries no title', (name, record, expected) => { |
| 119 | + expect(fillAsTheRunnerDoes(zh(name), record)).toBe(expected); |
| 120 | + }); |
| 121 | +}); |
0 commit comments