@dsh-workbench/shared 0.3.5 → 0.3.6

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.
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { longTaskDot, longTaskPollMs, longTaskRunsOf, runsRouteOf } from '../long-task.js';
2
+ import {
3
+ longTaskDot,
4
+ longTaskPollMs,
5
+ longTaskRunsOf,
6
+ longTaskStopPress,
7
+ longTaskStopStage,
8
+ LONG_TASK_STOP_ARM_MS,
9
+ runsRouteOf,
10
+ stopRouteOf,
11
+ } from '../long-task.js';
3
12
 
4
13
  describe('runsRouteOf', () => {
5
14
  it('★ 事实名 = 路由名(三处同一个来源,改一处不可能漏另一处)', () => {
@@ -8,6 +17,14 @@ describe('runsRouteOf', () => {
8
17
  });
9
18
  });
10
19
 
20
+ describe('stopRouteOf(停止那条写路由)', () => {
21
+ it('★ 就是读路由 + `/stop`(读口与写口同一个单元名,不另造一套命名)', () => {
22
+ expect(stopRouteOf('cmdb')).toBe('cmdb/runs/stop');
23
+ expect(stopRouteOf('issues')).toBe('issues/runs/stop');
24
+ expect(stopRouteOf('cmdb')).toBe(`${runsRouteOf('cmdb')}/stop`);
25
+ });
26
+ });
27
+
11
28
  describe('longTaskDot', () => {
12
29
  it('★ 两个"还在等"的状态都转圈:等编排与等你登录是同一个信号', () => {
13
30
  expect(longTaskDot('running')).toBe('ongoing');
@@ -15,6 +32,29 @@ describe('longTaskDot', () => {
15
32
  expect(longTaskDot('ok')).toBe('done');
16
33
  expect(longTaskDot('failed')).toBe('error');
17
34
  });
35
+
36
+ it('★ 用户按停的那一趟是灰点:不是成功、不是出错,落进 idle', () => {
37
+ expect(longTaskDot('stopped')).toBe('idle');
38
+ });
39
+ });
40
+
41
+ describe('longTaskStopStage / longTaskStopPress(两按:第一次上膛,第二次才真发)', () => {
42
+ it('★ 三档:没上膛 / 上了膛 / 请求在飞;在飞压过一切', () => {
43
+ expect(longTaskStopStage({ armed: false, busy: false })).toBe('idle');
44
+ expect(longTaskStopStage({ armed: true, busy: false })).toBe('armed');
45
+ expect(longTaskStopStage({ armed: false, busy: true })).toBe('busy');
46
+ expect(longTaskStopStage({ armed: true, busy: true })).toBe('busy');
47
+ });
48
+
49
+ it('★ 按一下该干什么:idle → arm、armed → fire、busy → ignore(在飞时按不动)', () => {
50
+ expect(longTaskStopPress('idle')).toBe('arm');
51
+ expect(longTaskStopPress('armed')).toBe('fire');
52
+ expect(longTaskStopPress('busy')).toBe('ignore');
53
+ });
54
+
55
+ it('★ 上膛时限照 dsh:3 秒自己落膛', () => {
56
+ expect(LONG_TASK_STOP_ARM_MS).toBe(3000);
57
+ });
18
58
  });
19
59
 
20
60
  describe('longTaskRunsOf(这一读交回的"当前那些趟")', () => {
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 一条长任务的四种态;needs-login 与 running 一样算还没结束。
2
+ * 一条长任务的五种态;needs-login 与 running 一样算还没结束,stopped 是用户自己按停的。
3
3
  */
4
- export type LongTaskStatus = 'running' | 'needs-login' | 'ok' | 'failed';
4
+ export type LongTaskStatus = 'running' | 'needs-login' | 'stopped' | 'ok' | 'failed';
5
5
 
6
6
  /**
7
7
  * 长任务步骤流里的一行:kind 定样式,登录那一步带 loginUrl 给用户去点。
@@ -21,6 +21,15 @@ export function runsRouteOf(unit: string): string {
21
21
  return `${unit}/runs`;
22
22
  }
23
23
 
24
+ /**
25
+ * 停止那条写路由(走 actions 通道的资源名):`<单元>/runs/stop`。
26
+ * @param unit 单元 id。
27
+ * @returns <unit>/runs/stop。
28
+ */
29
+ export function stopRouteOf(unit: string): string {
30
+ return `${unit}/runs/stop`;
31
+ }
32
+
24
33
  /**
25
34
  * 长任务读口回的载荷:一串 run,形状由模块自己定,外壳只透传。
26
35
  */
@@ -63,8 +72,8 @@ export function longTaskRunsOf<T>(state: {
63
72
 
64
73
  /**
65
74
  * 把长任务态折成状态点的那一档。
66
- * @param status 长任务的四种态。
67
- * @returns ongoing / done / error / idle;needs-login 也算进行中。
75
+ * @param status 长任务的五种态。
76
+ * @returns ongoing / done / error / idle;needs-login 也算进行中;`stopped` 落进 idle —— 灰点:不是成功、不是出错,是用户自己按停的。
68
77
  */
69
78
  export function longTaskDot(status: LongTaskStatus): 'ongoing' | 'done' | 'error' | 'idle' {
70
79
  if (status === 'ok') return 'done';
@@ -72,3 +81,36 @@ export function longTaskDot(status: LongTaskStatus): 'ongoing' | 'done' | 'error
72
81
  if (status === 'running' || status === 'needs-login') return 'ongoing';
73
82
  return 'idle';
74
83
  }
84
+
85
+ /**
86
+ * 停止钮的三档:没上膛 / 上了膛 / 请求在飞。
87
+ */
88
+ export type LongTaskStopStage = 'idle' | 'armed' | 'busy';
89
+
90
+ /**
91
+ * 算停止钮现在在哪一档。
92
+ * @param state armed = 已经按过第一下、在等第二下;busy = 停止请求已经在飞。
93
+ * @returns busy 优先(在飞时按不动),其次 armed,否则 idle。
94
+ */
95
+ export function longTaskStopStage(state: {
96
+ readonly armed: boolean;
97
+ readonly busy: boolean;
98
+ }): LongTaskStopStage {
99
+ if (state.busy) return 'busy';
100
+ return state.armed ? 'armed' : 'idle';
101
+ }
102
+
103
+ /**
104
+ * 按一下该干什么:第一次只上膛('arm'),上了膛再按才真发('fire'),在飞时按不动('ignore')。
105
+ * @param stage 现在这一档(先过 `longTaskStopStage`)。
106
+ * @returns 'arm' / 'fire' / 'ignore',调用点照它上膛、落膛加发请求、或什么都不做。
107
+ */
108
+ export function longTaskStopPress(stage: LongTaskStopStage): 'arm' | 'fire' | 'ignore' {
109
+ if (stage === 'busy') return 'ignore';
110
+ return stage === 'armed' ? 'fire' : 'arm';
111
+ }
112
+
113
+ /**
114
+ * 上膛后多久自己落膛(毫秒)。3 秒 = dsh 那行现在的取值。
115
+ */
116
+ export const LONG_TASK_STOP_ARM_MS = 3000;
@@ -24,6 +24,10 @@
24
24
  gap: var(--wb-len-6);
25
25
  }
26
26
 
27
+ .wb-formRows > .wb-formRow[data-layout='stacked'] {
28
+ grid-template-columns: minmax(0, 1fr);
29
+ }
30
+
27
31
  .wb-formLabel {
28
32
  min-width: 0;
29
33
  font-size: var(--dsw-font-xs-13-font-size);
@@ -36,12 +40,39 @@
36
40
  white-space: normal;
37
41
  }
38
42
 
43
+ .wb-formRow[data-layout='stacked'] .wb-formHint,
44
+ .wb-formRow[data-layout='stacked'] .wb-formProblem {
45
+ grid-column: 1 / -1;
46
+ }
47
+
39
48
  .wb-formControl {
40
49
  display: flex;
41
50
  align-items: center;
42
51
  min-width: 0;
43
52
  }
44
53
 
54
+ .wb-formControl
55
+ > :where(
56
+ .wb-selectAnchor,
57
+ .wb-choiceMenu,
58
+ input:not(
59
+ [type='checkbox'],
60
+ [type='radio'],
61
+ [type='button'],
62
+ [type='submit'],
63
+ [type='reset'],
64
+ [type='image'],
65
+ [type='color'],
66
+ [type='range']
67
+ ),
68
+ select,
69
+ textarea
70
+ ) {
71
+ box-sizing: border-box;
72
+ width: 100%;
73
+ min-width: 0;
74
+ }
75
+
45
76
  .wb-formHint {
46
77
  grid-column: 2;
47
78
  font-size: var(--dsw-font-xxs-12-font-size);
@@ -84,11 +84,37 @@ describe('FormRow', () => {
84
84
 
85
85
  const FORM_CSS = readFileSync(new URL('./FormRows.css', import.meta.url), 'utf8');
86
86
 
87
+ function normalize(selector: string): string {
88
+ return selector
89
+ .replace(/\s+/g, ' ')
90
+ .replace(/\(\s+/g, '(')
91
+ .replace(/\s+\)/g, ')')
92
+ .trim();
93
+ }
94
+
95
+ function selectorParts(head: string): string[] {
96
+ const parts: string[] = [];
97
+ let depth = 0;
98
+ let current = '';
99
+ for (const char of head) {
100
+ if (char === '(') depth += 1;
101
+ if (char === ')') depth -= 1;
102
+ if (char === ',' && depth === 0) {
103
+ parts.push(normalize(current));
104
+ current = '';
105
+ continue;
106
+ }
107
+ current += char;
108
+ }
109
+ parts.push(normalize(current));
110
+ return parts;
111
+ }
112
+
87
113
  function declarations(selector: string): string[] {
88
114
  for (const block of FORM_CSS.split('}')) {
89
115
  const [head, ...rest] = block.split('{');
90
116
  if (rest.length === 0 || head === undefined) continue;
91
- if (head.trim() !== selector) continue;
117
+ if (!selectorParts(head).includes(normalize(selector))) continue;
92
118
  return rest
93
119
  .join('{')
94
120
  .split(';')
@@ -132,7 +158,51 @@ describe('标签列:同一个 FormRows 共用一列(ADR-0052 §11)', () =>
132
158
  );
133
159
  });
134
160
 
161
+ it('★ stacked 行在 FormRows 里也压得过 subgrid 与那条回退(同权重、靠后那条赢过的老坑)', () => {
162
+ expect(declarations(".wb-formRows > .wb-formRow[data-layout='stacked']")).toContain(
163
+ 'grid-template-columns: minmax(0, 1fr)',
164
+ );
165
+ });
166
+
167
+ it('★ stacked 行里 hint / problem 通栏(它们的 grid-column: 2 会造出隐式第二列)', () => {
168
+ expect(declarations(".wb-formRow[data-layout='stacked'] .wb-formHint")).toContain(
169
+ 'grid-column: 1 / -1',
170
+ );
171
+ expect(declarations(".wb-formRow[data-layout='stacked'] .wb-formProblem")).toContain(
172
+ 'grid-column: 1 / -1',
173
+ );
174
+ });
175
+
135
176
  it('★ 不是 FormRow 的子项(分组标题那种)通栏,别被塞进标签那一列', () => {
136
177
  expect(declarations('.wb-formRows > :not(.wb-formRow)')).toContain('grid-column: 1 / -1');
137
178
  });
138
179
  });
180
+
181
+ const EXCLUDED_INPUT_TYPES = [
182
+ 'checkbox',
183
+ 'radio',
184
+ 'button',
185
+ 'submit',
186
+ 'reset',
187
+ 'image',
188
+ 'color',
189
+ 'range',
190
+ ];
191
+ const CONTROL_FILL_SELECTOR = `.wb-formControl > :where(.wb-selectAnchor, .wb-choiceMenu, input:not(${EXCLUDED_INPUT_TYPES.map((one) => `[type='${one}']`).join(', ')}), select, textarea)`;
192
+
193
+ describe('控件槽那一格:字段控件铺满,宽度不归调用点写(ADR-0052 §13)', () => {
194
+ it('★ 那一格里的字段控件吃满控件槽:border-box + width 100% + min-width 0', () => {
195
+ const fill = declarations(CONTROL_FILL_SELECTOR);
196
+
197
+ expect(fill).toContain('box-sizing: border-box');
198
+ expect(fill).toContain('width: 100%');
199
+ expect(fill).toContain('min-width: 0');
200
+ });
201
+
202
+ it('★ 开关与按钮那一族不在名单里(拉宽一颗 switch、一颗 <input type=submit> 就是另一回事)', () => {
203
+ for (const type of EXCLUDED_INPUT_TYPES) {
204
+ expect(CONTROL_FILL_SELECTOR).toContain(`[type='${type}']`);
205
+ }
206
+ expect(FORM_CSS).not.toMatch(/\.wb-formControl\s*>\s*\*/);
207
+ });
208
+ });
@@ -45,4 +45,56 @@
45
45
  margin: var(--wb-len-4) 0 0;
46
46
  max-height: var(--wb-len-220);
47
47
  overflow: auto;
48
+ }
49
+
50
+ .wb-longTaskStop {
51
+ display: inline-flex;
52
+ flex: none;
53
+ align-items: center;
54
+ align-self: center;
55
+ justify-content: center;
56
+ width: var(--wb-len-20);
57
+ height: var(--wb-len-20);
58
+ padding: 0;
59
+ margin-left: auto;
60
+ border: 0.5px solid var(--dsw-alias-border-l2);
61
+ border-radius: var(--dsw-radius-sm);
62
+ background: var(--dsw-specific-menu);
63
+ color: var(--dsw-alias-label-secondary);
64
+ cursor: pointer;
65
+ }
66
+
67
+ .wb-longTaskStop:hover:not(:disabled),
68
+ .wb-longTaskStop:focus-visible {
69
+ border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
70
+ color: var(--dsw-alias-state-error-primary);
71
+ }
72
+
73
+ .wb-longTaskStop:disabled {
74
+ cursor: default;
75
+ }
76
+
77
+ .wb-longTaskStopArmed,
78
+ .wb-longTaskStopArmed:hover:not(:disabled),
79
+ .wb-longTaskStopArmed:focus-visible {
80
+ gap: var(--wb-len-4);
81
+ width: auto;
82
+ padding: 0 var(--wb-len-7);
83
+ border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
84
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
85
+ color: var(--dsw-alias-state-error-primary);
86
+ }
87
+
88
+ .wb-longTaskStopLabel {
89
+ white-space: nowrap;
90
+ font-size: var(--dsw-font-xxxs-11-font-size);
91
+ line-height: var(--wb-line-18);
92
+ }
93
+
94
+ .wb-longTaskStopWhy {
95
+ margin: 0;
96
+ font-size: var(--dsw-font-xxxs-11-font-size);
97
+ line-height: var(--wb-line-16);
98
+ color: var(--dsw-alias-state-error-primary);
99
+ overflow-wrap: anywhere;
48
100
  }
@@ -1,11 +1,12 @@
1
- import { longTaskDot, type LongTaskStatus } from '../api/long-task.js';
1
+ import { longTaskDot, longTaskStopPress, longTaskStopStage, LONG_TASK_STOP_ARM_MS, type LongTaskStatus } from '../api/long-task.js';
2
2
  import { Link } from './Link.js';
3
- import { Fragment, h, useState } from '../lib/react.js';
3
+ import { Fragment, h, useEffect, useState } from '../lib/react.js';
4
4
  import {
5
5
  CodeBlock,
6
6
  DisclosureRow,
7
7
  IconCodeOutlineRegular,
8
8
  IconFlatListOutlineRegular,
9
+ IconStopFillRegular,
9
10
  StateDot,
10
11
  } from '@deepseek-ai/dsh-client-ui-primitives';
11
12
  import './LongTaskRow.css';
@@ -19,7 +20,22 @@ export interface LongTaskRowLink {
19
20
  }
20
21
 
21
22
  /**
22
- * `LongTaskRow` 的契约:状态、标题、问题、链接,与两处可展开的原始输出。
23
+ * `LongTaskRow` 那颗两按停止钮的契约:确认之后真发的那一下、三句词、在飞与失败两态。
24
+ * `label` 是没上膛时那颗钮的 aria-label(dsh 的词:`停止任务 {label}`);`askLabel` 是上膛时那颗钮上显示的字(`确认停止`);
25
+ * `armedLabel` 是上膛时的 aria-label,不给就用 `askLabel`(`再次点击确认停止`);`busy` = 这一趟正在被停、钮禁用;
26
+ * `failed` 给了才在问题那行后面再画一行。
27
+ */
28
+ export interface LongTaskRowStop {
29
+ readonly onStop: () => void;
30
+ readonly label: string;
31
+ readonly askLabel: string;
32
+ readonly armedLabel?: string | undefined;
33
+ readonly busy?: boolean | undefined;
34
+ readonly failed?: string | null | undefined;
35
+ }
36
+
37
+ /**
38
+ * `LongTaskRow` 的契约:状态、标题、问题、链接,两处可展开的原始输出,与最右边那颗可选的停止钮。
23
39
  */
24
40
  export interface LongTaskRowProps {
25
41
  readonly status: LongTaskStatus;
@@ -32,6 +48,7 @@ export interface LongTaskRowProps {
32
48
  readonly rawTitle?: string | undefined;
33
49
  readonly copyLabel?: string | undefined;
34
50
  readonly copiedLabel?: string | undefined;
51
+ readonly stop?: LongTaskRowStop | undefined;
35
52
  }
36
53
 
37
54
  function RowLink({ href, text }: LongTaskRowLink) {
@@ -45,7 +62,7 @@ function RowLink({ href, text }: LongTaskRowLink) {
45
62
  /**
46
63
  * 长任务那一行:状态点 + 标题 + 链接,跑失败时把步骤与原始输出收在两处可展开里。
47
64
  * 发布、扫描、迁移这类跑得久的事都用它,别自己画进度。
48
- * @param props.status - `longTaskDot` 认的四态之一,决定状态点颜色与要不要画"失败"那两块。
65
+ * @param props.status - `longTaskDot` 认的五态之一,决定状态点颜色与要不要画"失败"那两块。
49
66
  * @param props.headline - 这一行在干什么(一句话)。
50
67
  * @param props.problem - 失败原因;不给不画。
51
68
  * @param props.links - 行尾的链接,通常指向产物或日志。
@@ -53,15 +70,38 @@ function RowLink({ href, text }: LongTaskRowLink) {
53
70
  * @param props.raw - 原始输出;只在失败时画,且要同时给 `rawTitle` 才成一块可展开。
54
71
  * @param props.stepsTitle - 步骤那块展开行的标题;`rawTitle` 同理是原始输出那块。
55
72
  * @param props.copyLabel - 复制钮文案:给了才画复制钮;`copiedLabel` 是复制成功后那句。
73
+ * @param props.stop - 给了才在最右边画那颗两按停止钮;**不给就一个像素都不变**。只在生产方认 `signal` 时给。
56
74
  * @returns 一行长任务。
57
75
  */
58
76
  export function LongTaskRow(props: LongTaskRowProps) {
59
77
  const [stepsOpen, setStepsOpen] = useState(false);
60
78
  const [rawOpen, setRawOpen] = useState(false);
79
+ const [armed, setArmed] = useState(false);
61
80
  const failed = props.status === 'failed';
62
81
  const problem = props.problem ?? '';
63
82
  const raw = (props.raw ?? '').trim();
64
83
  const steps = props.steps ?? [];
84
+ const stop = props.stop;
85
+ const busy = stop?.busy === true;
86
+ const stopWhy = stop?.failed ?? '';
87
+
88
+ useEffect(() => {
89
+ if (!armed) return undefined;
90
+ const timer = setTimeout(() => setArmed(false), LONG_TASK_STOP_ARM_MS);
91
+ return () => clearTimeout(timer);
92
+ }, [armed]);
93
+
94
+ const onStopPress = (): void => {
95
+ if (stop === undefined) return;
96
+ const press = longTaskStopPress(longTaskStopStage({ armed, busy }));
97
+ if (press === 'ignore') return;
98
+ if (press === 'arm') {
99
+ setArmed(true);
100
+ return;
101
+ }
102
+ setArmed(false);
103
+ stop.onStop();
104
+ };
65
105
 
66
106
  return (
67
107
  <div className="wb-longTask">
@@ -71,8 +111,23 @@ export function LongTaskRow(props: LongTaskRowProps) {
71
111
  {(props.links ?? []).map((link) => (
72
112
  <RowLink href={link.href} key={`${link.text}@${link.href}`} text={link.text} />
73
113
  ))}
114
+ {stop === undefined ? null : (
115
+ <button
116
+ type="button"
117
+ className={`wb-longTaskStop${armed ? ' wb-longTaskStopArmed' : ''}`}
118
+ data-stop-state={longTaskStopStage({ armed, busy })}
119
+ aria-label={armed ? stop.armedLabel ?? stop.askLabel : stop.label}
120
+ disabled={busy}
121
+ onClick={onStopPress}
122
+ onBlur={() => setArmed(false)}
123
+ >
124
+ <IconStopFillRegular size={12} />
125
+ {armed ? <span className="wb-longTaskStopLabel">{stop.askLabel}</span> : null}
126
+ </button>
127
+ )}
74
128
  </p>
75
129
  {problem === '' ? null : <p className="wb-longTaskProblem">{problem}</p>}
130
+ {stopWhy === '' ? null : <p className="wb-longTaskStopWhy">{stopWhy}</p>}
76
131
  {!failed ? null : (
77
132
  <Fragment>
78
133
  {steps.length === 0 || props.stepsTitle === undefined ? null : (
@@ -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
+ });