@abianbiya/specflow 0.1.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/LICENSE +21 -0
- package/README.md +46 -0
- package/extensions/index.ts +323 -0
- package/package.json +37 -0
- package/skills/specflow/SKILL.md +48 -0
- package/skills/specflow/references/design-phase.md +27 -0
- package/skills/specflow/references/execution-phase.md +55 -0
- package/skills/specflow/references/lifecycle-phase.md +57 -0
- package/skills/specflow/references/project-setup.md +9 -0
- package/skills/specflow/references/requirements-phase.md +33 -0
- package/skills/specflow/references/tasks-phase.md +35 -0
- package/skills/specflow/templates/project.md +57 -0
- package/src/controller.test.ts +195 -0
- package/src/controller.ts +119 -0
- package/src/parse.test.ts +487 -0
- package/src/parse.ts +389 -0
- package/src/render.test.ts +395 -0
- package/src/render.ts +350 -0
- package/src/shared.ts +93 -0
- package/src/trace.test.ts +182 -0
- package/src/trace.ts +135 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { SpecflowPhase, SpecflowSpec, SpecflowStatus } from "./parse.js";
|
|
3
|
+
import {
|
|
4
|
+
actionOptions,
|
|
5
|
+
documentOptions,
|
|
6
|
+
listText,
|
|
7
|
+
nextActionLine,
|
|
8
|
+
phaseLabel,
|
|
9
|
+
pickerOptions,
|
|
10
|
+
plainStyler,
|
|
11
|
+
renderDetailsLines,
|
|
12
|
+
renderWidgetLines,
|
|
13
|
+
selectActive,
|
|
14
|
+
taskOptions,
|
|
15
|
+
traceWarningLine,
|
|
16
|
+
type Truncate,
|
|
17
|
+
} from "./render.js";
|
|
18
|
+
|
|
19
|
+
const truncate: Truncate = (line, width) => (line.length <= width ? line : line.slice(0, width));
|
|
20
|
+
|
|
21
|
+
/** Minimal spec fixture; `dir` follows `name` unless the override sets it. */
|
|
22
|
+
function spec(over: Partial<SpecflowSpec> = {}): SpecflowSpec {
|
|
23
|
+
const merged = {
|
|
24
|
+
name: "demo",
|
|
25
|
+
status: "active" as SpecflowStatus,
|
|
26
|
+
statusSource: "frontmatter" as const,
|
|
27
|
+
legacy: false,
|
|
28
|
+
docs: {},
|
|
29
|
+
tasks: [],
|
|
30
|
+
criteria: [],
|
|
31
|
+
done: 0,
|
|
32
|
+
total: 0,
|
|
33
|
+
phase: 1 as SpecflowPhase,
|
|
34
|
+
gate: null,
|
|
35
|
+
mtimeMs: 0,
|
|
36
|
+
...over,
|
|
37
|
+
};
|
|
38
|
+
return { dir: `/r/.specflow/specs/${merged.name}`, ...merged } as SpecflowSpec;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("selectActive", () => {
|
|
42
|
+
test("gate-paused spec outranks an in-progress one", () => {
|
|
43
|
+
const paused = spec({ name: "paused", gate: "review", status: "active" });
|
|
44
|
+
const running = spec({ name: "running", status: "active", phase: 4, mtimeMs: 99 });
|
|
45
|
+
expect(selectActive([running, paused])?.name).toBe("paused");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("unknown status ranks alongside in-progress, above completed and archived", () => {
|
|
49
|
+
const unknown = spec({ name: "unknown", status: "unknown" });
|
|
50
|
+
const completed = spec({ name: "completed", status: "completed", phase: "done" });
|
|
51
|
+
const archived = spec({ name: "archived", status: "archived", phase: "archived" });
|
|
52
|
+
expect(selectActive([archived, completed, unknown])?.name).toBe("unknown");
|
|
53
|
+
expect(selectActive([archived, completed])?.name).toBe("completed");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("ties break on newest mtime, then name ascending", () => {
|
|
57
|
+
const older = spec({ name: "aaa", mtimeMs: 1 });
|
|
58
|
+
const newer = spec({ name: "zzz", mtimeMs: 2 });
|
|
59
|
+
expect(selectActive([older, newer])?.name).toBe("zzz");
|
|
60
|
+
const b = spec({ name: "bbb", mtimeMs: 5 });
|
|
61
|
+
const a = spec({ name: "aaa", mtimeMs: 5 });
|
|
62
|
+
expect(selectActive([b, a])?.name).toBe("aaa");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a pin wins while the spec still exists, otherwise ranking reapplies", () => {
|
|
66
|
+
const wanted = spec({ name: "wanted", status: "archived", phase: "archived" });
|
|
67
|
+
const other = spec({ name: "other", status: "active" });
|
|
68
|
+
expect(selectActive([wanted, other], wanted.dir)?.name).toBe("wanted");
|
|
69
|
+
expect(selectActive([other], wanted.dir)?.name).toBe("other");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("no specs selects nothing", () => {
|
|
73
|
+
expect(selectActive([])).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("phaseLabel", () => {
|
|
78
|
+
test("covers every inferred phase", () => {
|
|
79
|
+
expect(phaseLabel(1)).toBe("Phase 1/4 Requirements");
|
|
80
|
+
expect(phaseLabel(2)).toBe("Phase 2/4 Design");
|
|
81
|
+
expect(phaseLabel(3)).toBe("Phase 3/4 Tasks");
|
|
82
|
+
expect(phaseLabel(4)).toBe("Phase 4/4 Execution");
|
|
83
|
+
expect(phaseLabel("done")).toBe("Done");
|
|
84
|
+
expect(phaseLabel("archived")).toBe("Archived");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("renderWidgetLines", () => {
|
|
89
|
+
test("renders rule, heading with status and count, and the phase rail", () => {
|
|
90
|
+
const lines = renderWidgetLines(spec({ name: "specflow-pi", status: "active", done: 3, total: 6, phase: 4 }), 80, 6, truncate, plainStyler);
|
|
91
|
+
expect(lines).toHaveLength(3);
|
|
92
|
+
expect(lines[0].startsWith(" ─")).toBe(true);
|
|
93
|
+
expect(lines[1]).toContain("Specflow: specflow-pi");
|
|
94
|
+
expect(lines[1]).toContain("· active · 3/6");
|
|
95
|
+
expect(lines[2]).toContain("Phase 4/4 Execution");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("omits the count segment when no task is declared", () => {
|
|
99
|
+
const lines = renderWidgetLines(spec({ phase: 1 }), 80, 6, truncate, plainStyler);
|
|
100
|
+
expect(lines[1]).toContain("Specflow: demo · active");
|
|
101
|
+
expect(lines[1]).not.toContain("0/0");
|
|
102
|
+
expect(lines[2]).toContain("Phase 1/4 Requirements");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("gate badge is orthogonal to the phase label and only when present", () => {
|
|
106
|
+
const gated = renderWidgetLines(spec({ phase: 3, gate: "review" }), 80, 6, truncate, plainStyler);
|
|
107
|
+
expect(gated[2]).toContain("Phase 3/4 Tasks");
|
|
108
|
+
expect(gated[2]).toContain("awaiting your review");
|
|
109
|
+
const clear = renderWidgetLines(spec({ phase: 3 }), 80, 6, truncate, plainStyler);
|
|
110
|
+
expect(clear[2]).not.toContain("awaiting your review");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("marks unreadable specs and honors the line budget", () => {
|
|
114
|
+
const lines = renderWidgetLines(spec({ error: "EACCES", total: 2, done: 1 }), 80, 6, truncate, plainStyler);
|
|
115
|
+
expect(lines[1]).toContain("unreadable");
|
|
116
|
+
expect(renderWidgetLines(spec(), 80, 2, truncate, plainStyler)).toHaveLength(2);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("truncates overlong lines to the panel width", () => {
|
|
120
|
+
const lines = renderWidgetLines(spec({ name: "x".repeat(200) }), 40, 6, truncate, plainStyler);
|
|
121
|
+
for (const line of lines) expect(line.length).toBeLessThanOrEqual(40);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("listText / pickerOptions", () => {
|
|
126
|
+
test("lists name · status · done/total · gate with the count omitted when absent", () => {
|
|
127
|
+
const text = listText([
|
|
128
|
+
spec({ name: "with-tasks", status: "active", done: 1, total: 4 }),
|
|
129
|
+
spec({ name: "gate-only", status: "active", gate: "review", phase: 1 }),
|
|
130
|
+
spec({ name: "broken", status: "unknown", error: "EACCES" }),
|
|
131
|
+
]);
|
|
132
|
+
expect(text.split("\n")).toEqual(["with-tasks · active · 1/4", "gate-only · active · awaiting review", "broken · unknown (unreadable)"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("never lists anything when there are no specs", () => {
|
|
136
|
+
expect(listText([])).toBe("");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("the text listing disambiguates duplicate basenames too (F2)", () => {
|
|
140
|
+
const text = listText([
|
|
141
|
+
spec({ name: "foo", dir: "/r/.specflow/specs/foo", done: 0, total: 1 }),
|
|
142
|
+
spec({ name: "foo", dir: "/r/.specflow/specs/archived/foo", status: "archived", phase: "archived" }),
|
|
143
|
+
]);
|
|
144
|
+
expect(text.split("\n")).toEqual(["foo (specs) · active · 0/1", "foo (archived) · archived"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("picker labels are sanitized while the raw dir round-trips", () => {
|
|
148
|
+
const raw = spec({ name: "evil \u001b[31mname" });
|
|
149
|
+
const [option] = pickerOptions([raw]);
|
|
150
|
+
expect(option.label).toBe("evil name · active");
|
|
151
|
+
expect(option.dir).toBe(raw.dir);
|
|
152
|
+
expect(pickerOptions([raw]).find((o) => o.label === option.label)?.dir).toBe(raw.dir);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a name containing ' · ' still round-trips through the label", () => {
|
|
156
|
+
const odd = spec({ name: "a · b" });
|
|
157
|
+
const options = pickerOptions([odd]);
|
|
158
|
+
expect(options.find((o) => o.label === options[0].label)?.dir).toBe(odd.dir);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("same basename in flat and legacy layouts yields distinct, pinnable labels (F2)", () => {
|
|
162
|
+
const flat = spec({ name: "foo", dir: "/r/.specflow/specs/foo", status: "active" });
|
|
163
|
+
const legacy = spec({ name: "foo", dir: "/r/.specflow/specs/active/foo", status: "active", legacy: true });
|
|
164
|
+
const options = pickerOptions([flat, legacy]);
|
|
165
|
+
expect(new Set(options.map((o) => o.label)).size).toBe(2);
|
|
166
|
+
// every label maps back to exactly one directory
|
|
167
|
+
for (const o of options) expect(options.filter((x) => x.label === o.label)).toHaveLength(1);
|
|
168
|
+
expect(options.find((o) => o.label.includes("(specs)"))?.dir).toBe(flat.dir);
|
|
169
|
+
expect(options.find((o) => o.label.includes("(active)"))?.dir).toBe(legacy.dir);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("duplicate basenames in the same parent disambiguate deeper (F2)", () => {
|
|
173
|
+
const a = spec({ name: "foo", dir: "/r/.specflow/specs/a/foo" });
|
|
174
|
+
const b = spec({ name: "foo", dir: "/r/.specflow/specs/b/foo" });
|
|
175
|
+
const options = pickerOptions([a, b]);
|
|
176
|
+
expect(new Set(options.map((o) => o.label)).size).toBe(2);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("distinct names are never decorated with a path suffix", () => {
|
|
180
|
+
const options = pickerOptions([spec({ name: "one" }), spec({ name: "two" })]);
|
|
181
|
+
expect(options.map((o) => o.label)).toEqual(["one · active", "two · active"]);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("documentOptions", () => {
|
|
186
|
+
test("offers the three spec documents plus project.md", () => {
|
|
187
|
+
const options = documentOptions(spec({ name: "feat" }), "/r/.specflow/project.md");
|
|
188
|
+
expect(options.map((o) => o.label)).toEqual(["requirements.md", "design.md", "tasks.md", "project.md"]);
|
|
189
|
+
expect(options.map((o) => o.path)).toEqual([
|
|
190
|
+
"/r/.specflow/specs/feat/requirements.md",
|
|
191
|
+
"/r/.specflow/specs/feat/design.md",
|
|
192
|
+
"/r/.specflow/specs/feat/tasks.md",
|
|
193
|
+
"/r/.specflow/project.md",
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("prefers the discovered path when parse.ts reported one", () => {
|
|
198
|
+
const options = documentOptions(
|
|
199
|
+
spec({ name: "legacy", docs: { requirements: "/r/.specflow/specs/active/legacy/requirements.md" } }),
|
|
200
|
+
"/r/.specflow/project.md",
|
|
201
|
+
);
|
|
202
|
+
expect(options[0].path).toBe("/r/.specflow/specs/active/legacy/requirements.md");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("renderDetailsLines", () => {
|
|
207
|
+
test("frames the popup and reports the scroll window when content overflows", () => {
|
|
208
|
+
const body = Array.from({ length: 40 }, (_, i) => `line ${i + 1}`);
|
|
209
|
+
const lines = renderDetailsLines("feat · design.md", body, 40, 12, 0, truncate, plainStyler, {
|
|
210
|
+
indent: " ",
|
|
211
|
+
scrollbar: Array.from({ length: 9 }, () => "░"),
|
|
212
|
+
plainBody: true,
|
|
213
|
+
border: true,
|
|
214
|
+
});
|
|
215
|
+
expect(lines[0].startsWith("┌")).toBe(true);
|
|
216
|
+
expect(lines.at(-1)?.startsWith("└")).toBe(true);
|
|
217
|
+
expect(lines.join("\n")).toContain("lines 1–9 of 40");
|
|
218
|
+
expect(lines.join("\n")).toContain("feat · design.md");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("no overflow indicator when the body fits", () => {
|
|
222
|
+
const lines = renderDetailsLines("h", ["one"], 40, 12, 0, truncate, plainStyler, {});
|
|
223
|
+
expect(lines.join("\n")).not.toContain("lines 1–");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("clamps an out-of-range scroll offset", () => {
|
|
227
|
+
const body = Array.from({ length: 40 }, (_, i) => `line ${i + 1}`);
|
|
228
|
+
const lines = renderDetailsLines("h", body, 60, 12, 999, truncate, plainStyler, {});
|
|
229
|
+
expect(lines.join("\n")).toContain("lines 32–40 of 40");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
/** A task fixture whose detail rows are exactly what parse.ts stores. */
|
|
234
|
+
function task(id: string, done = false, details: string[] = []) {
|
|
235
|
+
return { id, title: `${id} work`, done, details };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
describe("nextActionLine (AC4)", () => {
|
|
239
|
+
test("names the first ready task in document order", () => {
|
|
240
|
+
const s = spec({
|
|
241
|
+
tasks: [task("1.1"), task("1.2", false, ["- Depends on: 1.1"]), task("2.1")],
|
|
242
|
+
total: 3,
|
|
243
|
+
});
|
|
244
|
+
expect(nextActionLine(s)).toBe("Next: 1.1 1.1 work");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("skips a done task and reports the next ready one", () => {
|
|
248
|
+
const s = spec({ tasks: [task("1.1", true), task("1.2")], done: 1, total: 2 });
|
|
249
|
+
expect(nextActionLine(s)).toBe("Next: 1.2 1.2 work");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("waits with the blocking ids when nothing is ready", () => {
|
|
253
|
+
const s = spec({ tasks: [task("1.1"), task("2.1", false, ["- Depends on: 1.1"])], total: 2 });
|
|
254
|
+
expect(nextActionLine(s)).toBe("Next: 1.1 1.1 work");
|
|
255
|
+
// mutual dependency: every unfinished task waits, so nothing is runnable
|
|
256
|
+
const cycle = spec({
|
|
257
|
+
tasks: [task("1.1", false, ["- Depends on: 2.1"]), task("2.1", false, ["- Depends on: 1.1"])],
|
|
258
|
+
total: 2,
|
|
259
|
+
});
|
|
260
|
+
expect(nextActionLine(cycle)).toBe("Waiting: 1.1 1.1 work (blocked by 2.1)");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("names an unknown dependency instead of treating it as satisfied", () => {
|
|
264
|
+
const s = spec({ tasks: [task("2.1", false, ["- Depends on: 9.9"])], total: 1 });
|
|
265
|
+
expect(nextActionLine(s)).toBe("Waiting: 2.1 (unknown dependency 9.9)");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("reports completion, and says nothing when the spec declares no tasks", () => {
|
|
269
|
+
expect(nextActionLine(spec({ tasks: [task("1.1", true)], done: 1, total: 1 }))).toBe("All tasks done");
|
|
270
|
+
expect(nextActionLine(spec({ total: 0 }))).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("traceWarningLine (AC5)", () => {
|
|
275
|
+
test("stays silent for a consistent spec", () => {
|
|
276
|
+
const s = spec({ tasks: [task("1.1", false, ["- Criteria: AC1"])], criteria: ["AC1"], total: 1 });
|
|
277
|
+
expect(traceWarningLine(s)).toBeUndefined();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("counts unclaimed ACs, singulars an orphan criterion, and reports dangling deps", () => {
|
|
281
|
+
expect(traceWarningLine(spec({ tasks: [task("1.1")], criteria: ["AC1", "AC2"], total: 1 }))).toBe(
|
|
282
|
+
"⚠ 2 unclaimed AC",
|
|
283
|
+
);
|
|
284
|
+
expect(traceWarningLine(spec({ tasks: [task("1.1", false, ["- Criteria: AC7"])], criteria: [], total: 1 }))).toBe(
|
|
285
|
+
"⚠ 1 orphan criterion",
|
|
286
|
+
);
|
|
287
|
+
expect(traceWarningLine(spec({ tasks: [task("2.1", false, ["- Depends on: 9.9"])], total: 1 }))).toBe(
|
|
288
|
+
"⚠ 1 dangling dep",
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("joins several problems into one line", () => {
|
|
293
|
+
const s = spec({
|
|
294
|
+
tasks: [task("1.1", false, ["- Criteria: AC7"]), task("2.1", false, ["- Depends on: 9.9"])],
|
|
295
|
+
criteria: ["AC1"],
|
|
296
|
+
total: 2,
|
|
297
|
+
});
|
|
298
|
+
expect(traceWarningLine(s)).toBe("⚠ 1 unclaimed AC · 1 orphan criterion · 1 dangling dep");
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("taskOptions (AC7)", () => {
|
|
303
|
+
test("marks ready tasks and blocked ones with what they wait on, excluding done tasks", () => {
|
|
304
|
+
const s = spec({
|
|
305
|
+
tasks: [task("1.1", true), task("1.2"), task("2.1", false, ["- Depends on: 1.2"])],
|
|
306
|
+
done: 1,
|
|
307
|
+
total: 3,
|
|
308
|
+
});
|
|
309
|
+
const options = taskOptions(s);
|
|
310
|
+
expect(options.map((o) => o.label)).toEqual([
|
|
311
|
+
"▶ 1.2 1.2 work",
|
|
312
|
+
"⏸ 2.1 2.1 work (blocked by 1.2)",
|
|
313
|
+
]);
|
|
314
|
+
expect(options.map((o) => o.ready)).toEqual([true, false]);
|
|
315
|
+
expect(options.map((o) => o.task.id)).toEqual(["1.2", "2.1"]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("sanitizes ids and titles and marks an unknown dependency", () => {
|
|
319
|
+
const evil = { id: "1.1", title: "evil\u001b[31mtitle", done: false, details: ["- Depends on: 9.9"] };
|
|
320
|
+
const [option] = taskOptions(spec({ tasks: [evil], total: 1 }));
|
|
321
|
+
expect(option.label).toBe("⏸ 1.1 eviltitle (blocked by unknown dep)");
|
|
322
|
+
expect(option.label).not.toContain("\u001b");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("no unfinished task yields no options", () => {
|
|
326
|
+
expect(taskOptions(spec({ tasks: [task("1.1", true)], done: 1, total: 1 }))).toEqual([]);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("actionOptions (AC6)", () => {
|
|
331
|
+
test("offers execute, validate, document and toggle for a running spec", () => {
|
|
332
|
+
const s = spec({ tasks: [task("1.1")], total: 1 });
|
|
333
|
+
expect(actionOptions(s, false)).toEqual([
|
|
334
|
+
{ label: "Execute a task…", action: "execute" },
|
|
335
|
+
{ label: "Validate implementation", action: "validate" },
|
|
336
|
+
{ label: "Open document…", action: "document" },
|
|
337
|
+
{ label: "Hide panel", action: "toggle" },
|
|
338
|
+
]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("adds approve only when a gate is pending", () => {
|
|
342
|
+
const gated = spec({ tasks: [task("1.1")], total: 1, gate: "review", phase: 3 });
|
|
343
|
+
expect(actionOptions(gated, true).map((a) => a.action)).toEqual([
|
|
344
|
+
"execute",
|
|
345
|
+
"approve",
|
|
346
|
+
"validate",
|
|
347
|
+
"document",
|
|
348
|
+
"toggle",
|
|
349
|
+
]);
|
|
350
|
+
expect(actionOptions(gated, true).at(-1)?.label).toBe("Show panel");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("hides execute and validate when there is no task work", () => {
|
|
354
|
+
expect(actionOptions(spec({ total: 0 }), false).map((a) => a.action)).toEqual(["document", "toggle"]);
|
|
355
|
+
expect(actionOptions(spec({ tasks: [task("1.1", true)], done: 1, total: 1 }), false).map((a) => a.action)).toEqual([
|
|
356
|
+
"validate",
|
|
357
|
+
"document",
|
|
358
|
+
"toggle",
|
|
359
|
+
]);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe("renderWidgetLines with the cockpit rows (AC4, AC5)", () => {
|
|
364
|
+
test("adds the next-action row and the warning row within the budget", () => {
|
|
365
|
+
const s = spec({
|
|
366
|
+
tasks: [task("1.1", false, ["- Criteria: AC7"])],
|
|
367
|
+
criteria: ["AC1"],
|
|
368
|
+
total: 1,
|
|
369
|
+
phase: 4,
|
|
370
|
+
});
|
|
371
|
+
const lines = renderWidgetLines(s, 90, 6, truncate, plainStyler);
|
|
372
|
+
expect(lines).toHaveLength(5);
|
|
373
|
+
expect(lines[3]).toBe(" Next: 1.1 1.1 work");
|
|
374
|
+
expect(lines[4]).toBe(" ⚠ 1 unclaimed AC · 1 orphan criterion");
|
|
375
|
+
expect(lines.every((l) => l.length <= 90)).toBe(true);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("a consistent spec shows no warning row", () => {
|
|
379
|
+
const s = spec({ tasks: [task("1.1", false, ["- Criteria: AC1"])], criteria: ["AC1"], total: 1 });
|
|
380
|
+
const lines = renderWidgetLines(s, 90, 6, truncate, plainStyler);
|
|
381
|
+
expect(lines).toHaveLength(4);
|
|
382
|
+
expect(lines.some((l) => l.includes("⚠"))).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("a requirements-only spec keeps the three base rows", () => {
|
|
386
|
+
const lines = renderWidgetLines(spec({ phase: 1 }), 90, 6, truncate, plainStyler);
|
|
387
|
+
expect(lines).toHaveLength(3);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("respects a tighter cap than the number of rows available", () => {
|
|
391
|
+
const s = spec({ tasks: [task("1.1", false, ["- Criteria: AC7"])], criteria: [], total: 1 });
|
|
392
|
+
expect(renderWidgetLines(s, 90, 4, truncate, plainStyler)).toHaveLength(4);
|
|
393
|
+
expect(renderWidgetLines(s, 90, 2, truncate, plainStyler)).toHaveLength(2);
|
|
394
|
+
});
|
|
395
|
+
});
|