From 3d0331d2583699d356c85caf50fd469b5d1ca633 Mon Sep 17 00:00:00 2001 From: argszero Date: Tue, 22 Sep 2026 07:30:14 +0800 Subject: [PATCH] fix(ui): derive the trend chart's grain from the request window --- src/state_gate.rs | 473 ++++++++++++++++++++++++++++++++++++++++++++++ ui/README.md | 19 ++ ui/index.html | 2 +- ui/js/app.js | 29 ++- 4 files changed, 506 insertions(+), 17 deletions(-) diff --git a/src/state_gate.rs b/src/state_gate.rs index 16307b6..9b77abf 100644 --- a/src/state_gate.rs +++ b/src/state_gate.rs @@ -3156,10 +3156,483 @@ impl R158Reading { } } +// ── R165:趋势图的聚合粒度必须由**实际发出的请求窗口**决定,不由控件值决定 ──────────────────── +// +// `txTrendBucket()` 按 `#tx-range` 的**控件值**分支(24h/7d/30d/custom/else),而窗口真源是 +// `txRangeParams()`(它同时拼出列表与趋势两份请求串)。两个产生**逐字相同的请求**的控件状态 +// ——「全部时间」与「自定义 + 两个输入框都空」(后者 `txRangeParams()` 返回 `""`,与 all 同)—— +// 在旧实现里得到**不同粒度**:`all` ⇒ week(按窗口:无界 ⇒ 最粗),`custom` ⇒ hour +// (按输入框:空 ⇒ 跨度 0 天)。于是图按 `MM-DD HH:00` 自称,而 `TX_TREND_MAX_COLS = 40` +// 又把 x 轴锚在右端 ⇒ 只画最近约 40 小时,永不与查询对账。 +// +// 本门禁钉的是**形状**:粒度函数体内不许再出现控件选项值、必须委派给 `txRangeParams()`、 +// 返回的粒度字面量恰为 `{hour, day, week}`、且窗口真源与控件**都还在**。 +// 「屏幕上的轴与请求窗口真的对不对得上」由 jsdom 探针 `r165_probe.js` 证。 + +/// `#tx-range` 这个 ` null),", + "(txRange === \"custom\" ? Promise.resolve(null) \ + : api.get(tq).catch(() => null)),", + ); + assert_ne!(tree_hide, APP_JS, "`m_hide` 没有落地(锚点漂移了)"); + + // 先证明这些树互不相同,否则「判词不同」可能只是同一棵树的两张脸。 + assert_ne!(tree_fix, tree_day); + assert_ne!(APP_JS, tree_fix); + assert_ne!(tree_hour_start, tree_hide); + assert_ne!(tree_hour_start, APP_JS); + assert_ne!(tree_hide, APP_JS); + + // 绝对判词(全部打在自造树上,与腿无关)。 + let tree_control = r165_variant_control(APP_JS); + let tree_nocall = r165_variant_nocall(APP_JS); + let tree_grain = r165_variant_grain(APP_JS); + assert_ne!(tree_control, tree_fix); + assert_ne!(tree_nocall, tree_fix); + assert_ne!(tree_grain, tree_fix); + let declared = [ + ("fix (spliced)", tree_fix.as_str(), fixed), + // 形状同类、取值不同:门禁接受,探针 K3 拒掉 —— 本门禁的射程边界,不是漏。 + ("m_day", tree_day.as_str(), fixed), + ( + "m_control", + tree_control.as_str(), + (false, true, true, true), + ), + ("m_nocall", tree_nocall.as_str(), (true, false, true, true)), + ("m_grain", tree_grain.as_str(), (true, true, false, true)), + ]; + let mut reports = Vec::new(); + for (name, app, expected) in declared { + let read = r165_read(app, INDEX_HTML); + reports.push(format!("{name}: {}", read.report())); + assert_eq!( + read.verdicts(), + expected, + "变体 `{name}` 的判词与声明不符(声明 {expected:?})—— 门禁的鉴别力变了" + ); + assert_eq!( + read.options, + r165_expected_options(), + "变体 `{name}` 的选项集不该变:{:?}", + read.options + ); + } + + // 与腿无关的两条关系。 + let real = r165_read(APP_JS, INDEX_HTML); + assert!( + real.verdicts() == unfixed || real.verdicts() == fixed, + "真树的形状既不是「未修」也不是「已修」—— 门禁不认识它了:{}", + real.report() + ); + for (name, tree) in [("m_hour_start", &tree_hour_start), ("m_hide", &tree_hide)] { + let read = r165_read(tree, INDEX_HTML); + reports.push(format!("{name}: {}", read.report())); + assert_eq!( + read.verdicts(), + real.verdicts(), + "`{name}` 改的是别处,粒度函数的形状没动 ⇒ 必须与真树同判" + ); + } + // ⚠️ 落地轮已做(#314:默认期望必须钉在**显式基线**上):真树修好之后,「未修 ⇒ 轴测试红」 + // 这条期望会**静默反转** —— 本文件不再在任何腿上观察它,而是由落地轮的编译器门禁在 + // 一棵**显式基线树**(`git archive <落地前的 HEAD>` 物化,同 R158 / #588)上观察, + // 否则「基线腿」在已修的真树上会变成同义反复。表的声明与本节脚注一起改,否则各说一套。 + // (仪器住在仓外,故此处不写文件名 —— #606。) + println!("real tree: {}\n{}", real.report(), reports.join("\n")); + } + + /// 修复体文本**逐字**来自编辑表 E1;`r165_compile_gate.py` 另外断言它与 E1 的产物 + /// 是子串关系(跨制品对账)。这里只钉「常量非空且不是占位符」。 + #[test] + fn the_r165_fixed_body_is_the_edit_sheet_text() { + assert!( + R165_FIXED_BODY.contains(R165_WINDOW_SOURCE), + "修复体必须委派给窗口真源:{R165_FIXED_BODY:?}" + ); + assert!( + R165_FIXED_BODY.contains("return \"week\";"), + "修复体的「无下界 ⇒ 最粗粒度」那一支不见了" + ); + assert!( + !R165_FIXED_BODY.contains("txRange === "), + "修复体里还留着控件值比较 —— 那不是修复" + ); + } + /// R158:交易视图里**同一份 token 数量只许有一种拼写**,且导出写精确值。 /// /// 四条规则各有独立的牙(`the_r158_rules_separate_the_variants` 逐条证): diff --git a/ui/README.md b/ui/README.md index 44436c7..591d0a6 100644 --- a/ui/README.md +++ b/ui/README.md @@ -831,6 +831,25 @@ function txQuerySig() { // 只此一处定义「载荷是 (`app.js` 对某 id 做 `innerHTML =`)同样是死的。今日实测为 **0 处**,故未建门禁 —— 该判定的词法近似(按 id 找 `innerHTML =`)比本条脆弱,留待需要时再收。 +## 趋势图的聚合粒度:由**实际请求窗口**决定,不由控件值决定(R165) + +**契约**:`txTrendBucket()` 的返回值必须是**真正发出去的请求窗口**的函数。窗口真源只有一个 —— +`txRangeParams()`(`ui/js/app.js`),它同时也是请求串的构造者。因此 `txTrendBucket()` 读**它**, +而不是再解释一遍 `#tx-range` 的选项值。 + +**为什么**:`all`(全部时间)与「自定义 + 两个输入框都空」拼出的**列表请求逐字相同**(都无边界 —— +`txRangeParams()` 在 `custom` 且两个输入框皆空时返回 `""`),而旧实现按控件值分别给出 `week` / `hour`: +原来那张「控件值 → 粒度」的表里,`custom` 分支把跨度**从输入框**算(空 ⇒ 跨度 0 天 ⇒ `hour`), +`all` 分支按窗口(无界 ⇒ `week`)。⇒ 同一份数据、同一个请求,两条粒度。 +另一副面孔:`TX_TREND_MAX_COLS = 40` 把 x 轴**锚在右端**,小时粒度下最多画**最近约 40 小时**, +却按 `MM-DD HH:00` 标尺自称。 + +**射程**(#341):门禁 `state_gate::the_trend_grain_derives_from_the_query_not_from_the_control` +是**词法**的 —— 它证明该函数体内**没有** `#tx-range` 的选项值字面量、**调用了** `txRangeParams`, +并给返回的粒度字面量划界(`{hour, day, week}`)。它**不**证明阈值(3.5 / 60 天)取得对, +也不证明 `URLSearchParams` 解出的跨度与请求里的 `now` 逐毫秒一致 —— 那些由 jsdom 探针 +在它所跑的那棵树上覆盖。 + ## 共享行的「动作」与「结局文案」必须由同一条目给出(C2153) 共享 key 有**三个**状态,三个都可达:`PATCH /api/sharings/:id` 接受 `on` / `paused` / `off` diff --git a/ui/index.html b/ui/index.html index ec63e82..57c7108 100644 --- a/ui/index.html +++ b/ui/index.html @@ -855,6 +855,6 @@

使用模型

- + diff --git a/ui/js/app.js b/ui/js/app.js index 9ea3345..8ede994 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -1639,23 +1639,20 @@ return bucket === "hour" ? md + " " + p(d.getHours()) + ":00" : md; } - // 趋势聚合粒度:跟随外部时间段筛选(24h→小时;≤3.5 天→小时;≤60 天→天;其余→周) + // 趋势聚合粒度:**实际请求窗口**的函数,不是控件值的函数(R165)。 + // 唯一窗口真源就是请求串本身(`txRangeParams()`):无 `start` ⇒ 下界不可知 ⇒ 与「全部时间」 + // 同口径(week);有 `start` ⇒ 按 `end`(缺省 = now)算跨度,阈值沿用旧实现(≤3.5d / ≤60d)。 function txTrendBucket() { - if (txRange === "24h") return "hour"; - if (txRange === "7d") return "day"; - if (txRange === "30d") return "day"; - if (txRange === "custom") { - const s = txCustomStart ? new Date(txCustomStart) : null; - const e = txCustomEnd ? new Date(txCustomEnd) : null; - const now = new Date(); - const from = (s && !isNaN(s.getTime())) ? s : ((e && !isNaN(e.getTime())) ? new Date(e.getTime() - 30 * 86400000) : now); - const to = (e && !isNaN(e.getTime())) ? e : now; - const days = (to.getTime() - from.getTime()) / 86400000; - if (days <= 3.5) return "hour"; - if (days <= 60) return "day"; - return "week"; - } - return "week"; // all:跨度过大按周聚合 + const p = new URLSearchParams(txRangeParams()); + const s = p.get("start"); + if (!s) return "week"; // 全部时间 / 自定义但起点为空:跨度不可知 + const from = new Date(s); + const e = p.get("end"); + const to = e ? new Date(e) : new Date(); + const days = (to.getTime() - from.getTime()) / 86400000; + if (days <= 3.5) return "hour"; + if (days <= 60) return "day"; + return "week"; } function renderTransactions() {