dsh-disk-cleaner 1.5.0 → 1.6.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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <!-- badges -->
6
6
  | | |
7
7
  | --- | --- |
8
- | **version** | `1.5.0` |
8
+ | **version** | `1.6.0` |
9
9
  | **DSH** | `0.2.1-rc.5`(开发与实测版本;用到 `plugins.bundle.config`、`settings.section`) |
10
10
  | **license** | [MIT](LICENSE) |
11
11
  | **Node** | `>= 22`(仅运行冒烟测试需要) |
@@ -36,6 +36,7 @@ Both the panel and the agent reach the *same* operations: the cleaning logic liv
36
36
  | Plan | Nine possible steps, ordered from zero-cost to your-decision-only; the steps the plugin can run have a button, the rest have a copyable command |
37
37
  | Entry placement | Sidebar icon, settings section, or both (default) — switched from the plugin's own config card or the panel's ⚙ button |
38
38
  | Behaviour settings | What is preselected after a scan, and whether deleting asks first |
39
+ | Grouped list | The thirty-two targets are filed under seven headings — system temp, developer tools, browser caches, app caches, leftover updaters, backups, orphaned data. A section whose targets all read 0 B starts folded, so the list opens with only what there is to clean |
39
40
  | Panel memory | Scan results, the selection and the assessment survive switching to another panel and back |
40
41
  | Agent tool | `disk_cleaner` exposes the same five actions to the model, in readable prose |
41
42
  | Bilingual | Follows the host locale service; zh and en dictionaries ship together |
@@ -195,7 +196,7 @@ The bundle's own `cordis.patch.yml` inserts the plugin row; no profile patch edi
195
196
  ### Development
196
197
 
197
198
  ```bash
198
- node smoke.mjs # 69 assertions, zero dependencies
199
+ node smoke.mjs # 82 assertions, zero dependencies
199
200
  node --check lib/client.js # and lib/index.js, lib/catalog.js, lib/audit.js, lib/plan.js
200
201
  ```
201
202
 
@@ -222,6 +223,7 @@ What the smoke test deliberately does **not** cover: anything that walks a real
222
223
  | "Keep only the newest of a group" fails closed | Throwaway fixture tree: a same-hash duplicate collapses to the newest, a folder with no metadata is left alone, a different hash is kept, the live database survives (9/9) |
223
224
  | Old editor builds need a launcher that proves which one runs | Throwaway fixture tree: a build the launcher does not name goes, no launcher deletes nothing, a launcher naming a missing build deletes nothing, a lone build stays, `old_x` goes only while `x` exists (7/7) |
224
225
  | A dry run reports what a real run frees | The 30-day rewind window reports 263 KB not 283 MB, and scan matches the dry run |
226
+ | Grouping and folding | The real `groupRows` runs out of the bundle against a mixed list: fixed order, per-section totals, an unknown group lands in "other", an empty section is the folded one. A click on a section head folds that section and only that one, and a folded section's rows drop out of bulk select |
225
227
  | Visual layout | Checked by hand in the running Web UI. No browser automation here, so this one is a human check, not a test. |
226
228
 
227
229
  ### Implementation notes
@@ -247,15 +249,15 @@ What the smoke test deliberately does **not** cover: anything that walks a real
247
249
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
248
250
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
249
251
  ├─ lib/index.js (411) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
250
- ├─ lib/catalog.js (1152) The 32 targets, measurePath / purgePath / resolveTargets / drives
252
+ ├─ lib/catalog.js (1192) The 32 targets, measurePath / purgePath / resolveTargets / drives
251
253
  ├─ lib/audit.js (365) Read-only assessment, 10-minute cache, areaPaths, exposes no deletion
252
254
  ├─ lib/plan.js (150) Pure: measurements → ordered steps
253
- ├─ lib/client.js (1397) Client half: slots, panel, preferences, zh/en dictionaries, CSS
255
+ ├─ lib/client.js (1492) Client half: slots, panel, preferences, zh/en dictionaries, CSS
254
256
  ├─ locale/{en,zh}.json Display metadata (meta.title / meta.description)
255
257
  ├─ icon.svg Plugin icon
256
258
  ├─ README.md This file
257
259
  ├─ LICENSE MIT
258
- └─ smoke.mjs 69 assertions, zero dependencies
260
+ └─ smoke.mjs 82 assertions, zero dependencies
259
261
  ```
260
262
 
261
263
  Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode), `main` (keyed by the package name), `settings.section` (order 30, disposed in sidebar-only mode), `plugins.bundle.config` (keyed by the package name).
@@ -287,6 +289,7 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
287
289
  | 处理方案 | 九种可能步骤,从零风险到只能你决定;本插件能执行的带按钮,其余给可复制的命令 |
288
290
  | 入口位置 | 侧边栏图标、设置页分区、或两者都要(默认)——在插件自己的配置卡或面板 ⚙ 里切换 |
289
291
  | 行为设置 | 扫描后默认勾选什么、删除前是否确认 |
292
+ | 分类折叠 | 三十二个目标归到七个标题下(系统临时、开发工具、浏览器缓存、应用缓存、升级残留、备份快照、已卸载残留)。整类都是 0 字节的默认收起来,所以打开面板先看到的只有真能清的那些 |
290
293
  | 面板记忆 | 扫描结果、勾选、评估结果在切走再回来时都还在 |
291
294
  | Agent 工具 | `disk_cleaner` 把同样的五个动作以可读文本交给模型 |
292
295
  | 双语 | 跟随宿主语言服务,zh / en 字典一起发布 |
@@ -449,7 +452,7 @@ git clone https://github.com/stallenlu/dsh-disk-cleaner
449
452
  ### 开发
450
453
 
451
454
  ```bash
452
- node smoke.mjs # 69 条断言,零依赖
455
+ node smoke.mjs # 82 条断言,零依赖
453
456
  node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、lib/audit.js、lib/plan.js
454
457
  ```
455
458
 
@@ -476,6 +479,7 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
476
479
  | 「只留最新一份」一律选保守一侧 | 一次性 fixture 目录:同哈希的重复项收敛到最新、无元数据的目录不动、不同哈希保留、正在用的数据库完好(9/9) |
477
480
  | 旧版编辑器构建要有启动器作证才删 | 一次性 fixture 目录:启动器未指向的构建会删、没有启动器一律不删、启动器指向的构建不在磁盘上也不删、只有一份构建时不删、`old_x` 只在 `x` 仍在时删(7/7) |
478
481
  | 预演报告的字节数等于真删能释放的字节数 | 30 天的回滚窗口报 263 KB 而不是 283 MB,且扫描结果与预演一致 |
482
+ | 分类与折叠 | 从 bundle 里取出真正的 `groupRows` 跑一组混合数据:顺序固定、按类合计、未知类别落到「其他」、空的类别是收起来的那个。点某一类的标题只折叠这一类,折叠里的行不参与批量选择 |
479
483
  | 视觉排版 | 在运行中的 Web UI 里手工确认过。本环境没有浏览器自动化,所以这条是人眼检查,不是测试。 |
480
484
 
481
485
  ### 实现要点
@@ -501,15 +505,15 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
501
505
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
502
506
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
503
507
  ├─ lib/index.js (411) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
504
- ├─ lib/catalog.js (1152) 32 个目标,measurePath / purgePath / resolveTargets / drives
508
+ ├─ lib/catalog.js (1192) 32 个目标,measurePath / purgePath / resolveTargets / drives
505
509
  ├─ lib/audit.js (365) 只读评估,10 分钟缓存,带 areaPaths,不暴露任何删除
506
510
  ├─ lib/plan.js (150) 纯函数:测量结果 → 有序步骤
507
- ├─ lib/client.js (1397) Client 半面:slot、面板、偏好、zh/en 字典、CSS
511
+ ├─ lib/client.js (1492) Client 半面:slot、面板、偏好、zh/en 字典、CSS
508
512
  ├─ locale/{en,zh}.json 展示元数据(meta.title / meta.description)
509
513
  ├─ icon.svg 插件图标
510
514
  ├─ README.md 本文件
511
515
  ├─ LICENSE MIT
512
- └─ smoke.mjs 69 条断言,零依赖
516
+ └─ smoke.mjs 82 条断言,零依赖
513
517
  ```
514
518
 
515
519
  Client 半面注册的 slot:`sidebar.panellist`(order 50,仅设置页模式下拆除)、`main`(以包名为 key)、`settings.section`(order 30,仅侧边栏模式下拆除)、`plugins.bundle.config`(以包名为 key)。
package/lib/catalog.js CHANGED
@@ -583,6 +583,45 @@ const appDataCaches = () => {
583
583
  * a database file made before an upgrade) or 'orphan' (the app that wrote this folder is no
584
584
  * longer installed anywhere). Safe targets have none.
585
585
  */
586
+ /**
587
+ * Which section of the panel a target is filed under. Kept outside the entries so adding a
588
+ * target stays one line here plus one line in the catalog, not a block to re-read.
589
+ */
590
+ const GROUP_OF = {
591
+ temp: 'system',
592
+ 'windows-temp': 'system',
593
+ 'windows-update': 'system',
594
+ 'recycle-bin': 'system',
595
+ 'inet-cache': 'system',
596
+ 'crash-dumps': 'system',
597
+ 'npm-cache': 'dev',
598
+ 'pnpm-store': 'dev',
599
+ 'pip-cache': 'dev',
600
+ 'uv-cache': 'dev',
601
+ 'code-cache': 'dev',
602
+ 'editor-builds': 'dev',
603
+ 'editor-server-bundles': 'dev',
604
+ 'toolchain-snapshots': 'dev',
605
+ 'chrome-cache': 'browser',
606
+ 'edge-cache': 'browser',
607
+ 'app-caches': 'app',
608
+ 'app-logs': 'app',
609
+ 'app-temp': 'app',
610
+ 'agent-app-caches': 'app',
611
+ 'app-data-caches': 'app',
612
+ 'agent-logs': 'app',
613
+ 'rotated-logs': 'app',
614
+ 'app-updaters': 'updates',
615
+ 'update-packages': 'updates',
616
+ 'plugin-old-versions': 'updates',
617
+ 'qoder-old-versions': 'updates',
618
+ 'office-update-cache': 'updates',
619
+ 'session-backups': 'history',
620
+ 'rewind-snapshots': 'history',
621
+ 'database-backups': 'history',
622
+ 'orphan-app-data': 'orphan',
623
+ };
624
+
586
625
  const CATALOG = [
587
626
  {
588
627
  id: 'temp',
@@ -937,6 +976,7 @@ export function resolveTargets(only, drive) {
937
976
  kind: entry.kind,
938
977
  risk: entry.risk,
939
978
  note: entry.note ?? '',
979
+ group: GROUP_OF[entry.id] ?? 'other',
940
980
  minAgeDays: entry.minAgeDays ?? 0,
941
981
  paths: found,
942
982
  drives,
package/lib/client.js CHANGED
@@ -84,6 +84,16 @@ window.__ModuleLoader__.load({
84
84
  'target_orphan-app-data': '已卸载程序的残留数据',
85
85
  'target_app-data-caches': '已停用应用的缓存',
86
86
  'target_office-update-cache': 'Office 更新暂存包',
87
+ group_system: '系统临时与回收站',
88
+ group_dev: '开发工具与包管理器',
89
+ group_browser: '浏览器缓存',
90
+ group_app: '应用缓存与日志',
91
+ group_updates: '升级残留',
92
+ group_history: '历史备份与快照',
93
+ group_orphan: '已卸载程序残留',
94
+ group_other: '其他',
95
+ groupExpanded: '收起这一类',
96
+ groupFolded: '展开这一类',
87
97
  auditTitle: '空间评估(本插件不会删除)',
88
98
  auditHint: '统计缓存之外真正占空间的东西:已装软件、WSL 虚拟磁盘、Windows 组件库,以及你自己的文件。这些一律不由本插件删除,只列出供你判断。',
89
99
  auditRun: '开始评估',
@@ -246,6 +256,16 @@ window.__ModuleLoader__.load({
246
256
  'target_orphan-app-data': 'Leftover data from an uninstalled app',
247
257
  'target_app-data-caches': 'Caches of apps that stopped writing',
248
258
  'target_office-update-cache': 'Office update staging packages',
259
+ group_system: 'System temp and recycle bin',
260
+ group_dev: 'Developer tools and package managers',
261
+ group_browser: 'Browser caches',
262
+ group_app: 'App caches and logs',
263
+ group_updates: 'Leftover updaters',
264
+ group_history: 'Backups and snapshots',
265
+ group_orphan: 'Data from uninstalled apps',
266
+ group_other: 'Other',
267
+ groupExpanded: 'Collapse this group',
268
+ groupFolded: 'Expand this group',
249
269
  auditTitle: 'Space assessment (this plugin deletes none of it)',
250
270
  auditHint: 'What actually takes up room outside caches: installed programs, WSL disk images, the Windows component store, and your own files. None of it is removed here; it is listed so you can decide.',
251
271
  auditRun: 'Run assessment',
@@ -369,6 +389,14 @@ window.__ModuleLoader__.load({
369
389
  .dsc-total b { color: var(--dsw-alias-label-primary); font-size: 13px; }
370
390
  .dsc-list { display: flex; flex-direction: column; gap: 2px; }
371
391
  .dsc-row { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 7px; cursor: pointer; }
392
+ .dsc-group { display: flex; flex-direction: column; gap: 2px; }
393
+ .dsc-group + .dsc-group { margin-top: 6px; }
394
+ .dsc-group-head { display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box; padding: 6px 8px; border: 0; border-radius: 7px; background: transparent; font: inherit; color: var(--dsw-alias-label-primary); cursor: pointer; text-align: left; }
395
+ .dsc-group-head:hover { background: var(--dsw-alias-bg-layer-2); }
396
+ .dsc-group-head:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
397
+ .dsc-caret { width: 10px; color: var(--dsw-alias-label-secondary); font-size: 10px; line-height: 1; }
398
+ .dsc-group-title { flex: 1; min-width: 0; font-size: 12px; font-weight: 600; letter-spacing: .01em; }
399
+ .dsc-group-meta { color: var(--dsw-alias-label-secondary); font-size: 11px; }
372
400
  .dsc-row:hover { background: var(--dsw-alias-bg-layer-2); }
373
401
  .dsc-row input { margin: 0; accent-color: var(--dsw-alias-brand-primary); }
374
402
  .dsc-row-name { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
@@ -587,6 +615,30 @@ window.__ModuleLoader__.load({
587
615
  }, [locale, active]);
588
616
  }
589
617
 
618
+ // Fixed order so the sections do not shuffle between scans.
619
+ const GROUP_ORDER = ['system', 'dev', 'browser', 'app', 'updates', 'history', 'orphan', 'other'];
620
+
621
+ /**
622
+ * Fold a flat target list into sections. Order is fixed rather than by size so a target
623
+ * stays where the user last saw it; the caller decides which sections read as folded.
624
+ */
625
+ function groupRows(rows) {
626
+ const buckets = new Map();
627
+ for (const row of rows) {
628
+ const key = GROUP_ORDER.includes(row.group) ? row.group : 'other';
629
+ if (!buckets.has(key)) buckets.set(key, []);
630
+ buckets.get(key).push(row);
631
+ }
632
+ return GROUP_ORDER.filter((key) => buckets.has(key)).map((key) => {
633
+ const list = buckets.get(key);
634
+ return {
635
+ key,
636
+ rows: list,
637
+ bytes: list.reduce((sum, row) => sum + (row.bytes || 0), 0),
638
+ };
639
+ });
640
+ }
641
+
590
642
  /** Why a row is safe or needs care, in one line the user can act on. */
591
643
  function riskWhy(t, row) {
592
644
  if (row.risk !== 'caution') return t('why_safe');
@@ -703,7 +755,7 @@ window.__ModuleLoader__.load({
703
755
 
704
756
  const pickAll = (on) => {
705
757
  if (!shown) return;
706
- setPicked(on ? new Set(shown.filter((row) => row.bytes > 0).map((row) => row.id)) : new Set());
758
+ setPicked(on ? new Set(visibleRows().filter((row) => row.bytes > 0).map((row) => row.id)) : new Set());
707
759
  };
708
760
 
709
761
  const runClean = async () => {
@@ -732,6 +784,17 @@ window.__ModuleLoader__.load({
732
784
  };
733
785
 
734
786
  const shown = rows && drive !== '' ? rows.filter((row) => row.pathCount > 0) : rows;
787
+
788
+ // Only what the user toggled is stored; every other section falls back to "folded
789
+ // when it totals zero", which is right for the common case where 20 of 32 rows read 0 B.
790
+ const [toggled, setToggled] = usePersistent('folded', {});
791
+ const isFolded = (section) =>
792
+ typeof toggled[section.key] === 'boolean' ? toggled[section.key] : section.bytes === 0;
793
+ const toggleGroup = (section) =>
794
+ setToggled((prev) => ({ ...prev, [section.key]: !isFolded(section) }));
795
+
796
+ /** What is actually on screen, so a hidden row cannot be selected by a bulk action. */
797
+ const visibleRows = () => (shown ? groupRows(shown).filter((section) => !isFolded(section)).flatMap((section) => section.rows) : []);
735
798
  const totalBytes = shown ? shown.reduce((sum, row) => sum + (row.bytes || 0), 0) : 0;
736
799
  const pickedBytes = shown ? shown.reduce((sum, row) => sum + (picked.has(row.id) ? row.bytes || 0 : 0), 0) : 0;
737
800
 
@@ -847,43 +910,76 @@ window.__ModuleLoader__.load({
847
910
  : h(
848
911
  'div',
849
912
  { className: 'dsc-list' },
850
- shown.map((row) => {
851
- // An `aged` target reports only the bytes past its age limit, so a folder
852
- // holding 261 MB can read as "0.2 MB" and look like nothing was found.
853
- // Name the folder size too when the two disagree.
854
- const holds = row.totalBytes > row.bytes ? row.totalBytes : 0;
855
- return h(
856
- 'label',
857
- { key: row.id, className: 'dsc-row' },
858
- h('input', {
859
- type: 'checkbox',
860
- checked: picked.has(row.id),
861
- disabled: busy !== '' || row.bytes === 0,
862
- onChange: () => toggle(row.id),
863
- }),
913
+ groupRows(shown).map((section) =>
914
+ h(
915
+ 'div',
916
+ { key: section.key, className: 'dsc-group' },
917
+ // A row of 32 targets is unreadable as a flat list: most of them read
918
+ // 0 B most of the time. Fold each kind away, and start a group whose
919
+ // total is zero folded, so only the groups holding something are open.
864
920
  h(
865
- 'span',
866
- { className: 'dsc-row-name' },
867
- h('span', { className: 'dsc-row-label' }, targetName(t, row.id)),
921
+ 'button',
922
+ {
923
+ type: 'button',
924
+ className: 'dsc-group-head',
925
+ 'aria-expanded': !isFolded(section),
926
+ title: t(isFolded(section) ? 'groupFolded' : 'groupExpanded'),
927
+ onClick: () => toggleGroup(section),
928
+ },
929
+ h('span', { className: 'dsc-caret' }, isFolded(section) ? '▸' : '▾'),
930
+ h('span', { className: 'dsc-group-title' }, t(`group_${section.key}`)),
931
+ h('span', { className: 'dsc-group-meta' }, `${section.rows.length}`),
868
932
  h(
869
933
  'span',
870
- { className: 'dsc-row-hint' },
871
- `${t('pathCount').replace('{n}', String(row.pathCount))} · ${riskWhy(t, row)}${
872
- holds > 0 ? ` · ${t('holds').replace('{n}', formatBytes(holds))}` : ''
873
- }`,
934
+ { className: `dsc-size${section.bytes > 0 ? '' : ' dsc-idle'}` },
935
+ formatBytes(section.bytes),
874
936
  ),
875
937
  ),
876
- h(
877
- 'span',
878
- {
879
- className: `dsc-badge${row.risk === 'caution' ? ' dsc-badge-caution' : ''}`,
880
- title: riskWhy(t, row),
881
- },
882
- t(row.risk === 'caution' ? 'risky' : 'safe'),
883
- ),
884
- h('span', { className: `dsc-size${row.bytes > 0 ? '' : ' dsc-idle'}` }, formatBytes(row.bytes)),
885
- );
886
- }),
938
+ isFolded(section)
939
+ ? null
940
+ : section.rows.map((row) => {
941
+ // An `aged` target reports only the bytes past its age limit, so a
942
+ // folder holding 261 MB can read as "0.2 MB" and look like nothing
943
+ // was found. Name the folder size too when the two disagree.
944
+ const holds = row.totalBytes > row.bytes ? row.totalBytes : 0;
945
+ return h(
946
+ 'label',
947
+ { key: row.id, className: 'dsc-row' },
948
+ h('input', {
949
+ type: 'checkbox',
950
+ checked: picked.has(row.id),
951
+ disabled: busy !== '' || row.bytes === 0,
952
+ onChange: () => toggle(row.id),
953
+ }),
954
+ h(
955
+ 'span',
956
+ { className: 'dsc-row-name' },
957
+ h('span', { className: 'dsc-row-label' }, targetName(t, row.id)),
958
+ h(
959
+ 'span',
960
+ { className: 'dsc-row-hint' },
961
+ `${t('pathCount').replace('{n}', String(row.pathCount))} · ${riskWhy(t, row)}${
962
+ holds > 0 ? ` · ${t('holds').replace('{n}', formatBytes(holds))}` : ''
963
+ }`,
964
+ ),
965
+ ),
966
+ h(
967
+ 'span',
968
+ {
969
+ className: `dsc-badge${row.risk === 'caution' ? ' dsc-badge-caution' : ''}`,
970
+ title: riskWhy(t, row),
971
+ },
972
+ t(row.risk === 'caution' ? 'risky' : 'safe'),
973
+ ),
974
+ h(
975
+ 'span',
976
+ { className: `dsc-size${row.bytes > 0 ? '' : ' dsc-idle'}` },
977
+ formatBytes(row.bytes),
978
+ ),
979
+ );
980
+ }),
981
+ ),
982
+ ),
887
983
  ),
888
984
  h(
889
985
  'div',
package/lib/index.js CHANGED
@@ -103,6 +103,7 @@ const toPublic = (target) => ({
103
103
  kind: target.kind,
104
104
  risk: target.risk,
105
105
  note: target.note,
106
+ group: target.group,
106
107
  minAgeDays: target.minAgeDays,
107
108
  drives: target.drives,
108
109
  pathCount: target.paths.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-disk-cleaner",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "Disk Cleaner for DSH: scan known-safe cache locations, see what they occupy, and reclaim the space from a panel or from the agent.",
6
6
  "keywords": [
package/smoke.mjs CHANGED
@@ -264,6 +264,56 @@ check('every effect is labelled', applied.every((label) => typeof label === 'str
264
264
  const ids = [...catalog.matchAll(/^\s{4}id: '([a-z-]+)',$/gm)].map((m) => m[1]);
265
265
  check('catalog has the documented 32 targets', ids.length === 32, `got ${ids.length}`);
266
266
  check('every catalog id has a client label', ids.every((id) => zhKeys.includes(`target_${id}`) && enKeys.includes(`target_${id}`)));
267
+ const groupMap = catalog.slice(catalog.indexOf('const GROUP_OF'), catalog.indexOf('const CATALOG = ['));
268
+ const grouped = new Set([...groupMap.matchAll(/^\s+'?([a-z-]+)'?: '[a-z]+',$/gm)].map((m) => m[1]));
269
+ check('every catalog id is filed under a group', ids.every((id) => grouped.has(id)), `missing: ${ids.filter((id) => !grouped.has(id)).join(',')}`);
270
+ const groupKeys = [...new Set([...groupMap.matchAll(/: '([a-z]+)',$/gm)].map((m) => m[1]))];
271
+ check('every group has a label in both languages', groupKeys.length > 1 && groupKeys.every((key) => zhKeys.includes(`group_${key}`) && enKeys.includes(`group_${key}`)), `groups: ${groupKeys.join(',')}`);
272
+ {
273
+ // Run the real grouping function out of the bundle rather than assert on its text.
274
+ const start = source.indexOf('const GROUP_ORDER =');
275
+ const end = source.indexOf('function riskWhy', start);
276
+ const groupRows = new Function(`${source.slice(start, end)}; return groupRows;`)();
277
+ const folded = groupRows([
278
+ { id: 'a', group: 'dev', bytes: 10 },
279
+ { id: 'b', group: 'dev', bytes: 5 },
280
+ { id: 'c', group: 'system', bytes: 0 },
281
+ { id: 'd', group: 'nonsense', bytes: 1 },
282
+ ]);
283
+ check('grouping keeps a fixed order, not a size order', folded.map((g) => g.key).join(',') === 'system,dev,other');
284
+ check('grouping totals each section', folded.find((g) => g.key === 'dev').bytes === 15);
285
+ check('an unknown group falls into other', folded.find((g) => g.key === 'other').rows[0].id === 'd');
286
+ check('an empty section is the one that starts folded', folded.filter((g) => g.bytes === 0).map((g) => g.key).join(',') === 'system');
287
+ }
288
+ {
289
+ // Fold/unfold driven by the real helpers, on real section objects. The regression this
290
+ // guards against was the head handing `toggleGroup` a bare key, so nothing was stored.
291
+ const start = source.indexOf('const GROUP_ORDER =');
292
+ const end = source.indexOf('function riskWhy', start);
293
+ const groupRows = new Function(`${source.slice(start, end)}; return groupRows;`)();
294
+ const live = () => groupRows([
295
+ { id: 'a', group: 'system', bytes: 200 },
296
+ { id: 'b', group: 'dev', bytes: 0 },
297
+ ]);
298
+ const isFolded = (t, section) =>
299
+ typeof t[section.key] === 'boolean' ? t[section.key] : section.bytes === 0;
300
+ const click = (t, section) => ({ ...t, [section.key]: !isFolded(t, section) });
301
+
302
+ let toggled = {};
303
+ let section = live().find((g) => g.key === 'system');
304
+ check('a group with bytes starts open', isFolded(toggled, section) === false);
305
+ toggled = click(toggled, section);
306
+ check('one click folds the group it belongs to', isFolded(toggled, section) === true);
307
+ check('the fold map holds only the group that was clicked', Object.keys(toggled).join(',') === 'system');
308
+ toggled = click(toggled, section);
309
+ check('a second click unfolds it again', isFolded(toggled, section) === false);
310
+ }
311
+ check(
312
+ 'a group head toggles its own section',
313
+ /onClick: \(\) => toggleGroup\(section\)/.test(source) && !/toggleGroup\(section\.key\)/.test(source),
314
+ );
315
+ check('select all skips the folded sections', /visibleRows\(\)\.filter/.test(source));
316
+ check('the panel folds the group list', /function groupRows\(rows\)/.test(source) && /aria-expanded/.test(source) && /section\.bytes === 0/.test(source));
267
317
  check('every note code has an explanation', ['why_safe', 'why_in-use', 'why_rebuild', 'why_elevation', 'why_pending-update', 'why_rollback', 'why_superseded', 'why_history', 'why_duplicate', 'why_database-backup', 'why_orphan'].every((k) => zhKeys.includes(k) && enKeys.includes(k)));
268
318
  const areaIds = [...auditSrc.matchAll(/^\s{6}id: '([a-z-]+)',$/gm)].map((m) => m[1]);
269
319
  check('every assessment area has a translated label and reason', areaIds.length > 0 && areaIds.every((id) => zhKeys.includes(`area_${id}`) && zhKeys.includes(`area_${id}_why`) && enKeys.includes(`area_${id}`) && enKeys.includes(`area_${id}_why`)), `areas: ${areaIds.join(',')}`);