diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index b887013b..d65066a0 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -4612,9 +4612,13 @@ window.__ModuleLoader__.load({ // 属调优噪音,按对齐结论留在配置文件,不上 UI。 // #249 第二批:注入形态的父/子开关(父 = autoInject)。子项紧跟父行、缩进 // 显示;父关时子项加一句「不生效」,但开关仍可点——父关是「不生效」而不是 - // 「重置用户配置」,子项自己勾着的值要留着,也应该能提前设好。同一份关系在 - // 后端 src/config.js 的 INJECT_CHILD_FLAGS(运行时闸门),两侧漂移由 - // test/inject-parent-gate.test.js 钉住。 + // 「重置用户配置」,子项自己勾着的值要留着,也应该能提前设好。 + // 这张表是**面板的渲染关系**,不是运行时闸门:后端 src/config.js 的 + // INJECT_CHILD_FLAGS 只收「父关、子项却仍有执行路径」的键(注入器的工具描述 + // 住在注入器外层,所以需要显式判父)。地志那两条不用——总闸关着时整个子系统 + // 不注册座位,子项天然没有跑起来的路径,加闸门是空转。所以两张表不必相等; + // test/inject-parent-gate.test.js 钉的是后端的闸门完整性 + 前端「子项只由 + // flagRow 渲染一次」(手写第二份会让同一个键在面板上出现两行)。 const FEATURE_CHILDREN = { autoInject: ["injectGuidanceEnabled", "continuityRescueEnabled"], // 热度衰减的注入侧乘数(#362)是它的第二口径:数据同源、含义不同 @@ -5171,13 +5175,12 @@ window.__ModuleLoader__.load({ }, CHORO_TIERS.map((r) => h("option", { key: r, value: r }, t(`memory.features.chorographyInject.${r}`)))) ), - h("div", { className: "mneme-featsubhint" }, t("memory.features.chorographyInject.hint")), - // 契约 §3.1 的两枚子开关,落在同一个子块里:它们只在仓库地志启用后 - // 才有意义,散到全部设置里就变成「看不见总闸却先看到子项」。 - // 两个都 opt-in/opt-out 按契约默认(自动更新默认关——它会读盘比 git 基线; - // 面板发起 T1 默认开——它是人主动点的按钮,不是后台行为)。 - boolRow("chorographyAutoRefresh", { sub: true }), - boolRow("chorographyT1FromPanel", { sub: true }) + h("div", { className: "mneme-featsubhint" }, t("memory.features.chorographyInject.hint")) + // 契约 §3.1 的两枚子开关(自动更新 / 面板发起 T1)不在这里手写:它们是 + // FEATURE_CHILDREN.chorographyEnabled 的子项,由 flagRow 紧跟父行统一渲染。 + // 手写一份会让同一个键渲染两遍——5c41d31 先写了这两行,5bad8a6 补父关系时 + // 没删旧的,v0.14.0-beta.1 就带着这个重复上线(切上面那个,下面那行不同步)。 + // 统一渲染还顺带补上 gate:父关时子行标注「不生效」,而值留着、仍可预置。 ); if (error && !state) return h("section", { className: "mneme-set-card" }, diff --git a/dsh-mneme/test/inject-parent-gate.test.js b/dsh-mneme/test/inject-parent-gate.test.js index fbd23c71..777db4a3 100644 --- a/dsh-mneme/test/inject-parent-gate.test.js +++ b/dsh-mneme/test/inject-parent-gate.test.js @@ -106,4 +106,29 @@ test("#249 第二批:面板的父子关系与后端同源(漏一处就静默 // 白名单:面板给的开关必须真能写进 kv,否则点了只会拿到 400。 assert.ok(FEATURE_FLAG_SPEC.booleans.includes(child), `${child} 不在 settings 白名单里`); } + + // 子项只能由 flagRow 经 FEATURE_CHILDREN 渲染一次。上面那张表(后端闸门)只 + // 覆盖 autoInject,地志的两枚子项是纯面板关系,不在里面——所以这一条改用前端 + // 表自己解析,覆盖面板声明的全部子项。 + // 回归:5c41d31 在 choroSub 里先手写了两行 boolRow,5bad8a6 往 FEATURE_CHILDREN + // 补父关系时没删旧的,同一个键渲染两遍(v0.14.0-beta.1 带着它上线:切上面那个, + // 下面那行不同步)。旧的断言查不出来——它只比「items 与 FEATURE_CHILDREN 有没有 + // 重叠」,手写行不进任何表。 + // 先剥注释再数:注释里写字面量不该算渲染点(客户端源码里也有同款约定)。 + const stripped = client + .replace(/\/\*[\s\S]*?\*\//g, "") + .split("\n") + .filter((line) => !/^\s*\/\//.test(line)) + .join("\n"); + const childrenBlock = stripped.match(/const FEATURE_CHILDREN = \{([\s\S]*?)\n \};/)?.[1] ?? ""; + const declaredChildren = [...childrenBlock.matchAll(/"([^"]+)"/g)].map((m) => m[1]); + assert.ok(declaredChildren.length >= 5, `前端 FEATURE_CHILDREN 解析失败(got ${declaredChildren.length})`); + for (const child of declaredChildren) { + const escaped = child.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const handwritten = (stripped.match(new RegExp(`boolRow\\(\\s*"${escaped}"`, "g")) || []).length; + assert.equal(handwritten, 0, `${child} 被手写渲染了 ${handwritten} 次:子项只能由 flagRow 渲染,否则面板上会出现两行`); + } + // 防「删掉渲染让上面那条变绿」:子项真得有展开点。 + assert.ok(/kids\.map\(\(c\) => boolRow\(c, \{ sub: true, gate: k \}\)\)/.test(stripped), + "flagRow 必须展开子项并带 gate,否则 FEATURE_CHILDREN 只是声明、面板上没有子行"); });