@dsh-workbench/shared 0.3.5 → 0.3.7

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.
Files changed (153) hide show
  1. package/dist/api/context-ref.d.ts +0 -12
  2. package/dist/api/context-ref.js +0 -40
  3. package/dist/api/facts.d.ts +0 -6
  4. package/dist/api/facts.js +0 -8
  5. package/dist/api/long-task.d.ts +33 -4
  6. package/dist/api/long-task.js +34 -2
  7. package/dist/components/ConfirmBar.css +26 -0
  8. package/dist/components/ConfirmBar.d.ts +36 -0
  9. package/dist/components/ConfirmBar.js +26 -0
  10. package/dist/components/Form.d.ts +7 -5
  11. package/dist/components/Form.js +5 -3
  12. package/dist/components/FormNodes.d.ts +17 -13
  13. package/dist/components/FormNodes.js +13 -5
  14. package/dist/components/FormRows.css +31 -0
  15. package/dist/components/FormTypes.d.ts +39 -14
  16. package/dist/components/LongTaskRow.css +52 -0
  17. package/dist/components/LongTaskRow.d.ts +18 -2
  18. package/dist/components/LongTaskRow.js +33 -5
  19. package/dist/components/MockStrip.css +11 -1
  20. package/dist/components/StateDot.d.ts +5 -0
  21. package/dist/components/StateDot.js +10 -0
  22. package/dist/config/constants.d.ts +2 -0
  23. package/dist/config/constants.js +2 -0
  24. package/dist/host/__tests__/fakes.d.ts +41 -0
  25. package/dist/host/__tests__/fakes.js +76 -0
  26. package/dist/host/__tests__/preflight-fixtures.d.ts +19 -0
  27. package/dist/host/__tests__/preflight-fixtures.js +47 -0
  28. package/dist/host/board-fact.d.ts +8 -0
  29. package/dist/host/board-fact.js +16 -0
  30. package/dist/host/browser-read.js +2 -1
  31. package/dist/host/context-inject.d.ts +24 -0
  32. package/dist/host/context-inject.js +74 -0
  33. package/dist/host/context-ref.d.ts +13 -0
  34. package/dist/host/context-ref.js +41 -0
  35. package/dist/host/diy-debug.d.ts +80 -0
  36. package/dist/host/diy-debug.js +88 -0
  37. package/dist/host/diy-record.d.ts +135 -0
  38. package/dist/host/diy-record.js +162 -0
  39. package/dist/host/events.d.ts +9 -0
  40. package/dist/host/events.js +70 -0
  41. package/dist/host/facts.d.ts +6 -0
  42. package/dist/host/facts.js +9 -0
  43. package/dist/host/git.js +1 -1
  44. package/dist/host/http.d.ts +2 -2
  45. package/dist/host/http.js +3 -3
  46. package/dist/host/long-task.d.ts +12 -0
  47. package/dist/host/long-task.js +34 -5
  48. package/dist/host/mount-units.d.ts +52 -0
  49. package/dist/host/mount-units.js +186 -0
  50. package/dist/host/opencli-runtime.js +1 -1
  51. package/dist/host/opencli-window.js +1 -1
  52. package/dist/host/opencli.js +2 -1
  53. package/dist/host/panel-state.d.ts +120 -0
  54. package/dist/host/panel-state.js +176 -0
  55. package/dist/host/plugin-source.js +1 -1
  56. package/dist/host/preflight.d.ts +58 -0
  57. package/dist/host/preflight.js +233 -0
  58. package/dist/host/probe-cache.d.ts +6 -0
  59. package/dist/host/probe-cache.js +19 -0
  60. package/dist/host/probe-libs.d.ts +45 -0
  61. package/dist/host/probe-libs.js +61 -0
  62. package/dist/host/probe-units.d.ts +17 -0
  63. package/dist/host/probe-units.js +102 -0
  64. package/dist/host/probe-verdict.d.ts +7 -0
  65. package/dist/host/probe-verdict.js +143 -0
  66. package/dist/host/shell-text.d.ts +13 -0
  67. package/dist/host/shell-text.js +20 -0
  68. package/dist/host/tool-approval.d.ts +1 -0
  69. package/dist/host/tool-scope.d.ts +32 -0
  70. package/dist/host/tool-scope.js +84 -0
  71. package/dist/host/unit-declaration.d.ts +65 -0
  72. package/dist/host/unit-declaration.js +213 -0
  73. package/dist/host/unit-face.d.ts +1 -0
  74. package/dist/host/unit-readiness.d.ts +7 -0
  75. package/dist/host/unit-readiness.js +5 -0
  76. package/dist/host/unit-registry.d.ts +9 -0
  77. package/dist/host/unit-registry.js +33 -0
  78. package/dist/host/version-range.d.ts +2 -0
  79. package/dist/host/version-range.js +73 -0
  80. package/dist/host.d.ts +7 -4
  81. package/dist/host.js +7 -4
  82. package/dist/i18n/en.js +6 -0
  83. package/dist/i18n/index.d.ts +7 -1
  84. package/dist/i18n/zh.d.ts +6 -0
  85. package/dist/i18n/zh.js +6 -0
  86. package/dist/index.d.ts +1 -4
  87. package/dist/index.js +1 -4
  88. package/dist/lib/board-fact.d.ts +0 -7
  89. package/dist/lib/board-fact.js +0 -15
  90. package/dist/lib/diy-record.d.ts +38 -1
  91. package/dist/lib/diy-record.js +66 -0
  92. package/dist/lib/text.d.ts +0 -13
  93. package/dist/lib/text.js +0 -20
  94. package/dist/testing/declaration.js +1 -1
  95. package/package.json +1 -1
  96. package/src/api/__tests__/facts.test.ts +0 -13
  97. package/src/api/__tests__/long-task.test.ts +41 -1
  98. package/src/api/context-ref.ts +0 -39
  99. package/src/api/facts.ts +0 -9
  100. package/src/api/long-task.ts +46 -4
  101. package/src/components/ConfirmBar.css +26 -0
  102. package/src/components/ConfirmBar.test.tsx +130 -0
  103. package/src/components/ConfirmBar.tsx +63 -0
  104. package/src/components/Form.test.tsx +15 -0
  105. package/src/components/Form.tsx +7 -5
  106. package/src/components/FormNodes.tsx +50 -26
  107. package/src/components/FormRows.css +31 -0
  108. package/src/components/FormRows.test.tsx +71 -1
  109. package/src/components/FormTypes.test.ts +31 -0
  110. package/src/components/FormTypes.ts +46 -15
  111. package/src/components/FormWiring.test.tsx +1 -0
  112. package/src/components/LongTaskRow.css +52 -0
  113. package/src/components/LongTaskRow.tsx +59 -4
  114. package/src/components/__tests__/long-task-row.test.ts +115 -0
  115. package/src/config/constants.ts +3 -0
  116. package/src/host/__tests__/board-fact.test.ts +47 -0
  117. package/src/{lib → host/__tests__}/diy-debug.test.ts +1 -1
  118. package/src/{lib → host/__tests__}/diy-record.test.ts +137 -1
  119. package/src/host/__tests__/facts.test.ts +13 -0
  120. package/src/host/__tests__/long-task.test.ts +306 -0
  121. package/src/{lib → host/__tests__}/panel-state.test.ts +1 -1
  122. package/src/host/__tests__/preflight-route.test.ts +33 -0
  123. package/src/host/__tests__/shell-text.test.ts +44 -0
  124. package/src/host/board-fact.ts +14 -0
  125. package/src/host/browser-read.ts +2 -1
  126. package/src/host/context-ref.ts +40 -0
  127. package/src/{lib → host}/diy-record.ts +95 -1
  128. package/src/host/facts.ts +10 -0
  129. package/src/host/git.ts +1 -1
  130. package/src/host/http.ts +4 -4
  131. package/src/host/long-task.ts +52 -7
  132. package/src/host/opencli-runtime.ts +1 -1
  133. package/src/host/opencli-window.ts +1 -1
  134. package/src/host/opencli.ts +2 -1
  135. package/src/host/plugin-source.ts +1 -1
  136. package/src/host/shell-text.ts +21 -0
  137. package/src/host/tool-approval.ts +1 -0
  138. package/src/{api → host}/unit-declaration.ts +1 -1
  139. package/src/host/unit-face.ts +1 -0
  140. package/src/host.ts +7 -4
  141. package/src/i18n/en.ts +7 -0
  142. package/src/i18n/zh.ts +6 -0
  143. package/src/index.ts +1 -4
  144. package/src/lib/board-fact.test.ts +1 -33
  145. package/src/lib/board-fact.ts +0 -13
  146. package/src/lib/text.test.ts +1 -43
  147. package/src/lib/text.ts +0 -22
  148. package/src/testing/declaration.ts +1 -1
  149. package/dist/lib/lint-probe.d.ts +0 -1
  150. package/dist/lib/lint-probe.js +0 -6
  151. /package/src/{api → host}/__tests__/unit-declaration.test.ts +0 -0
  152. /package/src/{lib → host}/diy-debug.ts +0 -0
  153. /package/src/{lib → host}/panel-state.ts +0 -0
@@ -0,0 +1,115 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const HERE = new URL('../', import.meta.url);
5
+ const read = (name: string): string => readFileSync(new URL(name, HERE), 'utf8');
6
+
7
+ const ROW = read('LongTaskRow.tsx');
8
+ const CSS = read('LongTaskRow.css');
9
+
10
+ describe('两按停止钮:接线(这仓的组件测试是 node 环境,读源码断言)', () => {
11
+ it('★★ 不给 stop ⇒ 那颗钮整个不出现;文件里也再没有第二颗 button', () => {
12
+ const gate = ROW.indexOf('{stop === undefined ? null : (');
13
+ const button = ROW.indexOf('<button');
14
+
15
+ expect(ROW).toContain('const stop = props.stop;');
16
+ expect(gate).toBeGreaterThan(0);
17
+ expect(button).toBeGreaterThan(gate);
18
+ expect(ROW.indexOf('<button', button + 1)).toBe(-1);
19
+ });
20
+
21
+ it('★★ 住在 .wb-longTaskHead 里、最右边那颗(margin-left: auto)', () => {
22
+ const head = ROW.indexOf('className="wb-longTaskHead"');
23
+ const button = ROW.indexOf('<button');
24
+ const close = ROW.indexOf('</p>', button);
25
+
26
+ expect(head).toBeGreaterThan(0);
27
+ expect(button).toBeGreaterThan(head);
28
+ expect(close).toBeGreaterThan(button);
29
+ expect(CSS).toContain('margin-left: auto');
30
+ });
31
+
32
+ it('★★ 两按只走 longTaskStopPress(longTaskStopStage({ armed, busy })):arm 上膛 / fire 落膛并 onStop / ignore 不动', () => {
33
+ expect(ROW).toContain('longTaskStopPress(longTaskStopStage({ armed, busy }))');
34
+ expect(ROW).toContain('onClick={onStopPress}');
35
+ expect(ROW).toContain("if (press === 'ignore') return;");
36
+ expect(ROW).toContain("if (press === 'arm') {");
37
+ expect(ROW).toContain('setArmed(true);');
38
+ expect(ROW).toMatch(/setArmed\(false\);\s*stop\.onStop\(\);/);
39
+ });
40
+
41
+ it('★★ onBlur 落膛(焦点一走就退回没上膛)', () => {
42
+ expect(ROW).toContain('onBlur={() => setArmed(false)}');
43
+ });
44
+
45
+ it('★★ 上膛后 3 秒自己落膛:用 LONG_TASK_STOP_ARM_MS,卸载 / 落膛都清掉计时器', () => {
46
+ expect(ROW).toContain("from '../api/long-task.js'");
47
+ expect(ROW).toContain('setTimeout(() => setArmed(false), LONG_TASK_STOP_ARM_MS)');
48
+ expect(ROW).toContain('clearTimeout(timer)');
49
+ expect(ROW).toContain('}, [armed]);');
50
+ });
51
+
52
+ it('★★ data-stop-state 交出那一档(照 dsh 的 data-kill-state)', () => {
53
+ expect(ROW).toContain('data-stop-state={longTaskStopStage({ armed, busy })}');
54
+ });
55
+
56
+ it('★★ busy 时 disabled;上膛时多一个 span 显示 askLabel;三句词全部由调用点给', () => {
57
+ expect(ROW).toContain('const busy = stop?.busy === true;');
58
+ expect(ROW).toContain('disabled={busy}');
59
+ expect(ROW).toContain('<span className="wb-longTaskStopLabel">{stop.askLabel}</span>');
60
+ expect(ROW).toContain('aria-label={armed ? stop.armedLabel ?? stop.askLabel : stop.label}');
61
+ expect(ROW).toContain('<IconStopFillRegular size={12} />');
62
+ expect(ROW).toContain('type="button"');
63
+ expect(ROW).not.toContain('Translate');
64
+ });
65
+
66
+ it('★★ failed 非空 ⇒ 在 problem 那行后面再画一行(wb-longTaskStopWhy)', () => {
67
+ const problem = ROW.indexOf('className="wb-longTaskProblem"');
68
+ const why = ROW.indexOf('className="wb-longTaskStopWhy"');
69
+
70
+ expect(ROW).toContain("const stopWhy = stop?.failed ?? '';");
71
+ expect(ROW).toContain('className="wb-longTaskStopWhy">{stopWhy}');
72
+ expect(problem).toBeGreaterThan(0);
73
+ expect(why).toBeGreaterThan(problem);
74
+ });
75
+
76
+ it('★★ 没有 stop 的行一个像素都不变:既要的规则一个字没动,新规则也只在带钮的行上起作用', () => {
77
+ expect(CSS).toContain('.wb-longTaskHeadline {\n margin: 0;');
78
+ expect(CSS).toContain('.wb-longTaskLink {\n flex: none;\n}');
79
+ expect(CSS).toContain('.wb-longTaskHead {');
80
+ });
81
+ });
82
+
83
+ describe('两按停止钮:样式跟 dsh 那颗钮同款(组件自带,同目录一份 .css)', () => {
84
+ it('★★ 四个类都在同目录那份 .css 里', () => {
85
+ for (const cls of [
86
+ '.wb-longTaskStop {',
87
+ '.wb-longTaskStopArmed,',
88
+ '.wb-longTaskStopLabel {',
89
+ '.wb-longTaskStopWhy {',
90
+ ]) {
91
+ expect(CSS).toContain(cls);
92
+ }
93
+ });
94
+
95
+ it('★★ 上膛变宽(width: auto),取值照 dsh:error 45% 边框 + 12% 底 + gap 4 + padding 0 7px', () => {
96
+ expect(CSS).toMatch(/\.wb-longTaskStopArmed[\s\S]*width: auto/);
97
+ expect(CSS).toContain('color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent)');
98
+ expect(CSS).toContain('color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent)');
99
+ expect(CSS).toContain('gap: var(--wb-len-4)');
100
+ expect(CSS).toContain('padding: 0 var(--wb-len-7)');
101
+ });
102
+
103
+ it('★★ 上膛那颗字 11px / 18px;失败那行 error 色', () => {
104
+ expect(CSS).toContain('font-size: var(--dsw-font-xxxs-11-font-size)');
105
+ expect(CSS).toContain('line-height: var(--wb-line-18)');
106
+ expect(CSS).toMatch(/\.wb-longTaskStopWhy \{[\s\S]*--dsw-alias-state-error-primary/);
107
+ });
108
+
109
+ it('★ 一格注释都没有,颜色不写字面值(尺寸走 --wb-len-*)', () => {
110
+ expect(CSS).not.toContain('/*');
111
+ expect(CSS).not.toContain('#');
112
+ expect(CSS).toContain('var(--wb-len-20)');
113
+ expect(CSS).not.toMatch(/(^|[^a-z-])rgb\(/);
114
+ });
115
+ });
@@ -73,6 +73,9 @@ export const OPENCLI_MAX_CONCURRENT = 4;
73
73
  /** 启动预热站点那一跳的超时(毫秒);超时按未热对待,不挡面板。 */
74
74
  export const WARM_TIMEOUT_MS = 20_000;
75
75
 
76
+ /** 体检结果算「还新鲜」的时长(毫秒):期内再来的强制体检直接回上一次那份快照。 */
77
+ export const PREFLIGHT_FRESH_MS = 30_000;
78
+
76
79
  /** 装 / 卸模块包的超时(毫秒):pnpm 与取包内容都按它算。 */
77
80
  export const INSTALL_TIMEOUT_MS = 300_000;
78
81
 
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { BoardProbe } from '../../lib/board-fact.js';
3
+ import { boardFactOf } from '../board-fact.js';
4
+
5
+ const WORKSHOP = '/w/workbench/diy';
6
+
7
+ const probe = (over: Partial<BoardProbe> = {}): BoardProbe => ({
8
+ workbench: true,
9
+ where: WORKSHOP,
10
+ bar: true,
11
+ board: WORKSHOP,
12
+ open: true,
13
+ drawn: true,
14
+ ...over,
15
+ });
16
+
17
+ describe('板事实:一份判据,两边共用', () => {
18
+ it('★★ 画着的就是工坊那块板(含子路径)⇒ workshop', () => {
19
+ expect(boardFactOf(probe(), WORKSHOP)).toBe('workshop');
20
+ expect(boardFactOf(probe({ board: `${WORKSHOP}/packages` }), WORKSHOP)).toBe('workshop');
21
+ });
22
+
23
+ it('★★ 画的是别的项目的板 ⇒ other(前缀像但没落在里面也算 other)', () => {
24
+ expect(boardFactOf(probe({ board: '/w/project' }), WORKSHOP)).toBe('other');
25
+ expect(boardFactOf(probe({ board: `${WORKSHOP}-other` }), WORKSHOP)).toBe('other');
26
+ });
27
+
28
+ it('★★ 那块板没画在屏上 ⇒ hidden:不挂、没声明开着、量出来不可见,三条里任一条不成立都算', () => {
29
+ expect(boardFactOf(probe({ bar: false }), WORKSHOP)).toBe('hidden');
30
+ expect(boardFactOf(probe({ open: false }), WORKSHOP)).toBe('hidden');
31
+ expect(boardFactOf(probe({ drawn: false }), WORKSHOP)).toBe('hidden');
32
+ });
33
+
34
+ it('★★ 工作台没答话(旧外壳 / 夹具页)⇒ unknown:放行,但不许当成"就是工坊板"', () => {
35
+ expect(boardFactOf(probe({ workbench: false }), WORKSHOP)).toBe('unknown');
36
+ });
37
+
38
+ it('★ 画着板但说不清是哪块、或不知道工坊在哪 ⇒ unknown', () => {
39
+ expect(boardFactOf(probe({ board: '' }), WORKSHOP)).toBe('unknown');
40
+ expect(boardFactOf(probe(), null)).toBe('unknown');
41
+ });
42
+
43
+ it('★★ 顺序要紧:没画在屏上,就别去猜是哪块板', () => {
44
+ expect(boardFactOf(probe({ board: WORKSHOP, drawn: false }), WORKSHOP)).toBe('hidden');
45
+ expect(boardFactOf(probe({ workbench: false, bar: false }), WORKSHOP)).toBe('unknown');
46
+ });
47
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { DEBUG_STALE_MS, createDebugStore, parseDebugIntent, type DebugPact } from './diy-debug.js';
2
+ import { DEBUG_STALE_MS, createDebugStore, parseDebugIntent, type DebugPact } from '../diy-debug.js';
3
3
 
4
4
  const pact = (sessionId: string, now = 1000, unit = 'demo'): DebugPact => ({
5
5
  sessionId,
@@ -4,9 +4,10 @@ import {
4
4
  createRecordStore,
5
5
  parseRecordIntent,
6
6
  recordOwnerText,
7
+ shotDueOf,
7
8
  type RecordPact,
8
9
  type RecordState,
9
- } from './diy-record.js';
10
+ } from '../diy-record.js';
10
11
 
11
12
  const pact = (sessionId: string, now = 1000, unit = 'demo'): RecordPact => ({
12
13
  sessionId,
@@ -169,3 +170,138 @@ describe('用户在左栏按的三个键:意图词表只此一份', () => {
169
170
  }
170
171
  });
171
172
  });
173
+
174
+ describe('按计数落图的台账:落成才前进,失败如实记、同一个计数只重试一次', () => {
175
+ const shot = (n: number, extra: Partial<RecordState['shots'][number]> = {}) => ({
176
+ n,
177
+ at: 1500 + n,
178
+ path: `/w/diy/shots/rec-shot-demo-${String(n)}.png`,
179
+ ...extra,
180
+ });
181
+
182
+ it('★ begin 时台账是空的(还没人按过「截图」)', () => {
183
+ const store = createRecordStore();
184
+ const state = held(store.begin(pact('s1', 1000)));
185
+
186
+ expect(state.shots).toEqual([]);
187
+ expect(state.shotsSeen).toBe(0);
188
+ expect(state.shotTries).toBeNull();
189
+ });
190
+
191
+ it('★★ 落成一张 ⇒ 台账一条、shotsSeen 跟到那个计数、重试数清零', () => {
192
+ const store = createRecordStore();
193
+ store.begin(pact('s1', 1000));
194
+ store.shotFailed('s1', shot(1, { error: '第一次没成' }), 1501);
195
+
196
+ const state = held(store.shotTaken('s1', shot(1), 1502));
197
+
198
+ expect(state.shots).toEqual([shot(1)]);
199
+ expect(state.shotsSeen).toBe(1);
200
+ expect(state.shotTries).toBeNull();
201
+ });
202
+
203
+ it('★★ 落失败 ⇒ 那一条带 error 原文、shotsSeen 一步不前进、同一个计数重试一次就试满了', () => {
204
+ const store = createRecordStore();
205
+ store.begin(pact('s1', 1000));
206
+
207
+ const first = held(store.shotFailed('s1', shot(1, { error: 'opencli:截不下来' }), 1501));
208
+ expect(first.shots).toEqual([shot(1, { error: 'opencli:截不下来' })]);
209
+ expect(first.shotsSeen).toBe(0);
210
+ expect(first.shotTries).toEqual({ n: 1, tries: 1 });
211
+
212
+ const retried = held(store.shotFailed('s1', shot(1, { error: '还是截不下来' }), 1601));
213
+ expect(retried.shots).toEqual([shot(1, { error: '还是截不下来' })]);
214
+ expect(retried.shotTries).toEqual({ n: 1, tries: 2 });
215
+ });
216
+
217
+ it('★★ shotDueOf:没涨不拍 / 涨了才拍 / 试满两次就放下 / 他再按一下(计数换新)重来', () => {
218
+ const store = createRecordStore();
219
+ const state = held(store.begin(pact('s1', 1000)));
220
+
221
+ expect(shotDueOf(state, 0)).toBeNull();
222
+ expect(shotDueOf(state, 1)).toBe(1);
223
+ expect(shotDueOf(state, 2.9)).toBe(2);
224
+ expect(shotDueOf(state, Number.NaN)).toBeNull();
225
+
226
+ const done = held(store.shotTaken('s1', shot(1), 1500));
227
+ expect(shotDueOf(done, 1)).toBeNull();
228
+ expect(shotDueOf(done, 2)).toBe(2);
229
+
230
+ const failed = held(store.shotFailed('s1', shot(2, { error: '没成' }), 1501));
231
+ expect(shotDueOf(failed, 2)).toBe(2);
232
+
233
+ const tried = held(store.shotFailed('s1', shot(2, { error: '还是没成' }), 1502));
234
+ expect(shotDueOf(tried, 2)).toBeNull();
235
+ expect(shotDueOf(tried, 3)).toBe(3);
236
+ });
237
+
238
+ it('★ 台账按计数排好,且同一张只留最新那条(重试不叠行)', () => {
239
+ const store = createRecordStore();
240
+ store.begin(pact('s1', 1000));
241
+ store.shotTaken('s1', shot(2), 1500);
242
+ store.shotFailed('s1', shot(1, { error: '没成' }), 1501);
243
+
244
+ const state = held(store.shotFailed('s1', shot(1, { error: '重试也没成' }), 1502));
245
+
246
+ expect(state.shots.map((one) => one.n)).toEqual([1, 2]);
247
+ expect(state.shots).toHaveLength(2);
248
+ expect(state.shots[0]?.error).toBe('重试也没成');
249
+ });
250
+
251
+ it('★ 台账不是谁都能写的:别人的会话 / 没有那一场,当场拒', () => {
252
+ const store = createRecordStore();
253
+
254
+ expect(store.shotTaken('s1', shot(1), 1000)).toEqual({ ok: false, code: 'none' });
255
+ expect(store.shotFailed('s1', shot(1), 1000)).toEqual({ ok: false, code: 'none' });
256
+
257
+ store.begin(pact('s1', 1000));
258
+ expect(store.shotTaken('s2', shot(1), 1100)).toEqual({ ok: false, code: 'not-owner' });
259
+ expect(store.shotFailed('s2', shot(1), 1100)).toEqual({ ok: false, code: 'not-owner' });
260
+ expect(store.live(1100)?.shots).toEqual([]);
261
+ });
262
+ });
263
+
264
+ describe('门铃那一格:命中记一次,超时一个字不动', () => {
265
+ it('★★ begin 时 0 次;命中一次 +1,并把这一次等了多少毫秒记下', () => {
266
+ const store = createRecordStore();
267
+ const state = held(store.begin(pact('s1', 1000)));
268
+
269
+ expect(state.beats).toBe(0);
270
+ expect(state.beatMs).toBe(0);
271
+
272
+ const first = held(store.beat('s1', 42, 1200));
273
+ expect(first.beats).toBe(1);
274
+ expect(first.beatMs).toBe(42);
275
+ expect(first.at).toBe(1200);
276
+
277
+ const second = held(store.beat('s1', 7, 1300));
278
+ expect(second.beats).toBe(2);
279
+ expect(second.beatMs).toBe(7);
280
+ });
281
+
282
+ it('★★ 跟着相位走不丢(started / finished 都保留),收工连它一起清;别人的会话不许写', () => {
283
+ const store = createRecordStore();
284
+ store.begin(pact('s1', 1000));
285
+ store.beat('s1', 12, 1100);
286
+ store.started('s1', 2000, 2001);
287
+ store.finished('s1', 3000, 3002);
288
+
289
+ const live = store.live(3002);
290
+ expect(live?.beats).toBe(1);
291
+ expect(live?.beatMs).toBe(12);
292
+
293
+ expect(store.beat('s2', 5, 3100)).toEqual({ ok: false, code: 'not-owner' });
294
+ expect(held(store.beat('s1', 5, 3200)).beats).toBe(2);
295
+ expect(store.stop('s1', 3300).ok).toBe(true);
296
+ expect(store.live(3300)).toBeNull();
297
+ expect(store.beat('s1', 5, 3400)).toEqual({ ok: false, code: 'none' });
298
+ });
299
+
300
+ it('★ 负数 / 小数收成正经毫秒(读侧那一行不出现 "-0 毫秒" 这种话)', () => {
301
+ const store = createRecordStore();
302
+ store.begin(pact('s1', 1000));
303
+
304
+ expect(held(store.beat('s1', -3, 1100)).beatMs).toBe(0);
305
+ expect(held(store.beat('s1', 4.7, 1200)).beatMs).toBe(4);
306
+ });
307
+ });
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isFactScope, scopePrefixOf } from '../../api/facts.js';
3
+ import { unitFactScope } from '../facts.js';
4
+
5
+ describe('unitFactScope', () => {
6
+ it('命名空间 = 模块 id + `*`("这个模块的数据全作废"只写一个字符串)', () => {
7
+ expect(unitFactScope('cmdb')).toBe('cmdb*');
8
+ expect(isFactScope('cmdb*')).toBe(true);
9
+ expect(isFactScope('*')).toBe(false);
10
+ expect(isFactScope('cmdb/summary')).toBe(false);
11
+ expect(scopePrefixOf('cmdb*')).toBe('cmdb');
12
+ });
13
+ });
@@ -0,0 +1,306 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { LongTaskStep } from '../../api/long-task.js';
3
+ import {
4
+ createLongTaskHost,
5
+ createLongTaskRegistry,
6
+ sharedLongTaskRegistry,
7
+ type LongTaskBase,
8
+ type LongTaskDeps,
9
+ } from '../long-task.js';
10
+
11
+ interface Probe extends LongTaskBase {
12
+ readonly note?: string | undefined;
13
+ }
14
+
15
+ function harness(over: Partial<LongTaskDeps<Probe>> = {}) {
16
+ let signal: AbortSignal | undefined;
17
+ let step: ((step: LongTaskStep) => void) | undefined;
18
+ let settle: ((result: unknown) => void) | undefined;
19
+ let fail: ((error: unknown) => void) | undefined;
20
+ const base: LongTaskDeps<Probe> = {
21
+ now: () => '2026-10-21T02:00:00.000Z',
22
+ run: (options) =>
23
+ new Promise((resolve, reject) => {
24
+ signal = options.signal;
25
+ step = options.onStep;
26
+ settle = resolve as (result: unknown) => void;
27
+ fail = reject as (error: unknown) => void;
28
+ }),
29
+ view: (snapshot) => ({ target: snapshot.target, status: snapshot.status, note: snapshot.note }),
30
+ };
31
+ return {
32
+ deps: { ...base, ...over },
33
+ signal: () => signal,
34
+ step: (one: LongTaskStep) => step?.(one),
35
+ done: (result: unknown) => settle?.(result),
36
+ boom: (error: unknown) => fail?.(error),
37
+ };
38
+ }
39
+
40
+ const flush = async (): Promise<void> => {
41
+ await Promise.resolve();
42
+ await Promise.resolve();
43
+ };
44
+
45
+ describe('createLongTaskRegistry.stop(停止只是转发给生产方,认不认 signal 是模块的事)', () => {
46
+ it('★★ stop:run 拿到的 signal 被 abort,这一趟变 stopped,回 true', () => {
47
+ const h = harness();
48
+ const runs = createLongTaskRegistry(h.deps);
49
+ const started = runs.start({ workspace: '/w/a', target: 'release' });
50
+
51
+ expect(h.signal()?.aborted).toBe(false);
52
+ expect(runs.stop(started.id)).toBe(true);
53
+ expect(h.signal()?.aborted).toBe(true);
54
+ expect(runs.snapshot(started.id)?.status).toBe('stopped');
55
+ });
56
+
57
+ it('★★ 结算首胜:停过之后 run 再 resolve 也不许翻案(还是 stopped,不是 ok)', async () => {
58
+ const h = harness();
59
+ const runs = createLongTaskRegistry(h.deps);
60
+ const started = runs.start({ workspace: '/w/a', target: 'release' });
61
+
62
+ runs.stop(started.id);
63
+ h.done({ anything: true });
64
+ await flush();
65
+
66
+ expect(runs.snapshot(started.id)?.status).toBe('stopped');
67
+ });
68
+
69
+ it('★★ 被 abort 的 run 常常 reject(AbortError):那一趟不许变成 failed', async () => {
70
+ const h = harness();
71
+ const runs = createLongTaskRegistry(h.deps);
72
+ const started = runs.start({ workspace: '/w/a', target: 'release' });
73
+
74
+ runs.stop(started.id);
75
+ h.boom(new Error('This operation was aborted'));
76
+ await flush();
77
+
78
+ expect(runs.snapshot(started.id)?.status).toBe('stopped');
79
+ expect(runs.snapshot(started.id)?.why).toBeUndefined();
80
+ });
81
+
82
+ it('★★ 不是活的:已收尾 / 不认识的 id ⇒ 回 false,什么都没变', async () => {
83
+ const h = harness();
84
+ const runs = createLongTaskRegistry(h.deps);
85
+ const started = runs.start({ workspace: '/w/a', target: 'release' });
86
+
87
+ h.done({});
88
+ await flush();
89
+
90
+ expect(runs.stop(started.id)).toBe(false);
91
+ expect(runs.snapshot(started.id)?.status).toBe('ok');
92
+ expect(runs.stop('run-nope')).toBe(false);
93
+ expect(runs.current('/w/a')).toHaveLength(1);
94
+ });
95
+
96
+ it('★★ 停这一下走 deps.stopped(与 outcome 之于 ok 同形:停在哪、文案怎么写是模块的知识)', () => {
97
+ const h = harness({ stopped: () => ({ note: '用户按停的' }) });
98
+ const runs = createLongTaskRegistry(h.deps);
99
+ const started = runs.start({ workspace: '/w/a', target: 'release' });
100
+
101
+ runs.stop(started.id);
102
+
103
+ expect(runs.snapshot(started.id)?.note).toBe('用户按停的');
104
+ });
105
+
106
+ it('★ 等你登录时也能停:登录地址跟着清掉', () => {
107
+ const h = harness();
108
+ const runs = createLongTaskRegistry(h.deps);
109
+ const started = runs.start({ workspace: '/w/a', target: 'release' });
110
+
111
+ h.step({ kind: 'start', text: '未登录', loginUrl: 'https://x/login' });
112
+ expect(runs.snapshot(started.id)?.status).toBe('needs-login');
113
+
114
+ expect(runs.stop(started.id)).toBe(true);
115
+ expect(runs.snapshot(started.id)?.status).toBe('stopped');
116
+ expect(runs.snapshot(started.id)?.loginUrl).toBeUndefined();
117
+ });
118
+
119
+ it('★ 停过之后迟到的步骤也不进这一趟(结算首胜)', () => {
120
+ const h = harness();
121
+ const runs = createLongTaskRegistry(h.deps);
122
+ const started = runs.start({ workspace: '/w/a', target: 'release' });
123
+
124
+ runs.stop(started.id);
125
+ h.step({ kind: 'info', text: '迟到一步' });
126
+
127
+ expect(runs.snapshot(started.id)?.steps).toEqual([]);
128
+ expect(runs.snapshot(started.id)?.status).toBe('stopped');
129
+ });
130
+
131
+ it('★★ current() 每条都带 id(浏览器按 id 点那颗钮),view 覆盖不了', () => {
132
+ const h = harness({ view: (snapshot) => ({ id: 'spoofed', status: snapshot.status }) });
133
+ const runs = createLongTaskRegistry({ ...h.deps, nextId: () => 'run-1' });
134
+ const started = runs.start({ workspace: '/w/a', target: 'release' });
135
+
136
+ const [row] = runs.current('/w/a');
137
+
138
+ expect(started.id).toBe('run-1');
139
+ expect(row?.['id']).toBe('run-1');
140
+ });
141
+ });
142
+
143
+ describe('createLongTaskRegistry.live(外壳侧栏那颗 loading 点读的:不分工作区,只给活着的)', () => {
144
+ it('★★ 两个工作区各一趟在跑 ⇒ 两趟都在;收尾的 / 停掉的不在', async () => {
145
+ const h = harness();
146
+ const runs = createLongTaskRegistry(h.deps);
147
+ const a = runs.start({ workspace: '/w/a', target: 'release', session: 's-1' });
148
+ const b = runs.start({ workspace: '/w/b', target: 'board' });
149
+
150
+ expect(runs.live().map((one) => one.id)).toEqual([a.id, b.id]);
151
+ expect(runs.live().map((one) => one.session)).toEqual(['s-1', undefined]);
152
+
153
+ h.done({});
154
+ await flush();
155
+ expect(runs.live().map((one) => one.id)).toEqual([a.id]);
156
+
157
+ runs.stop(a.id);
158
+ expect(runs.live()).toEqual([]);
159
+ });
160
+
161
+ it('★ 要登录那种也算活着(与 longTaskDot 同一口径)', () => {
162
+ const h = harness();
163
+ const runs = createLongTaskRegistry(h.deps);
164
+ runs.start({ workspace: '/w/a', target: 'release' });
165
+
166
+ h.step({ kind: 'start', text: '未登录', loginUrl: 'https://x/login' });
167
+
168
+ expect(runs.live().map((one) => one.status)).toEqual(['needs-login']);
169
+ });
170
+
171
+ it('★★ 交回的是 base 快照(工作区 / 目标 / 来源会话都在),模块那层 view 投影不参与', () => {
172
+ const h = harness({ view: () => ({ hidden: true }) });
173
+ const runs = createLongTaskRegistry(h.deps);
174
+ runs.start({ workspace: '/w/a', target: 'release', session: 's-1' });
175
+
176
+ expect(runs.live()[0]).toMatchObject({
177
+ workspace: '/w/a',
178
+ target: 'release',
179
+ session: 's-1',
180
+ status: 'running',
181
+ });
182
+ });
183
+ });
184
+
185
+ describe('sharedLongTaskRegistry(那一份跨热重载存活)', () => {
186
+ it('★★ 槽里那份**上一版的注册表**(四个方法、没有 stop)仍被认回:升级那次重载不重建空表', () => {
187
+ const scope = `test-${String(Date.now())}-${Math.random()}`;
188
+ const slot = Symbol.for(`dsh-workbench:long-task:${scope}:demo`);
189
+ const stale = {
190
+ start: () => undefined,
191
+ snapshot: () => null,
192
+ active: () => null,
193
+ current: () => [],
194
+ onChange: () => () => {},
195
+ };
196
+ (globalThis as unknown as Record<symbol, unknown>)[slot] = stale;
197
+
198
+ const again = sharedLongTaskRegistry(scope, 'demo', harness().deps);
199
+
200
+ expect(again).toBe(stale);
201
+ expect(Object.hasOwn(again, 'stop')).toBe(false);
202
+ });
203
+
204
+ it('★ 认不出(热重载换了接口)⇒ 重造,槽里换成新表', () => {
205
+ const scope = `test-${String(Date.now())}-${Math.random()}`;
206
+ const slot = Symbol.for(`dsh-workbench:long-task:${scope}:demo`);
207
+ const stale = {
208
+ start: () => undefined,
209
+ snapshot: () => null,
210
+ active: () => null,
211
+ };
212
+ (globalThis as unknown as Record<symbol, unknown>)[slot] = stale;
213
+
214
+ const fresh = sharedLongTaskRegistry(scope, 'demo', harness().deps);
215
+
216
+ expect(fresh).not.toBe(stale);
217
+ expect((globalThis as unknown as Record<symbol, unknown>)[slot]).toBe(fresh);
218
+ expect(typeof fresh.stop).toBe('function');
219
+ });
220
+ });
221
+
222
+ describe('来源会话(谁的这一趟,决定停它要不要审批)', () => {
223
+ it('★★ start 把来源会话记进快照', () => {
224
+ const h = harness();
225
+ const runs = createLongTaskRegistry({ ...h.deps, nextId: () => 'run-1' });
226
+ const started = runs.start({ workspace: '/w/a', target: 'release', session: 's-1' });
227
+
228
+ expect(started.session).toBe('s-1');
229
+ expect(runs.snapshot('run-1')?.session).toBe('s-1');
230
+ });
231
+
232
+ it('★★ 不给来源 ⇒ 快照里连 session 这个键都没有(别把 undefined 塞进去)', () => {
233
+ const h = harness();
234
+ const runs = createLongTaskRegistry(h.deps);
235
+ const started = runs.start({ workspace: '/w/a', target: 'release' });
236
+
237
+ expect(started.session).toBeUndefined();
238
+ expect(Object.keys(runs.snapshot(started.id) ?? {})).not.toContain('session');
239
+ });
240
+
241
+ it('★★ stop(id, by):谁按的落在那条 settle 通知上', () => {
242
+ const h = harness();
243
+ const runs = createLongTaskRegistry(h.deps);
244
+ const seen: Array<{ kind: string; by: string | undefined; status: string }> = [];
245
+ runs.onChange((change) => {
246
+ seen.push({ kind: change.kind, by: change.by, status: change.snapshot.status });
247
+ });
248
+ const started = runs.start({ workspace: '/w/a', target: 'release', session: 's-1' });
249
+
250
+ expect(runs.stop(started.id, 's-2')).toBe(true);
251
+
252
+ expect(seen).toEqual([
253
+ { kind: 'start', by: undefined, status: 'running' },
254
+ { kind: 'settle', by: 's-2', status: 'stopped' },
255
+ ]);
256
+ });
257
+
258
+ it('★★ stop(id) 不给 by ⇒ 通知里的 by 是 undefined(界面上按的用户)', () => {
259
+ const h = harness();
260
+ const runs = createLongTaskRegistry(h.deps);
261
+ let settleBy: string | undefined = 'sentinel';
262
+ runs.onChange((change) => {
263
+ if (change.kind === 'settle') settleBy = change.by;
264
+ });
265
+ const started = runs.start({ workspace: '/w/a', target: 'release' });
266
+
267
+ expect(runs.stop(started.id)).toBe(true);
268
+ expect(settleBy).toBeUndefined();
269
+ });
270
+ });
271
+
272
+ describe('createLongTaskHost 的 notify(每次变更都叫一下,bump 那半照旧)', () => {
273
+ it('★★ 起 / 走一步 / 被停 三次都叫 notify;停那笔带着谁按的', () => {
274
+ const bumps: string[][] = [];
275
+ const seen: string[] = [];
276
+ const box = createLongTaskHost({
277
+ scope: `test-${String(Date.now())}-${Math.random()}`,
278
+ unit: 'demo',
279
+ bump: (facts) => bumps.push([...facts]),
280
+ notify: (change) => seen.push(`${change.kind}:${change.snapshot.status}:${change.by ?? '-'}`),
281
+ });
282
+ const h = harness();
283
+ const runs = box.host.create({ key: 'release', ...h.deps, nextId: () => 'run-1' });
284
+
285
+ runs.start({ workspace: '/w/a', target: 'release', session: 's-1' });
286
+ h.step({ kind: 'start', text: '走一步' });
287
+
288
+ expect(runs.stop('run-1', 's-1')).toBe(true);
289
+ expect(seen).toEqual(['start:running:-', 'step:running:-', 'settle:stopped:s-1']);
290
+ expect(bumps).toEqual([['demo/runs'], ['demo/runs'], ['demo/runs']]);
291
+ });
292
+
293
+ it('★ 没给 notify 也照常跑(老调用点不受影响)', () => {
294
+ const bumps: string[][] = [];
295
+ const box = createLongTaskHost({
296
+ scope: `test-${String(Date.now())}-${Math.random()}`,
297
+ unit: 'demo',
298
+ bump: (facts) => bumps.push([...facts]),
299
+ });
300
+ const h = harness();
301
+
302
+ box.host.create({ key: 'release', ...h.deps }).start({ workspace: '/w/a', target: 'release' });
303
+
304
+ expect(bumps).toEqual([['demo/runs']]);
305
+ });
306
+ });
@@ -5,7 +5,7 @@ import {
5
5
  createPanelStateStore,
6
6
  parseCardsReport,
7
7
  parseCrashReport,
8
- } from './panel-state';
8
+ } from '../panel-state.js';
9
9
 
10
10
  function card(unit: string, route: string, phase: 'idle' | 'loading' | 'ready' | 'error', at: number) {
11
11
  return { unit, route, phase, at };