dsh-activity-pane 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scripts/check.mjs CHANGED
@@ -33,7 +33,9 @@ import {
33
33
  openTurnStartMissing,
34
34
  escapeCssString,
35
35
  firstPhysicalLine,
36
+ fmtAbsoluteDateTime,
36
37
  fmtElapsedMs,
38
+ fmtRelativeAge,
37
39
  fmtTokens,
38
40
  isActiveRow,
39
41
  shouldSubscribeToSession,
@@ -754,11 +756,11 @@ const drainGapIdle = {
754
756
  current: null,
755
757
  };
756
758
  assert.equal(
757
- buildRecent(drainGapIdle, [], 2000, undefined, {}, [], null, new Set(["root"])).length,
759
+ buildRecent(drainGapIdle, [], 2000, {}, [], null, new Set(["root"])).length,
758
760
  0,
759
761
  "耗尽空窗内委托周期会话不入最近历史(R-01-010 分区不变量)",
760
762
  );
761
- assert.equal(buildRecent(drainGapIdle, [], 2000, undefined, {}, [], null).length, 1, "委托周期结束后才入最近历史");
763
+ assert.equal(buildRecent(drainGapIdle, [], 2000, {}, [], null).length, 1, "委托周期结束后才入最近历史");
762
764
  // delegationActive:耗尽宽限内视为委托周期(空窗保持),超时退出。
763
765
  assert.equal(delegationActive({ mode: "delegating", anchor: 1000, turnStart: 9000, drainedAt: null }, 50000), true, "委托周期中视为活动");
764
766
  assert.equal(delegationActive({ mode: "delegating", anchor: 1000, turnStart: 9000, drainedAt: 30000 }, 31000), true, "耗尽宽限内视为活动(空窗保持运行呈现)");
@@ -897,6 +899,35 @@ assert.equal(fmtElapsedMs(193_000), "3m13s", "时长分秒格式");
897
899
  assert.equal(fmtElapsedMs(NaN), "", "NaN 时长归一为空");
898
900
  assert.equal(fmtElapsedMs(Infinity), "", "Infinity 时长归一为空");
899
901
  assert.equal(fmtElapsedMs(-1), "", "负时长归一为空");
902
+ // R-01-013/AC-05:历史卡同时显示本地绝对日期时间与相对年龄,异常输入不制造虚假时间。
903
+ const relativeMinute = 60_000;
904
+ const relativeHour = 60 * relativeMinute;
905
+ const relativeDay = 24 * relativeHour;
906
+ assert.equal(fmtRelativeAge(0), "刚刚", "小于一分钟显示刚刚");
907
+ assert.equal(fmtRelativeAge(2 * relativeMinute + 1), "2分钟前", "分钟级相对时间");
908
+ assert.equal(fmtRelativeAge(3 * relativeHour + 1), "3小时前", "小时级相对时间");
909
+ assert.equal(fmtRelativeAge(2 * relativeDay + 1), "2天前", "天级相对时间");
910
+ assert.equal(fmtRelativeAge(2 * 7 * relativeDay + 1), "2周前", "周级相对时间");
911
+ assert.equal(fmtRelativeAge(2 * 30 * relativeDay + 1), "2个月前", "月级相对时间");
912
+ assert.equal(fmtRelativeAge(2 * 365 * relativeDay + 1), "2年前", "年级相对时间");
913
+ assert.equal(fmtRelativeAge(NaN), "", "NaN 相对时间归一为空");
914
+ assert.equal(fmtRelativeAge(Infinity), "", "Infinity 相对时间归一为空");
915
+ assert.equal(fmtRelativeAge(-1), "", "负相对时间归一为空");
916
+ const sameYearActivity = new Date(2026, 7, 31, 14, 32);
917
+ const sameYearNow = new Date(2026, 8, 1, 9, 0);
918
+ assert.equal(
919
+ fmtAbsoluteDateTime(sameYearActivity.getTime(), sameYearNow.getTime()),
920
+ `${sameYearActivity.toLocaleDateString([], { month: "2-digit", day: "2-digit" })} ${sameYearActivity.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`,
921
+ "当前年份绝对时间省略年份但保留月日时分",
922
+ );
923
+ const priorYearActivity = new Date(2025, 11, 31, 23, 50);
924
+ assert.equal(
925
+ fmtAbsoluteDateTime(priorYearActivity.getTime(), sameYearNow.getTime()),
926
+ `${priorYearActivity.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" })} ${priorYearActivity.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`,
927
+ "跨年份绝对时间补充年份",
928
+ );
929
+ assert.equal(fmtAbsoluteDateTime(null, sameYearNow.getTime()), "", "缺失绝对时间归一为空");
930
+ assert.equal(fmtAbsoluteDateTime(NaN, sameYearNow.getTime()), "", "非法绝对时间归一为空");
900
931
 
901
932
  // R-01-012/AC-01
902
933
  // R-01-012/AC-02
@@ -2069,7 +2100,8 @@ assert.notEqual(
2069
2100
  cardSignature(entries),
2070
2101
  "缓存命中率变化签名必变",
2071
2102
  );
2072
- // ---- R-01-010/AC-01 非活动且 24h 内→最近历史区 ----
2103
+ // ---- R-01-010/AC-01 非活动主会话→最近历史区(不设时间窗口) ----
2104
+ const RECENT_PAGE_SIZE = 10;
2073
2105
  const NOW = 2_000_000_000_000; // 固定时钟便于确定性断言
2074
2106
  // 注意:completed:true 的主会话是"待打开"的活动卡(在活动区),不属历史区;
2075
2107
  // 真实"最近历史"是已处理(running:false 且无未确认完成)的非活动会话;
@@ -2090,19 +2122,19 @@ const recentSnap = {
2090
2122
  const recentUncompleted = buildRecent(recentSnap, [], NOW);
2091
2123
  assert.deepEqual(
2092
2124
  recentUncompleted.map((e) => e.id),
2093
- ["sAwait", "sB"],
2094
- "completed 边沿标志不参与活动/历史判定;无完成登记时按历史窗口入历史区(C-030)",
2125
+ ["sAwait", "sB", "sOld"],
2126
+ "completed 边沿标志不参与活动/历史判定;无完成登记时所有非活动主会话均入历史区(C-030)",
2095
2127
  );
2096
2128
  // 完成登记存在时(未确认完成):待开 E 显示为完成提醒并排除出历史区(AC-03、R-01-010/AC-06)。
2097
2129
  const recentAcks = new Map([
2098
2130
  ["sAwait", { lastTurnEnd: NOW - 1_000, ackedAt: null }],
2099
2131
  ["sAwait2", { lastTurnEnd: NOW - 900, ackedAt: NOW - 2_000 }],
2100
2132
  ]);
2101
- const recent = buildRecent(recentSnap, [], NOW, undefined, {}, [], recentAcks);
2133
+ const recent = buildRecent(recentSnap, [], NOW, {}, [], recentAcks);
2102
2134
  assert.deepEqual(
2103
2135
  recent.map((e) => e.id),
2104
- ["sB"],
2105
- "完成登记表中未确认完成的会话留在活动区、排除出历史区;已确认的不再排除(仍按窗口入区)",
2136
+ ["sB", "sOld"],
2137
+ "完成登记表中未确认完成的会话留在活动区、排除出历史区;已确认的不再排除",
2106
2138
  );
2107
2139
  assert.deepEqual(
2108
2140
  [recent[0].model, recent[0].reasoning, recent[0].userPreview, recent[0].agentPreview],
@@ -2117,7 +2149,7 @@ assert.deepEqual(messagePreviews({ history: [] }), { userPreview: "", agentPrevi
2117
2149
  // R-01-013/AC-04
2118
2150
  // R-01-013/AC-05
2119
2151
  // R-01-013/AC-06
2120
- const recentWithPreviews = buildRecent(recentSnap, [], NOW, undefined, {
2152
+ const recentWithPreviews = buildRecent(recentSnap, [], NOW, {
2121
2153
  sB: {
2122
2154
  model: { model: "Model M", reasoning: "High" },
2123
2155
  previews: { userPreview: "用户首行", agentPreview: "回复首行" },
@@ -2169,7 +2201,7 @@ const viaRunToIdle = buildEntries(
2169
2201
  [],
2170
2202
  );
2171
2203
  assert.deepEqual(viaRunToIdle.map((e) => e.id), ["sB"], "运行中会话在活动区");
2172
- const recentAfterIdle = buildRecent(recentSnap, [], NOW, undefined, {}, [], recentAcks);
2204
+ const recentAfterIdle = buildRecent(recentSnap, [], NOW, {}, [], recentAcks);
2173
2205
  assert.ok(recentAfterIdle.some((e) => e.id === "sB"), "转为非活动后进入历史区");
2174
2206
 
2175
2207
  // ---- R-01-010/AC-03 历史区按最后活动时间从新到旧 ----
@@ -2192,6 +2224,52 @@ assert.deepEqual(
2192
2224
  "历史区按最后活动时间倒序",
2193
2225
  );
2194
2226
 
2227
+ // ---- R-01-019/AC-01~AC-03 历史候选不再截断,按 10 条分批呈现 ----
2228
+ const manyRecentIds = Array.from({ length: RECENT_PAGE_SIZE * 2 + 1 }, (_, index) => `page-${index}`);
2229
+ const manyRecent = buildRecent(
2230
+ {
2231
+ ids: manyRecentIds,
2232
+ byId: Object.fromEntries(manyRecentIds.map((id, index) => [id, {
2233
+ id,
2234
+ displayTitle: id,
2235
+ running: false,
2236
+ updatedAt: NOW - index,
2237
+ }])),
2238
+ current: null,
2239
+ },
2240
+ [],
2241
+ NOW,
2242
+ );
2243
+ assert.equal(manyRecent.length, manyRecentIds.length, "历史候选超过 20 条时不再被核心截断(R-01-019/AC-01)");
2244
+ assert.deepEqual(
2245
+ manyRecent.slice(0, RECENT_PAGE_SIZE).map((entry) => entry.id),
2246
+ manyRecentIds.slice(0, RECENT_PAGE_SIZE),
2247
+ "历史首批按最近顺序取 10 条(R-01-019/AC-01)",
2248
+ );
2249
+ assert.deepEqual(
2250
+ manyRecent.slice(RECENT_PAGE_SIZE, RECENT_PAGE_SIZE * 2).map((entry) => entry.id),
2251
+ manyRecentIds.slice(RECENT_PAGE_SIZE, RECENT_PAGE_SIZE * 2),
2252
+ "历史第二批紧接首批取后续 10 条(R-01-019/AC-02)",
2253
+ );
2254
+ assert.deepEqual(
2255
+ manyRecent.slice(RECENT_PAGE_SIZE * 2).map((entry) => entry.id),
2256
+ manyRecentIds.slice(RECENT_PAGE_SIZE * 2),
2257
+ "历史末批只取剩余会话且不会越界重复(R-01-019/AC-03)",
2258
+ );
2259
+ const tiedRecent = buildRecent(
2260
+ {
2261
+ ids: ["tie-z", "tie-a"],
2262
+ byId: {
2263
+ "tie-z": { id: "tie-z", displayTitle: "Z", running: false, updatedAt: NOW },
2264
+ "tie-a": { id: "tie-a", displayTitle: "A", running: false, updatedAt: NOW },
2265
+ },
2266
+ current: null,
2267
+ },
2268
+ [],
2269
+ NOW,
2270
+ );
2271
+ assert.deepEqual(tiedRecent.map((entry) => entry.id), ["tie-a", "tie-z"], "相同 activityAt 时以会话 id 稳定排序");
2272
+
2195
2273
  // ---- R-01-010/AC-08 最后活动时间:turn/end 提取与 max 归一 ----
2196
2274
  assert.equal(lastTurnEndFromEvents([]), null, "空 history 无回合结束时刻");
2197
2275
  assert.equal(
@@ -2326,7 +2404,7 @@ const refineSnap = {
2326
2404
  },
2327
2405
  current: null,
2328
2406
  };
2329
- const refined = buildRecent(refineSnap, [], NOW, undefined, {}, [], null, null, {
2407
+ const refined = buildRecent(refineSnap, [], NOW, {}, [], null, null, {
2330
2408
  sTurn: NOW - 2_000, // 回合结束晚于宿主时间 → 精化为回合结束时刻
2331
2409
  sPrompt: NOW - 3_000, // 回合结束早于宿主时间(消息未处理)→ 取较新者
2332
2410
  });
@@ -2343,19 +2421,18 @@ assert.deepEqual(
2343
2421
  [["sPrompt", NOW - 1_000], ["sNone", NOW - 5_000], ["sTurn", NOW - 10_000]],
2344
2422
  "回合结束时刻在途时先按宿主列表时间判定、排序与显示(R-01-010/AC-09)",
2345
2423
  );
2346
- // 窗口下界语义:宿主时间超窗的会话不读取历史,即使回合在窗内结束也不入区(C-020 明示缺口)。
2424
+ // 无时间窗口语义:宿主时间较早的非活动会话仍进入历史区,已知 turn/end 继续精化排序(C-068)。
2347
2425
  const crossWindow = buildRecent(
2348
2426
  { ids: ["sLong"], byId: { sLong: { id: "sLong", displayTitle: "长回合", running: false, updatedAt: NOW - 25 * 3_600_000 } }, current: null },
2349
2427
  [],
2350
2428
  NOW,
2351
- undefined,
2352
2429
  {},
2353
2430
  [],
2354
2431
  null,
2355
2432
  null,
2356
2433
  { sLong: NOW - 1_000 },
2357
2434
  );
2358
- assert.equal(crossWindow.length, 0, "宿主时间超窗的会话不入历史区(跨窗长回合缺口,C-020)");
2435
+ assert.deepEqual(crossWindow.map((entry) => [entry.id, entry.activityAt]), [["sLong", NOW - 1_000]], "较早宿主时间的非活动会话仍进入历史区(C-068)");
2359
2436
 
2360
2437
  // ---- R-01-002/AC-03、AC-05、AC-10~AC-12、R-01-010/AC-06 完成确认:未确认完成提醒保留活动卡;
2361
2438
  // 显式确认(按钮)或新回合隐式更替后解除;打开/切走/刷新不解除(C-030)----
@@ -2384,7 +2461,7 @@ assert.deepEqual(
2384
2461
  "完成提醒计入徽标等待分子但不计阻塞(R-01-002/AC-06)",
2385
2462
  );
2386
2463
  assert.deepEqual(
2387
- buildRecent(holdSnap, [], NOW, undefined, {}, [], acks(1000)).map((e) => e.id),
2464
+ buildRecent(holdSnap, [], NOW, {}, [], acks(1000)).map((e) => e.id),
2388
2465
  [],
2389
2466
  "未确认完成提醒不入历史区(分区不变量)",
2390
2467
  );
@@ -2399,7 +2476,7 @@ assert.deepEqual(
2399
2476
  "确认后完成提醒解除、退出活动区",
2400
2477
  );
2401
2478
  assert.deepEqual(
2402
- buildRecent(holdSnap, [], NOW, undefined, {}, [], ackedMap).map((e) => e.id),
2479
+ buildRecent(holdSnap, [], NOW, {}, [], ackedMap).map((e) => e.id),
2403
2480
  ["sB"],
2404
2481
  "确认后会话进入历史区",
2405
2482
  );
@@ -2457,7 +2534,7 @@ assert.deepEqual(
2457
2534
  "错误提醒计入徽标等待分子但不计阻塞(R-01-002/AC-06)",
2458
2535
  );
2459
2536
  assert.deepEqual(
2460
- buildRecent(holdSnap, [], NOW, undefined, {}, [], errAcks("error", "boom")).map((e) => e.id),
2537
+ buildRecent(holdSnap, [], NOW, {}, [], errAcks("error", "boom")).map((e) => e.id),
2461
2538
  [],
2462
2539
  "错误提醒中会话不入历史区(分区不变量,R-01-010/AC-06)",
2463
2540
  );
@@ -2503,7 +2580,7 @@ assert.deepEqual(
2503
2580
  ["sB"],
2504
2581
  "上一帧活动区 id 离开活动区且出现于历史区判定为迁移",
2505
2582
  );
2506
- assert.deepEqual(movedToRecentIds(new Set(["sB"]), [], []), [], "彻底消失(归档/滑出历史窗口)不判定为迁移");
2583
+ assert.deepEqual(movedToRecentIds(new Set(["sB"]), [], []), [], "彻底消失(归档/不再被会话服务列出)不判定为迁移");
2507
2584
  assert.deepEqual(movedToRecentIds(new Set(), [{ id: "sB" }], []), [], "上一帧不在活动区不判定为迁移");
2508
2585
  assert.deepEqual(movedToRecentIds(new Set(["sB"]), [{ id: "sB" }], []), [], "仍在活动区不判定为迁移");
2509
2586
 
@@ -2513,7 +2590,7 @@ assert.deepEqual(
2513
2590
  ["rB"],
2514
2591
  "上一帧历史区 id 离开历史区且出现于活动区判定为反向迁移",
2515
2592
  );
2516
- assert.deepEqual(movedToActiveIds(new Set(["rB"]), [], []), [], "彻底消失(归档/滑出历史窗口)不判定为反向迁移");
2593
+ assert.deepEqual(movedToActiveIds(new Set(["rB"]), [], []), [], "彻底消失(归档/不再被会话服务列出)不判定为反向迁移");
2517
2594
  assert.deepEqual(movedToActiveIds(new Set(), [{ id: "rB" }], []), [], "上一帧不在历史区不判定为反向迁移");
2518
2595
  assert.deepEqual(movedToActiveIds(new Set(["rB"]), [], [{ id: "rB" }]), [], "仍在历史区不判定为反向迁移");
2519
2596
 
@@ -2544,14 +2621,13 @@ const recentArchived = buildRecent(
2544
2621
  },
2545
2622
  [],
2546
2623
  NOW,
2547
- undefined,
2548
2624
  {},
2549
2625
  ["sGone"],
2550
2626
  );
2551
2627
  assert.deepEqual(
2552
2628
  recentArchived.map((e) => e.id),
2553
2629
  ["sKeep"],
2554
- "归档会话即使在 24h 窗口内也不入最近历史",
2630
+ "归档会话即使仍被会话服务列出也不入最近历史",
2555
2631
  );
2556
2632
  assert.deepEqual(
2557
2633
  buildRecent(
@@ -2565,7 +2641,6 @@ assert.deepEqual(
2565
2641
  },
2566
2642
  [],
2567
2643
  NOW,
2568
- undefined,
2569
2644
  {},
2570
2645
  new Set(["sGone"]),
2571
2646
  ).map((e) => e.id),
@@ -2703,8 +2778,8 @@ assert.ok(bundle.includes("notifyLayoutChange"), "布局变化通知 sibling ove
2703
2778
  assert.ok(bundle.includes('window.dispatchEvent(new Event("resize"))'), "布局变化派发标准 resize 通知");
2704
2779
  assert.ok(bundle.includes("pane !== renderedPane"), "新窗格实例必须重置渲染签名");
2705
2780
  assert.ok(
2706
- clientSource.includes("const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface]);"),
2707
- "列表 phase 转换必须参与结构化渲染签名,空列表不得冻结在加载/失败状态(T-087)",
2781
+ clientSource.includes("const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface, recentTimeSignature]);"),
2782
+ "列表 phase 与历史时间文案必须参与结构化渲染签名,空列表不得冻结在加载/失败状态(T-087)",
2708
2783
  );
2709
2784
  // R-01-013/AC-02 回归:卡片标题必须随快照更新——单卡渲染异常不得冻结其余卡片
2710
2785
  // (此前渲染签名先于卡片循环提交且无异常隔离,故障卡及其后全部卡片永久滞留旧标题,
@@ -2950,7 +3025,8 @@ assert.ok(!bundle.includes("serviceTimer"), "服务发现不得保留后台定
2950
3025
  assert.ok(!bundle.includes("frameProbeTimer"), "宿主 frame 发现不得保留后台定时器");
2951
3026
  assert.ok(bundle.includes("conversationObserver"), "流式 DOM 观察绑定到 conversation seat");
2952
3027
  assert.ok(bundle.includes("centerObserver"), "宿主结构变化通过 center 直接子节点通知处理");
2953
- assert.ok(bundle.includes("setInterval(() => queueSync(), CLOCK_MS)"), "仅保留运行时长显示所需的单一 1 秒时钟");
3028
+ assert.ok(bundle.includes("setInterval(() => queueSync(), CLOCK_MS)"), "运行卡时长继续使用独立的 1 秒时钟");
3029
+ assert.ok(bundle.includes("RECENT_TIME_REFRESH_MS") && bundle.includes("syncRecentTimeClock"), "历史卡相对时间使用独立的分钟级刷新时钟(R-01-013/AC-05)");
2954
3030
  // R-01-014/AC-01
2955
3031
  // R-01-014/AC-02
2956
3032
  // R-01-014/AC-03
@@ -3144,6 +3220,7 @@ assert.ok(
3144
3220
  assert.ok(bundle.includes("lastTurnEndFromEvents") && bundle.includes("lastTurnEndFromTimings"), "回合结束时刻提取进入 bundle(R-01-010/AC-08)");
3145
3221
  assert.ok(bundle.includes("delegatingIds, turnEnds)"), "turnEnds 注入 buildRecent(R-01-010/AC-09)");
3146
3222
  assert.ok(bundle.includes("fmtRecentTime(entry.activityAt)"), "最近卡渲染读取精化后的 activityAt(R-01-010/AC-08)");
3223
+ assert.ok(bundle.includes("fmtRelativeAge") && bundle.includes("最后活动 ·"), "最近卡同时显示绝对日期时间与相对活动时间(R-01-013/AC-05)");
3147
3224
 
3148
3225
  assert.ok(
3149
3226
  bundle.indexOf('[data-kind="recent"] {') < bundle.indexOf(".dap-card[data-opening]"),
@@ -3390,12 +3467,54 @@ assert.ok(
3390
3467
  );
3391
3468
 
3392
3469
  // R-01-004/AC-03
3393
- // 滚动条仅滚动时显示:thumb 默认透明、data-scrolling 时显示;Firefox 路径在 @supports 门内。
3470
+ // 滚动条随滚动或鼠标指针进入窗格显示:thumb 默认透明、data-scrolling/data-pointer-inside 时显示;
3471
+ // Firefox 路径在 @supports 门内,native scrollbar 与右缘调宽手柄保持可分别命中。
3394
3472
  assert.ok(bundle.includes(".dap-scroll::-webkit-scrollbar-thumb {\n background: transparent;"), "滚动条 thumb 默认透明(不滚动时不显示)");
3395
3473
  assert.ok(bundle.includes(".dap-scroll[data-scrolling]::-webkit-scrollbar-thumb"), "滚动中经 data-scrolling 显示滚动条");
3474
+ assert.ok(bundle.includes("[data-dsh-activity-pane][data-pointer-inside] .dap-scroll::-webkit-scrollbar-thumb"), "鼠标进入窗格经 data-pointer-inside 显示滚动条");
3475
+ assert.ok(bundle.includes("[data-dsh-activity-pane][data-pointer-inside] .dap-scroll::-webkit-scrollbar-thumb:hover"), "鼠标悬停 native thumb 使用高 specificity hover 规则");
3476
+ assert.ok(bundle.includes("var(--dsh-scrollbar-thumb-hover"), "native thumb hover 读取 DSH 主题 hover token");
3477
+ assert.ok(bundle.includes("scrollbar-gutter: stable;"), "滚动区稳定预留 native scrollbar 槽位");
3478
+ assert.ok(bundle.includes("margin-right: var(--dsh-scrollbar-width, 8px);"), "滚动区右侧按 DSH scrollbar 宽度留出调宽边界");
3479
+ assert.ok(
3480
+ bundle.includes("[data-dsh-activity-pane] .dap-resize {\n position: absolute;\n top: 0; right: 0; bottom: 0;"),
3481
+ "调宽手柄保持在 pane 右缘",
3482
+ );
3483
+ assert.ok(!bundle.includes(".dap-resize:hover"), "调宽手柄悬停不绘制高亮区域");
3484
+ assert.ok(bundle.includes('pane.addEventListener("pointerenter", onPanePointerEnter)'), "鼠标进入窗格绑定滚动条显示");
3485
+ assert.ok(bundle.includes('pane.addEventListener("pointerleave", onPanePointerLeave)'), "鼠标离开窗格绑定滚动条隐藏");
3486
+ assert.ok(bundle.includes('pane.removeEventListener("pointerenter", onPanePointerEnter)') && bundle.includes('pane.removeEventListener("pointerleave", onPanePointerLeave)'), "unbind 清理窗格指针监听(R-02-003/AC-02)");
3396
3487
  assert.ok(bundle.includes("@supports not selector(::-webkit-scrollbar)") && bundle.includes("scrollbar-color: transparent transparent"), "Firefox 路径以 @supports 门隔离(防 Chromium 丢弃伪元素规则)");
3488
+ assert.ok(bundle.includes("[data-dsh-activity-pane][data-pointer-inside] .dap-scroll {\n scrollbar-color:"), "Firefox 路径经 data-pointer-inside 显示滚动条");
3397
3489
  assert.ok(bundle.includes('scroll?.addEventListener("scroll", onScroll, { passive: true })'), "滚动监听置位 data-scrolling");
3398
- assert.ok(bundle.includes('scroll?.removeEventListener("scroll", onScroll);\n\t\t\tif (scrollHideTimer !== null) clearTimeout(scrollHideTimer);'), "unbind 同步清理滚动监听与隐藏定时器(R-02-003/AC-02)");
3490
+ assert.ok(
3491
+ bundle.includes('scroll?.removeEventListener("scroll", onScroll);') && bundle.includes('if (scrollHideTimer !== null) clearTimeout(scrollHideTimer);'),
3492
+ "unbind 同步清理滚动监听与隐藏定时器(R-02-003/AC-02)",
3493
+ );
3494
+ assert.ok(
3495
+ bundle.includes('recentMore?.removeEventListener("click", onRecentMoreClick);') && !bundle.includes("recentObserver?.disconnect();"),
3496
+ "unbind 同步清理加载更多按钮监听,历史分页不再保留 observer(R-02-003/AC-02)",
3497
+ );
3498
+
3499
+ // R-01-019/AC-01~AC-04 历史候选与手动分页渲染契约
3500
+ assert.equal(RECENT_PAGE_SIZE, 10, "历史分页批次固定为 10 条(R-01-019/AC-01)");
3501
+ assert.ok(bundle.includes("const RECENT_PAGE_SIZE = 10;"), "生成 bundle 保留 10 条历史分页批次配置(R-01-019/AC-01)");
3502
+ assert.ok(bundle.includes('const recent = recentCandidates.slice(0, recentVisibleCount);'), "历史区只渲染当前可见候选前缀(R-01-019/AC-01)");
3503
+ assert.ok(bundle.includes('const nextCount = Math.min(recentVisibleCount + RECENT_PAGE_SIZE, recentTotal);'), "按钮激活后追加最多一个 10 条批次(R-01-019/AC-02)");
3504
+ assert.ok(
3505
+ bundle.includes('<button class="dap-recent-more" type="button" hidden>加载更多...</button>') &&
3506
+ bundle.includes('recentMore?.addEventListener("click", onRecentMoreClick);'),
3507
+ "历史区底部提供原生加载更多按钮并绑定显式激活(R-01-019/AC-02)",
3508
+ );
3509
+ assert.ok(
3510
+ !bundle.includes("new window.IntersectionObserver") && !bundle.includes('class="dap-recent-tail"') && !bundle.includes("maybeLoadMoreRecent"),
3511
+ "历史分页不再由 IntersectionObserver、尾部 sentinel 或滚动检查自动触发(R-01-019/AC-01、AC-02)",
3512
+ );
3513
+ assert.ok(
3514
+ bundle.includes("recentMore.hidden = !recentHasMore;") && bundle.includes("recentMore.disabled = recentAppendQueued;"),
3515
+ "仍有候选时显示按钮、追加期间禁用,候选耗尽后隐藏(R-01-019/AC-03)",
3516
+ );
3517
+ assert.ok(bundle.includes('recentHasMore = recent.length < recentTotal;'), "历史候选耗尽后停止追加(R-01-019/AC-03)");
3399
3518
 
3400
3519
  // R-01-018 回到顶部悬浮图标按钮
3401
3520
  // R-01-018/AC-01、R-01-018/AC-03:骨架按钮默认 hidden,滚动监听按 TOP_THRESHOLD 阈值揭隐/隐藏;