feat(admin): contact/engine/misc 表单映射纯函数
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -36,12 +36,18 @@ import {
|
||||
buildWallPayload,
|
||||
COMPARE_SCOPE_ALL,
|
||||
collectRemovedWallVariables,
|
||||
contactToForm,
|
||||
contactToPayload,
|
||||
descendingVersions,
|
||||
engineToForm,
|
||||
engineToPayload,
|
||||
formatKB,
|
||||
formatVersionTime,
|
||||
initWallEdit,
|
||||
JSON_GROUP_FIELDS,
|
||||
jsonGroupDraft,
|
||||
miscToForm,
|
||||
miscToPayload,
|
||||
normalizeAiCredsGroup,
|
||||
normalizeEmailCredsGroup,
|
||||
normalizePromptsGroup,
|
||||
@@ -63,6 +69,8 @@ import {
|
||||
touchstoneSentences,
|
||||
utf8Bytes,
|
||||
validateEmailCredsForm,
|
||||
validateEngineForm,
|
||||
validateKeyValueRows,
|
||||
validatePushCredsForm,
|
||||
type EmailCredsForm,
|
||||
type PromptsDraft,
|
||||
@@ -830,3 +838,62 @@ describe('WALL_SCENARIOS(场景卡定义)', () => {
|
||||
expect(SCENARIO_WHEN_LABELS).toEqual({ not_given: '客户还没留联系方式时', given: '客户已经留过之后' })
|
||||
})
|
||||
})
|
||||
|
||||
// ---- contact / engine / misc 表单化(Task 12;JsonGroup 退役配套) ---------------------
|
||||
|
||||
describe('contact 表单化', () => {
|
||||
it('快照 → 表单(bool 缺省 false / 语言数组)', () => {
|
||||
expect(contactToForm({ cc_completion: true, email_light_ask: false, template_languages: ['en', 'zh'] }))
|
||||
.toEqual({ ccCompletion: true, emailLightAsk: false, templateLanguages: ['en', 'zh'] })
|
||||
expect(contactToForm(null)).toEqual({ ccCompletion: false, emailLightAsk: false, templateLanguages: [] })
|
||||
})
|
||||
it('表单 → payload:接管键覆写 + 未知键透传', () => {
|
||||
const snapshot = { cc_completion: false, email_light_ask: false, template_languages: [], _meta: 'keep' }
|
||||
expect(contactToPayload({ ccCompletion: true, emailLightAsk: true, templateLanguages: ['en', ' '] }, snapshot))
|
||||
.toEqual({ _meta: 'keep', cc_completion: true, email_light_ask: true, template_languages: ['en'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('engine 表单化', () => {
|
||||
it('快照 → 表单(数值转串 / seed 映射转行)', () => {
|
||||
expect(engineToForm({ rate_limit_per_hour: 120, ws_conn_limit: 5, context_seed_keys: { a: 'b' } }))
|
||||
.toEqual({ rateLimit: '120', wsConnLimit: '5', seedRows: [{ key: 'a', value: 'b' }] })
|
||||
expect(engineToForm(null)).toEqual({ rateLimit: '', wsConnLimit: '', seedRows: [] })
|
||||
})
|
||||
it('payload:空数值槽保快照原值(快照也无 → 键省略);未知键透传;seed 恒重建', () => {
|
||||
const snapshot = { rate_limit_per_hour: 120, ws_conn_limit: 5, other: 1 }
|
||||
expect(engineToPayload({ rateLimit: '', wsConnLimit: '9', seedRows: [] }, snapshot))
|
||||
.toEqual({ other: 1, rate_limit_per_hour: 120, ws_conn_limit: 9, context_seed_keys: {} })
|
||||
expect(engineToPayload({ rateLimit: '60', wsConnLimit: '', seedRows: [] }, null))
|
||||
.toEqual({ rate_limit_per_hour: 60, context_seed_keys: {} })
|
||||
})
|
||||
it('payload:seed 行空键值剔除、重建映射', () => {
|
||||
expect(engineToPayload(
|
||||
{ rateLimit: '1', wsConnLimit: '1', seedRows: [{ key: 'product_name', value: 'app_product_name' }, { key: '', value: 'x' }] },
|
||||
{}
|
||||
).context_seed_keys).toEqual({ product_name: 'app_product_name' })
|
||||
})
|
||||
it('校验:非正整数报错', () => {
|
||||
expect(validateEngineForm({ rateLimit: '0', wsConnLimit: '', seedRows: [] }))
|
||||
.toEqual(['每 IP 每小时发言上限:须为正整数'])
|
||||
expect(validateEngineForm({ rateLimit: '30', wsConnLimit: '2', seedRows: [] })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('misc 表单化', () => {
|
||||
it('往返:快照 → 表单 → payload 保未知键', () => {
|
||||
const snapshot = { name_to_region_extras: { 'saudi arabia': '966' }, keep: true }
|
||||
const form = miscToForm(snapshot)
|
||||
expect(form.extrasRows).toEqual([{ key: 'saudi arabia', value: '966' }])
|
||||
expect(miscToPayload({ extrasRows: [{ key: 'saudi arabia', value: '966' }, { key: 'uae', value: '971' }] }, snapshot))
|
||||
.toEqual({ keep: true, name_to_region_extras: { 'saudi arabia': '966', uae: '971' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('KeyValue 行校验(engine seed / misc extras 共用)', () => {
|
||||
it('重复键与空键报错', () => {
|
||||
expect(validateKeyValueRows([{ key: 'a', value: '1' }, { key: 'a', value: '2' }], '映射'))
|
||||
.toEqual(['映射:键 a 重复'])
|
||||
expect(validateKeyValueRows([{ key: '', value: '1' }], '映射')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -934,3 +934,148 @@ export const JSON_GROUP_FIELDS: Record<
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// ---- contact / engine / misc 表单化(spec §5.4-5.7;JsonGroup 退役配套) ----------------
|
||||
|
||||
export interface KeyValueRow {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** 键值行校验(空行整行忽略;非空键重复报错;半行序列化时剔除不报错) */
|
||||
export function validateKeyValueRows(rows: KeyValueRow[], label: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
const errors: string[] = []
|
||||
for (const row of rows) {
|
||||
if (row.key.trim() === '' && row.value.trim() === '') continue
|
||||
const key = row.key.trim()
|
||||
if (key === '') continue // 半行(有 value 无 key)序列化时整行剔除,不报错
|
||||
if (seen.has(key)) errors.push(`${label}:键 ${key} 重复`)
|
||||
seen.add(key)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
function rowsToRecord(rows: KeyValueRow[]): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const row of rows) {
|
||||
const key = row.key.trim()
|
||||
if (key !== '') out[key] = row.value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function recordToRows(value: unknown): KeyValueRow[] {
|
||||
const src = asRecord(value) ?? {}
|
||||
return Object.entries(src)
|
||||
.filter(([, v]) => typeof v === 'string')
|
||||
.map(([key, v]) => ({ key, value: v as string }))
|
||||
}
|
||||
|
||||
// ---- contact ------------------------------------------------------------------------
|
||||
|
||||
export interface ContactForm {
|
||||
ccCompletion: boolean
|
||||
emailLightAsk: boolean
|
||||
templateLanguages: string[]
|
||||
}
|
||||
|
||||
export function contactToForm(
|
||||
group: Record<string, unknown> | null | undefined
|
||||
): ContactForm {
|
||||
const src = group ?? {}
|
||||
return {
|
||||
ccCompletion: src.cc_completion === true,
|
||||
emailLightAsk: src.email_light_ask === true,
|
||||
templateLanguages: strList(src.template_languages)
|
||||
}
|
||||
}
|
||||
|
||||
export function contactToPayload(
|
||||
form: ContactForm,
|
||||
group: Record<string, unknown> | null | undefined
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...(group ?? {}),
|
||||
cc_completion: form.ccCompletion,
|
||||
email_light_ask: form.emailLightAsk,
|
||||
template_languages: form.templateLanguages.map((s) => s.trim()).filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- engine -------------------------------------------------------------------------
|
||||
|
||||
export interface EngineForm {
|
||||
/** 正整数串;'' = 未填(payload 保快照原值/省略键——绝不提交非法值) */
|
||||
rateLimit: string
|
||||
wsConnLimit: string
|
||||
seedRows: KeyValueRow[]
|
||||
}
|
||||
|
||||
function intToForm(value: unknown): string {
|
||||
return typeof value === 'number' && Number.isInteger(value) ? String(value) : ''
|
||||
}
|
||||
|
||||
export function engineToForm(
|
||||
group: Record<string, unknown> | null | undefined
|
||||
): EngineForm {
|
||||
const src = group ?? {}
|
||||
return {
|
||||
rateLimit: intToForm(src.rate_limit_per_hour),
|
||||
wsConnLimit: intToForm(src.ws_conn_limit),
|
||||
seedRows: recordToRows(src.context_seed_keys)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单 → payload:空数值槽 = 保快照原值(spread 已带上;快照无值则不带键
|
||||
* ——resolver 出厂回退),非空槽覆写;context_seed_keys 行编辑恒重建(空行
|
||||
* 剔除——用户显式管理该映射,清空即提交空映射);其余未知键透传。
|
||||
*/
|
||||
export function engineToPayload(
|
||||
form: EngineForm,
|
||||
group: Record<string, unknown> | null | undefined
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...(group ?? {}) }
|
||||
const rate = form.rateLimit.trim()
|
||||
if (rate !== '') out.rate_limit_per_hour = Number(rate)
|
||||
const ws = form.wsConnLimit.trim()
|
||||
if (ws !== '') out.ws_conn_limit = Number(ws)
|
||||
out.context_seed_keys = rowsToRecord(form.seedRows)
|
||||
return out
|
||||
}
|
||||
|
||||
export function validateEngineForm(form: EngineForm): string[] {
|
||||
const errors: string[] = []
|
||||
const rate = form.rateLimit.trim()
|
||||
if (rate !== '' && (!/^\d+$/.test(rate) || Number(rate) < 1)) {
|
||||
errors.push('每 IP 每小时发言上限:须为正整数')
|
||||
}
|
||||
const ws = form.wsConnLimit.trim()
|
||||
if (ws !== '' && (!/^\d+$/.test(ws) || Number(ws) < 1)) {
|
||||
errors.push('每 IP WebSocket 连接上限:须为正整数')
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
// ---- misc ---------------------------------------------------------------------------
|
||||
|
||||
export interface MiscForm {
|
||||
extrasRows: KeyValueRow[]
|
||||
}
|
||||
|
||||
export function miscToForm(
|
||||
group: Record<string, unknown> | null | undefined
|
||||
): MiscForm {
|
||||
return { extrasRows: recordToRows((group ?? {}).name_to_region_extras) }
|
||||
}
|
||||
|
||||
export function miscToPayload(
|
||||
form: MiscForm,
|
||||
group: Record<string, unknown> | null | undefined
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...(group ?? {}),
|
||||
name_to_region_extras: rowsToRecord(form.extrasRows)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user