fix(admin): 变量集差替代配对递归(删尾行/整键检出)+ 标签契约钉位

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-14 19:03:36 +08:00
parent 2e6811a55b
commit 36fe534029
3 changed files with 56 additions and 39 deletions
@@ -55,6 +55,7 @@ import {
rowToEntry, rowToEntry,
rowsToEntries, rowsToEntries,
rowToSingleEntry, rowToSingleEntry,
SCENARIO_WHEN_LABELS,
scopedConfigJson, scopedConfigJson,
secretFieldState, secretFieldState,
snapshotGroupKeys, snapshotGroupKeys,
@@ -795,7 +796,7 @@ describe('模板变量保护', () => {
expect(removedTemplateVariables('发到 {intl} 联系', '发到手机联系')).toEqual(['intl']) expect(removedTemplateVariables('发到 {intl} 联系', '发到手机联系')).toEqual(['intl'])
expect(removedTemplateVariables('发到 {intl}', '发到 {intl}')).toEqual([]) expect(removedTemplateVariables('发到 {intl}', '发到 {intl}')).toEqual([])
}) })
it('wall 整组递归检出(池+模板+提醒),同变量去重', () => { it('wall 整组递归检出(池+模板+提醒),同变量去重(全域消失才报)', () => {
const before = { const before = {
deflections: { en: ['call {intl}'] }, deflections: { en: ['call {intl}'] },
cc_templates: { echo: { en: 'at {intl}' } }, cc_templates: { echo: { en: 'at {intl}' } },
@@ -803,14 +804,20 @@ describe('模板变量保护', () => {
} }
const after = { const after = {
deflections: { en: ['call me'] }, deflections: { en: ['call me'] },
cc_templates: { echo: { en: 'at {intl}' } }, cc_templates: { echo: { en: 'at email' } },
reminders: { cc_guide: { echo: '补全为 {intl}' } } reminders: { cc_guide: { echo: '补全为手机号' } }
} }
expect(collectRemovedWallVariables(before, after)).toEqual(['intl']) expect(collectRemovedWallVariables(before, after)).toEqual(['intl'])
}) })
it('快照缺键不误报', () => { it('快照缺键不误报', () => {
expect(collectRemovedWallVariables({}, { deflections: { en: ['x'] } })).toEqual([]) expect(collectRemovedWallVariables({}, { deflections: { en: ['x'] } })).toEqual([])
}) })
it('删除含变量的尾行/整语言键同样检出(位置无关)', () => {
const before = { deflections: { en: ['plain', 'call {intl}'] } }
expect(collectRemovedWallVariables(before, { deflections: { en: ['plain'] } })).toEqual(['intl'])
const before2 = { cc_templates: { echo: { en: 'at {intl}' } } }
expect(collectRemovedWallVariables(before2, { cc_templates: { echo: {} } })).toEqual(['intl'])
})
}) })
describe('WALL_SCENARIOS(场景卡定义)', () => { describe('WALL_SCENARIOS(场景卡定义)', () => {
@@ -819,4 +826,7 @@ describe('WALL_SCENARIOS(场景卡定义)', () => {
expect(price?.pools.map((p) => p.field)).toEqual(['deflections', 'deflections_contact']) expect(price?.pools.map((p) => p.field)).toEqual(['deflections', 'deflections_contact'])
expect(price?.pools.map((p) => p.when)).toEqual(['not_given', 'given']) expect(price?.pools.map((p) => p.when)).toEqual(['not_given', 'given'])
}) })
it('状态标签契约(ScenarioCard 消费)', () => {
expect(SCENARIO_WHEN_LABELS).toEqual({ not_given: '客户还没留联系方式时', given: '客户已经留过之后' })
})
}) })
+15 -16
View File
@@ -489,31 +489,30 @@ export function removedTemplateVariables(before: string, after: string): string[
return templateVariables(before).filter((v) => !afterSet.has(v)) return templateVariables(before).filter((v) => !afterSet.has(v))
} }
function collectRemovedStrings(before: unknown, after: unknown, out: string[]): void { function collectVariables(value: unknown, out: string[]): void {
if (typeof before === 'string' && typeof after === 'string') { if (typeof value === 'string') {
out.push(...removedTemplateVariables(before, after)) out.push(...templateVariables(value))
} else if ( } else if (value !== null && typeof value === 'object') {
before !== null && after !== null && // typeof 收窄只到 object(无索引签名)——与上方 asRecord 同款断言收形
typeof before === 'object' && typeof after === 'object' const record = value as Record<string, unknown>
) { for (const key of Object.keys(record)) collectVariables(record[key], out)
const b = before as Record<string, unknown>
const a = after as Record<string, unknown>
for (const key of Object.keys(b)) collectRemovedStrings(b[key], a[key], out)
} }
} }
/** wall 整组文本区(池/no_contact/cc_templates/reminders)递归比对——被删变量去重清单 */ /** wall 整组文本区(池/no_contact/cc_templates/reminders)变量集差——
* before 出现过且 after 全域不再出现的变量(位置无关:删除尾行/整键同样检出) */
export function collectRemovedWallVariables( export function collectRemovedWallVariables(
before: Record<string, unknown> | null | undefined, before: Record<string, unknown> | null | undefined,
after: Record<string, unknown> | null | undefined after: Record<string, unknown> | null | undefined
): string[] { ): string[] {
const out: string[] = [] const beforeVars: string[] = []
const b = before ?? {} const afterVars: string[] = []
const a = after ?? {}
for (const key of [...POOL_FIELDS, 'no_contact', 'cc_templates', 'reminders']) { for (const key of [...POOL_FIELDS, 'no_contact', 'cc_templates', 'reminders']) {
collectRemovedStrings(b[key], a[key], out) collectVariables((before ?? {})[key], beforeVars)
collectVariables((after ?? {})[key], afterVars)
} }
return [...new Set(out)] const afterSet = new Set(afterVars)
return [...new Set(beforeVars.filter((v) => !afterSet.has(v)))]
} }
// ---- 试金石 ------------------------------------------------------------------------ // ---- 试金石 ------------------------------------------------------------------------
@@ -507,7 +507,7 @@ emits 声明追加 `(e: 'navigate', group: string): void`。
- [ ] **Step 1: 写失败测试**: - [ ] **Step 1: 写失败测试**:
```ts ```ts
import { poolLanguages, templateVariables, removedTemplateVariables, collectRemovedWallVariables, WALL_SCENARIOS } from '../model' import { poolLanguages, templateVariables, removedTemplateVariables, collectRemovedWallVariables, WALL_SCENARIOS, SCENARIO_WHEN_LABELS } from '../model'
describe('poolLanguages(场景卡语言 tabs)', () => { describe('poolLanguages(场景卡语言 tabs)', () => {
it('多池语言并集,必留语言在前、附加语言按序', () => { it('多池语言并集,必留语言在前、附加语言按序', () => {
@@ -529,7 +529,7 @@ describe('模板变量保护', () => {
expect(removedTemplateVariables('发到 {intl} 联系', '发到手机联系')).toEqual(['intl']) expect(removedTemplateVariables('发到 {intl} 联系', '发到手机联系')).toEqual(['intl'])
expect(removedTemplateVariables('发到 {intl}', '发到 {intl}')).toEqual([]) expect(removedTemplateVariables('发到 {intl}', '发到 {intl}')).toEqual([])
}) })
it('wall 整组递归检出(池+模板+提醒),同变量去重', () => { it('wall 整组递归检出(池+模板+提醒),同变量去重(全域消失才报)', () => {
const before = { const before = {
deflections: { en: ['call {intl}'] }, deflections: { en: ['call {intl}'] },
cc_templates: { echo: { en: 'at {intl}' } }, cc_templates: { echo: { en: 'at {intl}' } },
@@ -537,14 +537,20 @@ describe('模板变量保护', () => {
} }
const after = { const after = {
deflections: { en: ['call me'] }, deflections: { en: ['call me'] },
cc_templates: { echo: { en: 'at {intl}' } }, cc_templates: { echo: { en: 'at email' } },
reminders: { cc_guide: { echo: '补全为 {intl}' } } reminders: { cc_guide: { echo: '补全为手机号' } }
} }
expect(collectRemovedWallVariables(before, after)).toEqual(['intl']) expect(collectRemovedWallVariables(before, after)).toEqual(['intl'])
}) })
it('快照缺键不误报', () => { it('快照缺键不误报', () => {
expect(collectRemovedWallVariables({}, { deflections: { en: ['x'] } })).toEqual([]) expect(collectRemovedWallVariables({}, { deflections: { en: ['x'] } })).toEqual([])
}) })
it('删除含变量的尾行/整语言键同样检出(位置无关)', () => {
const before = { deflections: { en: ['plain', 'call {intl}'] } }
expect(collectRemovedWallVariables(before, { deflections: { en: ['plain'] } })).toEqual(['intl'])
const before2 = { cc_templates: { echo: { en: 'at {intl}' } } }
expect(collectRemovedWallVariables(before2, { cc_templates: { echo: {} } })).toEqual(['intl'])
})
}) })
describe('WALL_SCENARIOS(场景卡定义)', () => { describe('WALL_SCENARIOS(场景卡定义)', () => {
@@ -553,6 +559,9 @@ describe('WALL_SCENARIOS(场景卡定义)', () => {
expect(price?.pools.map((p) => p.field)).toEqual(['deflections', 'deflections_contact']) expect(price?.pools.map((p) => p.field)).toEqual(['deflections', 'deflections_contact'])
expect(price?.pools.map((p) => p.when)).toEqual(['not_given', 'given']) expect(price?.pools.map((p) => p.when)).toEqual(['not_given', 'given'])
}) })
it('状态标签契约(ScenarioCard 消费)', () => {
expect(SCENARIO_WHEN_LABELS).toEqual({ not_given: '客户还没留联系方式时', given: '客户已经留过之后' })
})
}) })
``` ```
@@ -631,31 +640,30 @@ export function removedTemplateVariables(before: string, after: string): string[
return templateVariables(before).filter((v) => !afterSet.has(v)) return templateVariables(before).filter((v) => !afterSet.has(v))
} }
function collectRemovedStrings(before: unknown, after: unknown, out: string[]): void { function collectVariables(value: unknown, out: string[]): void {
if (typeof before === 'string' && typeof after === 'string') { if (typeof value === 'string') {
out.push(...removedTemplateVariables(before, after)) out.push(...templateVariables(value))
} else if ( } else if (value !== null && typeof value === 'object') {
before !== null && after !== null && // typeof 收窄只到 object(无索引签名)——断言收形
typeof before === 'object' && typeof after === 'object' const record = value as Record<string, unknown>
) { for (const key of Object.keys(record)) collectVariables(record[key], out)
const b = before as Record<string, unknown>
const a = after as Record<string, unknown>
for (const key of Object.keys(b)) collectRemovedStrings(b[key], a[key], out)
} }
} }
/** wall 整组文本区(池/no_contact/cc_templates/reminders)递归比对——被删变量去重清单 */ /** wall 整组文本区(池/no_contact/cc_templates/reminders)变量集差——
* before 出现过且 after 全域不再出现的变量(位置无关:删除尾行/整键同样检出) */
export function collectRemovedWallVariables( export function collectRemovedWallVariables(
before: Record<string, unknown> | null | undefined, before: Record<string, unknown> | null | undefined,
after: Record<string, unknown> | null | undefined after: Record<string, unknown> | null | undefined
): string[] { ): string[] {
const out: string[] = [] const beforeVars: string[] = []
const b = before ?? {} const afterVars: string[] = []
const a = after ?? {}
for (const key of [...POOL_FIELDS, 'no_contact', 'cc_templates', 'reminders']) { for (const key of [...POOL_FIELDS, 'no_contact', 'cc_templates', 'reminders']) {
collectRemovedStrings(b[key], a[key], out) collectVariables((before ?? {})[key], beforeVars)
collectVariables((after ?? {})[key], afterVars)
} }
return [...new Set(out)] const afterSet = new Set(afterVars)
return [...new Set(beforeVars.filter((v) => !afterSet.has(v)))]
} }
``` ```