diff --git a/src/state_gate.rs b/src/state_gate.rs index 6a5bad4..5cea6b1 100644 --- a/src/state_gate.rs +++ b/src/state_gate.rs @@ -16311,3 +16311,372 @@ fn r163_variant_self_call(app: &str) -> String { fn r163_variant_defect(app: &str) -> String { r163_variant_drop_fallback(&r163_variant_refusal_falls_through(app)) } + +// ── C2174:运营「上游 key 状态」卡的行计数必须用这张卡自己的名词 ──────────────────────────────── + +/// 这一卡的三个键。**标题是名词的唯一来源**(见 [`ops_key_rows_noun`])。 +const OPS_KEYS_TITLE: &str = "ops.keys.title"; +const OPS_KEYS_COUNT: &str = "ops.keys.count"; +const OPS_KEYS_SUB: &str = "ops.keys.sub"; + +/// 卡片自己给这批行的**名词**:`ops.keys.title` 去掉最后一个空格分隔的 token。 +/// +/// zh「上游 key 状态」→「上游 key」;en「Upstream key status」→「Upstream key」。 +/// 门禁里**零手写**这些词 —— 改称谓只改语言包,不改门禁(否则改一次词就要改一次门禁)。 +/// 标题只剩一个 token ⇒ `None`(「去掉末词」不再是可靠的派生,而门禁宁可判前置失败)。 +fn ops_key_rows_noun(title: &str) -> Option { + let toks: Vec<&str> = title.split_whitespace().collect(); + if toks.len() < 2 { + return None; + } + let noun = toks[..toks.len() - 1].join(" "); + if noun.is_empty() { + None + } else { + Some(noun) + } +} + +/// `haystack` 是否含 `noun`(**ASCII 大小写不敏感** —— en 的「Upstream key」要认「upstream keys」; +/// CJK 不受影响)。 +fn ops_noun_mentioned(haystack: &str, noun: &str) -> bool { + haystack + .to_ascii_lowercase() + .contains(&noun.to_ascii_lowercase()) +} + +/// 语言包区段里**真的当过键**的 `ops.keys.*` 名字(只认 `"key": ` 这种形状 —— +/// 注释里提到的键名不算,见坑 #296 家族)。 +fn ops_keys_in(region: &str) -> BTreeSet { + // 先剥注释:语言包区段里现在有解释性散文(本卡的三行 `//` 就在其中),而注释里写下的 + // `"ops.keys.x": ` 不是键(坑 #296 家族 —— 注释里的表达式会把计数带偏)。 + let region = code_text(region); + let region = region.as_str(); + let mut out = BTreeSet::new(); + let mut from = 0usize; + while let Some(rel) = region[from..].find("\"ops.keys.") { + let at = from + rel + 1; + let rest = ®ion[at..]; + let end = match rest.find('"') { + Some(e) => e, + None => break, + }; + if rest[end + 1..].trim_start().starts_with(':') { + out.insert(rest[..end].to_string()); + } + from = at + end + 1; + } + out +} + +/// 一个语言包在这一卡上的取值与派生名词。 +struct R2174Pack { + title: Option, + noun: Option, + count: Option, + sub: Option, + keys: BTreeSet, +} + +fn r2174_pack(region: &str) -> R2174Pack { + let title = pack_string(region, OPS_KEYS_TITLE); + let noun = title.as_deref().and_then(ops_key_rows_noun); + R2174Pack { + title, + noun, + count: pack_string(region, OPS_KEYS_COUNT), + sub: pack_string(region, OPS_KEYS_SUB), + keys: ops_keys_in(region), + } +} + +/// 一次读取的全部证据与四条判词(R1 的产物是两包的 `noun`,不是判词)。 +struct R2174Reading { + zh: R2174Pack, + en: R2174Pack, + r2: bool, + r3: bool, + r4: bool, + r5: bool, +} + +impl R2174Reading { + fn report(&self) -> String { + format!( + "r2={} r3={} r4={} r5={} | zh noun={:?} count={:?} sub={:?} | en noun={:?} count={:?} sub={:?}", + self.r2, + self.r3, + self.r4, + self.r5, + self.zh.noun, + self.zh.count, + self.zh.sub, + self.en.noun, + self.en.count, + self.en.sub + ) + } +} + +/// 读三份制品(语言包 / markup / 渲染器),导出四条判词。名册与期望值全部**派生**: +/// 名词来自标题、键集来自语言包、渲染器与 markup 的存在性来自那两个文件本身。 +fn r2174_read(pack: &str, html: &str, app: &str) -> R2174Reading { + let zh = r2174_pack(pack_region_strict(pack, ZH_PACK_START, EN_PACK_START)); + let en = r2174_pack(pack_region_strict(pack, EN_PACK_START, PACK_END)); + + let named = |p: &R2174Pack, v: &Option| match (p.noun.as_deref(), v.as_deref()) { + (Some(n), Some(text)) => ops_noun_mentioned(text, n), + _ => false, + }; + + // R4(前置,防空集/空语料假绿 —— 坑 #68 家族):两包的三个串都取到、名词派生成功且**确实 + // 短了**、两包的 `ops.keys.*` 键集相等且非空、三份语料各自携带这张卡的一部分。 + let r4 = zh.title.is_some() + && zh.count.is_some() + && zh.sub.is_some() + && en.title.is_some() + && en.count.is_some() + && en.sub.is_some() + && zh.noun.is_some() + && en.noun.is_some() + && zh.noun != zh.title + && en.noun != en.title + && !zh.keys.is_empty() + && zh.keys == en.keys + && html.contains(&format!("data-i18n=\"{OPS_KEYS_TITLE}\"")) + && app.contains(&format!("T(\"{OPS_KEYS_COUNT}\"")); + + // R5(阳性对照):计数行必须**仍**带两个占位符 —— 挡住「把计数行删掉 / 删掉一个数」那种 + // 「把承诺删掉」的化妆式修法(同 C2172 的 R5)。 + let r5 = zh + .count + .as_deref() + .is_some_and(|v| v.contains("{total}") && v.contains("{on}")) + && en + .count + .as_deref() + .is_some_and(|v| v.contains("{total}") && v.contains("{on}")); + + R2174Reading { + r2: named(&zh, &zh.count) && named(&en, &en.count), + r3: named(&zh, &zh.sub) && named(&en, &en.sub), + r4, + r5, + zh, + en, + } +} + +/// 轴:运营「上游 key 状态」卡的行计数说的是它数的那批行。 +/// +/// `/api/ops/runtime` 的 `key_health` 每行给 `{provider,total,on,off}`,其中 `total` 是 +/// `COUNT(*)`(**全部状态**:`on` / `paused` / `off`=软删),而同屏正上方的统计卡 +/// (`ops.stats.keys`「上架 key 数」)数的是 `WHERE status = 'on'`。产品把「上架」严格绑给 +/// `status='on'`(共享页「上架中」;含非 `on` 行的计数在那里叫「历史」)⇒ 这张卡的行若把 +/// `total` 称作「上架 key」,同一个词在同一个视口里就带了两个数。 +/// +/// 三条规则(R2/R3/R5)各有各的牙,见 [`the_r2174_rules_have_teeth`]。 +#[test] +fn the_ops_key_rows_are_named_the_way_their_own_card_names_them() { + let rd = r2174_read(I18N_JS, INDEX_HTML, APP_JS); + assert!( + rd.r4, + "前置不成立 —— 门禁的语料或键集已失真(空集上的断言会假绿):{}", + rd.report() + ); + assert!( + rd.r2, + "运营「上游 key 状态」卡的行计数没有用这张卡自己的名词 —— 它数的是**全部状态**的上游 key\ + (含 `paused` / `off`),而「上架 key」= `status='on'`(同屏「上架 key 数」那张卡与共享页\ + 「上架中」用的就是后者):{}", + rd.report() + ); + assert!( + rd.r3, + "同一张卡的副标题(`{OPS_KEYS_SUB}`)给的是**同一个数**的另一个称呼,也必须用卡片自己的\ + 名词 —— 只修计数行会在副标题留下同一处错词:{}", + rd.report() + ); + assert!( + rd.r5, + "计数行丢了占位符(`{{total}}` / `{{on}}`)—— 「把承诺删掉」不是修:{}", + rd.report() + ); +} + +/// 三条规则的**牙**:每个合成变异体只打翻它针对的那一条(基线=已知为绿的活树)。 +/// +/// ⚠️ 变异体一律从**活树**构造,每个锚点先断言唯一(#612 家族):变体没生效时构造器先响, +/// 而不是让某条腿因错误的原因变绿(#605)。 +#[test] +fn the_r2174_rules_have_teeth() { + let live = r2174_read(I18N_JS, INDEX_HTML, APP_JS); + assert!( + live.r2 && live.r3 && live.r4 && live.r5, + "基线不是绿的 —— 变异体的读数无从解释:{}", + live.report() + ); + + // ① 计数行退回缺陷措辞(两个包一起退,那是缺陷的忠实形状)⇒ 只翻 R2。 + let v = r2174_variant_count_reverts(I18N_JS); + let rd = r2174_read(&v, INDEX_HTML, APP_JS); + assert_eq!( + (rd.r2, rd.r3, rd.r5), + (false, true, true), + "① 计数行的名词退回「上架家族」只应翻掉 R2:{}", + rd.report() + ); + + // ② 只把副标题退回缺陷措辞 ⇒ 只翻 R3。 + let v = r2174_variant_sub_reverts(I18N_JS); + let rd = r2174_read(&v, INDEX_HTML, APP_JS); + assert_eq!( + (rd.r2, rd.r3, rd.r5), + (true, false, true), + "② 副标题的名词退回「上架家族」只应翻掉 R3:{}", + rd.report() + ); + + // ③ 计数行丢一个占位符 ⇒ 只翻 R5。 + let v = r2174_variant_placeholder_dropped(I18N_JS); + let rd = r2174_read(&v, INDEX_HTML, APP_JS); + assert_eq!( + (rd.r2, rd.r3, rd.r5), + (true, true, false), + "③ 丢掉 `{{on}}` 只应翻掉 R5:{}", + rd.report() + ); + + // ④ **声明为盲**的一条腿:把**称谓**整体改成与数据不符的那一族 —— 标题 + 计数行 + 副标题 + // 一起改、彼此自洽 ⇒ 门禁**全绿**。这条词法规则挡不住「改名而不改数据」的竞争修法 + // (挡它的是设计基线原型写的就是「上游 key」,以及 jsdom 探针的 `m_card_widen` 腿)。 + // 这里断言它**确实**全绿 —— 盲区要被测量,不能被当成「反正不会发生」。 + let v = r2174_variant_coherent_wrong_rename(I18N_JS); + let rd = r2174_read(&v, INDEX_HTML, APP_JS); + assert!( + rd.r2 && rd.r3 && rd.r4 && rd.r5, + "④ 是**声明为盲**的腿:它必须真的全绿(否则它就不是盲区,而是我们的判别式漏了它):{}", + rd.report() + ); +} + +/// 提取器自证:名词派生与键扫描在**合成输入**上按声明工作(与活树无关)。 +#[test] +fn the_r2174_scanners_have_teeth() { + // 名词 = 标题去掉末词(两种语言的形状)。 + assert_eq!( + ops_key_rows_noun("上游 key 状态").as_deref(), + Some("上游 key") + ); + assert_eq!( + ops_key_rows_noun("Upstream key status").as_deref(), + Some("Upstream key") + ); + // 单 token ⇒ None(派生不可靠,宁判前置失败)。 + assert_eq!(ops_key_rows_noun("状态"), None); + assert_eq!(ops_key_rows_noun(""), None); + // 多余空白不改变结果(split_whitespace)。 + assert_eq!( + ops_key_rows_noun("上游 key 状态").as_deref(), + Some("上游 key") + ); + + // 大小写:en 的复数要认单数名词;zh 原样。 + assert!(ops_noun_mentioned( + "Total upstream keys here", + "Upstream key" + )); + assert!(ops_noun_mentioned( + "{total} 个上游 key · {on} 个启用", + "上游 key" + )); + assert!(!ops_noun_mentioned("{total} listed keys", "upstream key")); + + // 键扫描只认 `"key": ` 形状:注释里提到的键名不算。 + let synthetic = " // \"ops.keys.fake\": 这是注释\n \"ops.keys.count\": \"x\",\n"; + let keys = ops_keys_in(synthetic); + assert!( + keys.contains("ops.keys.count") && !keys.contains("ops.keys.fake"), + "键扫描把注释里的键名当成了真键:{keys:?}" + ); +} + +/// 变异体①:计数行退回缺陷措辞(`total` 被称作「上架 key」)。 +fn r2174_variant_count_reverts(pack: &str) -> String { + let zh = " \"ops.keys.count\": \"{total} 个上游 key · {on} 个启用\",\n"; + let en = " \"ops.keys.count\": \"{total} upstream keys · {on} enabled\",\n"; + assert_eq!(pack.matches(zh).count(), 1, "zh 计数行锚点不唯一"); + assert_eq!(pack.matches(en).count(), 1, "en 计数行锚点不唯一"); + pack.replace( + zh, + " \"ops.keys.count\": \"{total} 个上架 key · {on} 个启用\",\n", + ) + .replace( + en, + " \"ops.keys.count\": \"{total} listed keys · {on} enabled\",\n", + ) +} + +/// 变异体②:只把副标题退回缺陷措辞(计数行不动)。 +fn r2174_variant_sub_reverts(pack: &str) -> String { + let zh = " \"ops.keys.sub\": \"按厂商聚合 · 启用状态与总上游 key 数\",\n"; + let en = + " \"ops.keys.sub\": \"Aggregated by provider · enabled vs. total upstream keys\",\n"; + assert_eq!(pack.matches(zh).count(), 1, "zh 副标题锚点不唯一"); + assert_eq!(pack.matches(en).count(), 1, "en 副标题锚点不唯一"); + pack.replace( + zh, + " \"ops.keys.sub\": \"按厂商聚合 · 启用状态与总上架数\",\n", + ) + .replace( + en, + " \"ops.keys.sub\": \"Aggregated by provider · enabled vs. total listings\",\n", + ) +} + +/// 变异体③:计数行丢掉 `{on}` 占位符(词没变)⇒ 只应翻 R5。 +fn r2174_variant_placeholder_dropped(pack: &str) -> String { + let zh = " \"ops.keys.count\": \"{total} 个上游 key · {on} 个启用\",\n"; + assert_eq!(pack.matches(zh).count(), 1, "zh 计数行锚点不唯一"); + pack.replace(zh, " \"ops.keys.count\": \"{total} 个上游 key\",\n") +} + +/// 变异体④(**声明为盲**的腿):把称谓整体改成与数据不符的那一族 —— 标题、计数行、副标题 +/// 一起改且彼此自洽 ⇒ 门禁全绿。(与探针的 `m_card_widen` 腿配对:那条腿在运行期拒绝它。) +fn r2174_variant_coherent_wrong_rename(pack: &str) -> String { + let anchors = [ + " \"ops.keys.title\": \"上游 key 状态\",\n", + " \"ops.keys.title\": \"Upstream key status\",\n", + " \"ops.keys.count\": \"{total} 个上游 key · {on} 个启用\",\n", + " \"ops.keys.count\": \"{total} upstream keys · {on} enabled\",\n", + " \"ops.keys.sub\": \"按厂商聚合 · 启用状态与总上游 key 数\",\n", + " \"ops.keys.sub\": \"Aggregated by provider · enabled vs. total upstream keys\",\n", + ]; + for a in anchors { + assert_eq!(pack.matches(a).count(), 1, "变异体④锚点不唯一:{a:?}"); + } + pack.replace( + " \"ops.keys.title\": \"上游 key 状态\",\n", + " \"ops.keys.title\": \"上架 key 状态\",\n", + ) + .replace( + " \"ops.keys.title\": \"Upstream key status\",\n", + " \"ops.keys.title\": \"Listed key status\",\n", + ) + .replace( + " \"ops.keys.count\": \"{total} 个上游 key · {on} 个启用\",\n", + " \"ops.keys.count\": \"{total} 个上架 key · {on} 个启用\",\n", + ) + .replace( + " \"ops.keys.count\": \"{total} upstream keys · {on} enabled\",\n", + " \"ops.keys.count\": \"{total} listed keys · {on} enabled\",\n", + ) + .replace( + " \"ops.keys.sub\": \"按厂商聚合 · 启用状态与总上游 key 数\",\n", + " \"ops.keys.sub\": \"按厂商聚合 · 启用状态与总上架 key 数\",\n", + ) + .replace( + " \"ops.keys.sub\": \"Aggregated by provider · enabled vs. total upstream keys\",\n", + " \"ops.keys.sub\": \"Aggregated by provider · enabled vs. total listed keys\",\n", + ) +} diff --git a/ui/README.md b/ui/README.md index 89a1351..85571d6 100644 --- a/ui/README.md +++ b/ui/README.md @@ -677,6 +677,34 @@ try { const w = await api.get("/api/wallet"); if (w) D.USER.balance = w.balance; - **CI 覆盖**:`src/state_gate.rs::the_ops_members_balance_cell_is_the_half_its_caption_names`(三规则:① 取值表达式按**标识符 token** 同时读到 `balance` 与 `gift_balance` ② 两包列头须落在**可花族**(标记由 `wallet.balance` 的包值派生)③ 反向:`admin.emp.col.perm` 仍只读 `balance`)。 - ⚠️ **射程**:门禁是**词法**的 —— 它证明取值**读到**两个字段、名字落在可花族,**不证明算术是 `+`**;行为由仪器 `c2173_probe.js` 钉(19 腿;甲/乙两棵合法树全绿,三条「半个答案 / 化妆 / 自相矛盾」被拒)。 +## 运营「上游 key 状态」卡的行计数用**这张卡自己的名词**(C2174) + +- **一个词一个数**:`/api/ops/runtime` 的 `key_health` 每行是 `{provider,total,on,off}`,其中 + `total` 是 `COUNT(*)`(**全部状态**:`on` / `paused` / `off`=软删/彻底下架)。而产品把 + 「上架」严格绑给 `status='on'` —— 同屏正上方那张卡 `ops.stats.keys`(zh「上架 key 数」) + 数的是 `WHERE status='on'`,共享页也把「上架中」绑给 `status==='on'`、把含非 `on` 行的计数 + 叫「历史」(`cnt.hist`)。于是这一行若把 `total` 称作「上架 key」,同一个词在同一个视口里 + 就带了两个数(6 行/5 启用 vs 上面那张卡的 5)。 +- **正确称谓在卡片自己身上**:这张卡的**标题**(`ops.keys.title` zh「上游 key 状态」)与**空态** + (`ops.keys.empty`「尚未上架任何**上游 key**」)称这批行为「上游 key」;设计基线原型写的是 + `k.n + " 个上游 key"`。漂移发生在改版那一次(`09e4121` / #157 新造这组键时把「上游」改称 + 「上架」),数据源(`COUNT(*)`,`85982e8` / #80)一字未改。 +- **修法**:**改词不改数** —— `ops.keys.count` 与 `ops.keys.sub` 两包四串换回「上游 key / + upstream key」族 + `ui/index.html` 的 `data-i18n="ops.keys.sub"` 静态 fallback 同步 + + `i18n.js?v=` 自增。**零新 i18n 键、零后端改动**。⛔ 不许把 `total` 改成只数 `on` + (同一行的 pill「N 个停用 / 全部停用」会结构性恒绿=把承诺删掉),也不许把同屏那张 + 「上架 key 数」改宽(它的值确实只有 `on`)。 +- **CI 覆盖**:`src/state_gate.rs::the_ops_key_rows_are_named_the_way_their_own_card_names_them` + (R1 名词=`ops.keys.title` 去掉末 token,**门禁里零手写词**;R2 计数行两包都含该名词; + R3 同卡副标题也必须含(它给的是同一个数的另一个称呼);R4 前置:两包三串齐、名词确实变短、 + 两包 `ops.keys.*` 键集相等、三份语料各带这张卡的一部分;R5 阳性对照:两个占位符仍在)。 + 配 `the_r2174_scanners_have_teeth`(名词派生 / 大小写 / **注释里的键名不算键**)。 +- ⚠️ **射程与一条声明为盲的腿**:门禁是**词法**的 —— 它证「行计数的名词与卡片自己的标题同源」, + **不证**屏幕上那一刻的文本(那半归 jsdom 探针),也**不证** `total` 的算法该不该含 `off` 行 + (那由 pill 的结构需求固定)。**盲区(由 `the_r2174_rules_have_teeth` 的变异体④实测)**: + 把**称谓整体**改成与数据不符的那一族(标题 + 计数行 + 副标题一起改、彼此自洽)门禁**全绿** —— + 挡它的是设计基线(原型写的就是「上游 key」)与运行期探针,不是这条词法规则。 + ## 表单控件的 property 式 `disabled`:必须有一个在**回收路径**上的清除点(R139) diff --git a/ui/index.html b/ui/index.html index 7743375..9a1ed27 100644 --- a/ui/index.html +++ b/ui/index.html @@ -784,7 +784,7 @@

今日调用量(按小时)

上游 key 状态

-

按厂商聚合 · 启用状态与总上架数

+

按厂商聚合 · 启用状态与总上游 key 数

@@ -861,7 +861,7 @@

使用模型

- + diff --git a/ui/js/i18n.js b/ui/js/i18n.js index cfc8315..941599a 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -660,9 +660,13 @@ "ops.hours.title": "今日调用量(按小时)", "ops.hours.sub": "按小时用量记录 · 服务端 UTC 时区", "ops.hours.empty": "今日暂无调用记录", + // 这一卡数的是**全部状态**的上游 key(含 paused / off),所以行计数必须用卡片自己的称谓 + // 「上游 key」;「上架 key」= status='on'(同屏「上架 key 数」那张卡与共享页「上架中」的画)。 + // 静态门禁 state_gate::the_ops_key_rows_are_named_the_way_their_own_card_names_them 从 + // ops.keys.title 派生名词,标题 / count / sub 三处必须同源。 "ops.keys.title": "上游 key 状态", - "ops.keys.sub": "按厂商聚合 · 启用状态与总上架数", - "ops.keys.count": "{total} 个上架 key · {on} 个启用", + "ops.keys.sub": "按厂商聚合 · 启用状态与总上游 key 数", + "ops.keys.count": "{total} 个上游 key · {on} 个启用", "ops.keys.allOn": "全部启用", "ops.keys.someOff": "{n} 个停用", "ops.keys.allOff": "全部停用", @@ -1476,9 +1480,12 @@ "ops.hours.title": "Calls today (by hour)", "ops.hours.sub": "Hourly usage records · server UTC", "ops.hours.empty": "No calls recorded today", + // The row counts upstream keys of **every** status (including paused / off), so it must use the + // card's own noun ("upstream key"); "listed key" means status = 'on' — the wording of the + // "Active keys" stat card and of the sharing page's "Listed" pill. "ops.keys.title": "Upstream key status", - "ops.keys.sub": "Aggregated by provider · enabled vs. total listings", - "ops.keys.count": "{total} listed keys · {on} enabled", + "ops.keys.sub": "Aggregated by provider · enabled vs. total upstream keys", + "ops.keys.count": "{total} upstream keys · {on} enabled", "ops.keys.allOn": "All enabled", "ops.keys.someOff": "{n} disabled", "ops.keys.allOff": "All disabled",