三件事都由用户实测反馈,前两件先复现再动手。
一、日期框:敲 20260606 变成 202606/06(用户要「永远解决」)
- **实测复现**:写了个真实按键工具(CDP Input.dispatchKeyEvent 每字符带 text;
技能里的 `--press` 只给 Enter 带 text,数字敲不进去)。在 /stats 的「起始日期」上敲 20260606:
敲之前 `INPUT|date|`、attached=false;敲完显示 `202606/06`、`el.value` 是**空**(非法)—— 截图为证。
- **根因**:筛选区的起止日期是原生 `<input type="date">`,而挂载代码只写了
`querySelectorAll('input[type=datetime-local]')` ⇒ **type=date 从没人管**。
Chrome 的年段允许超过 4 位数字(年份上限 275760),8 位被切成 年=202606 + 月=06。
原生分段状态读不到、`setSelectionRange` 对 date 类输入直接抛 ⇒ 修不了,只能换控件
(用户也说了「如果当前控件无法实现就换别的」)。
- **做法**:`datetime-input.js` 加「只日期」模式(`SPEC.date`:年/月/日三段,输出 `Y-m-d`),
并提供**统一入口 `attachAll(root)`** 按 `el.type` 分派;`attach` 里先把控件换成 `text`
(引导会写 `2026-06-` 这种半成品,而原生控件对非法值一律置空)。
`datetime-input.js` 改为**无条件引入**(公共页筛选区也要用;flatpickr 仍按需加载 ——
没有它时只挂输入引导、不给日历,省 60 多 KB),挂载在 body 末尾**立刻**跑一次
(别只等 DOMContentLoaded,否则会先露出原生框再变文本框)。三个后台页改用 `attachAll`,
顺带覆盖行程筛选的 `mfrom/mto`(date 类走 `enableTime:false`,实测无时间选择器)。
- **验证**:真实按键敲 20260606 → `value="2026-06-06"`;端到端 `from=2025-08-01&to=2025-08-31`
筛出 **186 次**,与月度图峰值月(186)完全吻合(排除「解析成空导致恒 0」)。
门禁 `TestDateInputsGoThroughAttachAll`:模板里再出现按 type 挑的选择器即红(做过变异验证);
JS 单测补 11 条逐键模拟(45/45)。
二、逐月平均气温图在桌面端很小
- 量出来的:10 个月固定 36px 宽,只占 1042px 里的 **396px**,右边 600 多像素全空。
- 窄屏保持「固定 36px + 横滚」(挤在一起月份标签会糊),宽屏改 `flex: 1 1 36px; max-width: 96px`
并把轨道从 84px 加到 130px、字号提到 `--fs-sm`。
三、类型分布与一天中的分布同行高度不齐
- grid 默认 `align-items: stretch`,把 256px 的「一天中的分布」拉到与 1411px 的「类型分布」一样高,
卡片内部留一大片空白。改成 `.grid { align-items: start }`。
回归:./scripts/verify.sh **PASS=349 FAIL=0 SKIP=0**;移动端 390 / 桌面 1440 溢出与改前一致。
⚠️ 两个验证纪律(都写进 details 三十二):
- 验收跑着的时候别并发跑测试/变异实验(两次踩到,其中一次变异正好被验收抓到,那轮结果作废)。
- **变异验证「内嵌文件」(templates/static)之后必须 `go clean -cache`**:`//go:embed` 的内容
在「改坏→还原」后,构建缓存会继续给出变异期间编译的那份 —— 实测同一个测试
`go test ./internal/web/` 绿、`go test ./...` 红,`go clean -testcache` 不管用。
114 lines
6.0 KiB
JavaScript
114 lines
6.0 KiB
JavaScript
/* parseDateStrict 的单元测试(node 直接跑,无依赖)。
|
|
* 用法:node internal/web/static/js/datetime-input.test.js
|
|
* 这里只测解析,不碰 DOM —— 解析是整个修复的核心,边界必须钉死。
|
|
*/
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
const src = fs.readFileSync(path.join(__dirname, 'datetime-input.js'), 'utf8');
|
|
const sandbox = { window: {} };
|
|
sandbox.window.document = undefined;
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(src, sandbox);
|
|
const api = sandbox.window.rondDatetime;
|
|
|
|
let pass = 0, fail = 0;
|
|
function eq(got, want, label) {
|
|
const g = JSON.stringify(got), w = JSON.stringify(want);
|
|
if (g === w) { pass++; console.log(' ok ' + label); }
|
|
else { fail++; console.log(' FAIL ' + label + '\n got ' + g + '\n want ' + w); }
|
|
}
|
|
function parse(s) {
|
|
const d = api.parseDate(s);
|
|
if (d === false || d === null || d === undefined) return null;
|
|
if (isNaN(d.getTime())) return 'INVALID_DATE';
|
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
|
String(d.getDate()).padStart(2, '0') + 'T' + String(d.getHours()).padStart(2, '0') +
|
|
':' + String(d.getMinutes()).padStart(2, '0');
|
|
}
|
|
|
|
console.log('=== 必须接受的写法 ===');
|
|
eq(parse('2026'), '2026-01-01T00:00', '2026(只给年)');
|
|
eq(parse('2026/6/6'), '2026-06-06T00:00', '2026/6/6 斜杠');
|
|
eq(parse('2026-6-6'), '2026-06-06T00:00', '2026-6-6 单数字月日');
|
|
eq(parse('2026-06-06'), '2026-06-06T00:00', '2026-06-06 完整');
|
|
eq(parse('20260606'), '2026-06-06T00:00', '20260606 八位纯数字');
|
|
eq(parse('2026-06-06T09:30'),'2026-06-06T09:30', '标准格式');
|
|
eq(parse('2026/6/6 09:30'), '2026-06-06T09:30', '斜杠+空格+时间');
|
|
eq(parse('2026-06-06 09:30'),'2026-06-06T09:30', '横杠+空格+时间');
|
|
eq(parse('2026-6-6 9:30'), '2026-06-06T09:30', '时间也用单数字');
|
|
eq(parse('202606'), '2026-06-01T00:00', '202606 六位(年月)');
|
|
eq(parse('2026-06'), '2026-06-01T00:00', '2026-06 年月');
|
|
eq(parse(' 2026/6/6 '), '2026-06-06T00:00', '前后空白');
|
|
eq(parse('2026.6.6'), '2026-06-06T00:00', '点号分隔');
|
|
eq(parse('20260606T0930'), '2026-06-06T09:30', '十二位纯数字');
|
|
eq(parse('202606060930'), '2026-06-06T09:30', '十二位纯数字无 T');
|
|
|
|
console.log('\n=== 必须拒绝的脏输入(flatpickr 原版会静默错解) ===');
|
|
eq(parse('202606'), '2026-06-01T00:00', '202606 按位取仍是合法年月(歧义但唯一解)');
|
|
eq(parse('2026060630'), null, '2026060630 十位:日=30 但月推断不了 → 拒');
|
|
eq(parse('2026/13/6'), null, '月 13 非法');
|
|
eq(parse('2026/2/30'), null, '2 月 30 日不存在');
|
|
eq(parse('2026/4/31'), null, '4 月 31 日不存在');
|
|
eq(parse('2026/00/6'), null, '月 0');
|
|
eq(parse('2026/6/0'), null, '日 0');
|
|
eq(parse('2026666'), null, '2026666 七位:原版解成 2031-06 → 必须拒');
|
|
eq(parse('20260606456'), null, '超长脏串');
|
|
eq(parse('abc'), null, '非数字');
|
|
eq(parse(''), null, '空串');
|
|
eq(parse(' '), null, '纯空白');
|
|
eq(parse('2026-6-6x'), null, '尾部残留字符');
|
|
eq(parse('2026年6月6日'), null, '中文写法(服务端不收)');
|
|
eq(parse('99'), null, '两位数(补录也只可能是四位年)');
|
|
eq(parse('2026-6-6T25:00'), null, '小时 25 非法');
|
|
eq(parse('2026-6-6T09:60'), null, '分钟 60 非法');
|
|
eq(parse('2026/6/6/9'), null, '多一段');
|
|
|
|
console.log('\n=== round-trip:解析结果必须能原样喂回 formatDate ===');
|
|
(function () {
|
|
const cases = ['2026-06-06T09:30', '2026-01-01T00:00', '2026-12-31T23:55'];
|
|
let bad = [];
|
|
for (const c of cases) {
|
|
const d = api.parseDate(c);
|
|
if (!d) { bad.push(c + ' 解析失败'); continue; }
|
|
const out = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' +
|
|
String(d.getDate()).padStart(2,'0') + 'T' + String(d.getHours()).padStart(2,'0') +
|
|
':' + String(d.getMinutes()).padStart(2,'0');
|
|
if (out !== c) bad.push(c + ' → ' + out);
|
|
}
|
|
eq(bad, [], 'round-trip 一致');
|
|
})();
|
|
|
|
console.log('\n=== 手输引导 layoutTo:逐键模拟(这才是这次 bug 的现场) ===');
|
|
(function () {
|
|
const D = api.SPEC.date, T = api.SPEC.dt;
|
|
const lay = (spec, raw) => { const r = api.layoutTo(spec, raw); return r ? r.value : null; };
|
|
// 逐键模拟:每敲一位,就把「当前框里的值 + 新键」再喂回去 —— 与浏览器里 input 事件一致
|
|
const type = (spec, keys) => {
|
|
let v = '';
|
|
for (const k of keys) { const n = lay(spec, v + k); v = (n === null) ? (v + k) : n; }
|
|
return v;
|
|
};
|
|
|
|
// ⚠️ 回归:2026-10-09 用户报的就是这个 —— 原生的 type=date 里敲 20260606 变成了 202606/06
|
|
eq(type(D, '20260606'), '2026-06-06', '只日期模式逐键敲 20260606 → 2026-06-06');
|
|
eq(lay(D, '20260606'), '2026-06-06', '只日期模式一次性粘 8 位 → 2026-06-06(没有时间尾巴)');
|
|
eq(type(D, '2026'), '2026-', '只日期:敲完年份就补横杠、光标进月份段');
|
|
eq(type(D, '202606'), '2026-06-', '只日期:敲完月份就补横杠、光标进日段');
|
|
eq(type(D, '20260606'), '2026-06-06', '只日期:敲完日段不再补尾巴');
|
|
|
|
// 重构后带时间那条必须一字不变(原来 34 条断言的语义)
|
|
eq(type(T, '202606060930'), '2026-06-06T09:30', '带时间模式逐键敲 12 位 → 2026-06-06T09:30');
|
|
eq(lay(T, '20260606'), '2026-06-06T', '带时间模式一次性粘 8 位 → 后面还留个 T 等时分');
|
|
|
|
// 越界一律「不接管」,原样留给解析器报错
|
|
eq(lay(D, '20261306'), null, '只日期:月 13 → 不接管');
|
|
eq(lay(D, '20260632'), null, '只日期:日 32 → 不接管');
|
|
eq(lay(D, '20260230'), null, '只日期:2026-02-30 不存在 → 不接管');
|
|
eq(lay(D, '202606061'), null, '只日期:9 位没有第 4 段可放 → 不接管');
|
|
})();
|
|
|
|
console.log('\n=== 汇总 ===');
|
|
console.log(' pass=' + pass + ' fail=' + fail);
|
|
process.exit(fail === 0 ? 0 : 1); |