@webappwiz/arbor 0.0.28 → 0.0.30
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/AGENTS.md +7 -0
- package/README.md +34 -12
- package/add.test.ts +125 -0
- package/add.ts +192 -0
- package/age.ts +11 -0
- package/arbor.test.ts +145 -0
- package/arbor.ts +429 -0
- package/assets.d.ts +9 -0
- package/attachments.ts +87 -0
- package/build.ts +98 -0
- package/claim.test.ts +89 -0
- package/claim.ts +73 -0
- package/components.json +21 -0
- package/config.ts +58 -0
- package/dev/api.ts +120 -0
- package/dev/app.test.tsx +645 -0
- package/dev/app.tsx +80 -0
- package/dev/assets.ts +16 -0
- package/dev/build/main.txt +68 -0
- package/dev/build/shell.txt +21 -0
- package/dev/build/styles.txt +2263 -0
- package/dev/components/ui/badge.tsx +51 -0
- package/dev/components/ui/button.tsx +57 -0
- package/dev/components/ui/dialog.tsx +154 -0
- package/dev/components/ui/empty.tsx +100 -0
- package/dev/components/ui/input-group.tsx +155 -0
- package/dev/components/ui/input.tsx +19 -0
- package/dev/components/ui/progress.tsx +80 -0
- package/dev/components/ui/textarea.tsx +17 -0
- package/dev/components/ui/toast.tsx +227 -0
- package/dev/feed.ts +69 -0
- package/dev/files.tsx +191 -0
- package/dev/index.html +21 -0
- package/dev/lib/utils.ts +7 -0
- package/dev/main.tsx +22 -0
- package/dev/markdown.test.tsx +133 -0
- package/dev/markdown.tsx +124 -0
- package/dev/mentions.tsx +319 -0
- package/dev/shadcn.css +649 -0
- package/dev/styles.css +107 -0
- package/dev/tags.tsx +44 -0
- package/dev/tasks.tsx +151 -0
- package/dev/todos.tsx +759 -0
- package/dev.test.ts +439 -0
- package/dev.ts +386 -0
- package/e2e.test.ts +256 -0
- package/escalate.test.ts +76 -0
- package/escalate.ts +112 -0
- package/exit.test.ts +71 -0
- package/exit.ts +74 -0
- package/git.ts +294 -0
- package/index.ts +9 -0
- package/journal.ts +89 -0
- package/list.test.ts +82 -0
- package/list.ts +137 -0
- package/load-config.test.ts +121 -0
- package/load-config.ts +62 -0
- package/log.test.ts +65 -0
- package/log.ts +40 -0
- package/merge.test.ts +332 -0
- package/merge.ts +283 -0
- package/package.json +32 -15
- package/path.test.ts +35 -0
- package/path.ts +38 -0
- package/plan.test.ts +318 -0
- package/plan.ts +302 -0
- package/progress.test.ts +54 -0
- package/progress.ts +31 -0
- package/remove.test.ts +68 -0
- package/remove.ts +88 -0
- package/repository.ts +83 -0
- package/retry.test.ts +43 -0
- package/retry.ts +48 -0
- package/shell.ts +44 -0
- package/show.test.ts +91 -0
- package/show.ts +151 -0
- package/snapshot.test.ts +33 -0
- package/snapshot.ts +84 -0
- package/table.ts +20 -0
- package/testing.ts +229 -0
- package/todo.test.ts +490 -0
- package/todo.ts +834 -0
- package/wait.test.ts +61 -0
- package/wait.ts +77 -0
- package/worktree-service.test.ts +158 -0
- package/worktree-service.ts +193 -0
- package/worktree.ts +239 -0
- package/add.d.ts +0 -23
- package/age.d.ts +0 -2
- package/arbor.d.ts +0 -11
- package/attachments.d.ts +0 -36
- package/claim.d.ts +0 -11
- package/config.d.ts +0 -55
- package/config.js +0 -7
- package/dev/assets.d.ts +0 -11
- package/dev.d.ts +0 -47
- package/escalate.d.ts +0 -28
- package/exit.d.ts +0 -42
- package/git.d.ts +0 -77
- package/index.d.ts +0 -2
- package/index.js +0 -4669
- package/journal.d.ts +0 -29
- package/list.d.ts +0 -18
- package/load-config.d.ts +0 -15
- package/log.d.ts +0 -14
- package/merge.d.ts +0 -24
- package/path.d.ts +0 -13
- package/plan.d.ts +0 -83
- package/remove.d.ts +0 -19
- package/repository.d.ts +0 -25
- package/retry.d.ts +0 -17
- package/shell.d.ts +0 -17
- package/show.d.ts +0 -53
- package/snapshot.d.ts +0 -31
- package/table.d.ts +0 -5
- package/todo.d.ts +0 -230
- package/wait.d.ts +0 -22
- package/worktree-service.d.ts +0 -51
- package/worktree.d.ts +0 -95
package/todo.test.ts
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { color } from "webappwiz/log";
|
|
3
|
+
import { add } from "./add";
|
|
4
|
+
import { merge } from "./merge";
|
|
5
|
+
import { PLAN_FILE } from "./plan";
|
|
6
|
+
import { remove } from "./remove";
|
|
7
|
+
import { Testing } from "./testing";
|
|
8
|
+
import {
|
|
9
|
+
recommend,
|
|
10
|
+
type TodoState,
|
|
11
|
+
todoAdd,
|
|
12
|
+
todoList,
|
|
13
|
+
todoRelease,
|
|
14
|
+
todoRemove,
|
|
15
|
+
todoShow,
|
|
16
|
+
todoTags,
|
|
17
|
+
todoTake,
|
|
18
|
+
todoUpdate,
|
|
19
|
+
} from "./todo";
|
|
20
|
+
|
|
21
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/** Backdates a todo, since `merge` offers one waiting too long for removal. */
|
|
24
|
+
async function age(deps: Testing, id: number, days: number): Promise<void> {
|
|
25
|
+
const todo = await deps.todos.find(id);
|
|
26
|
+
await deps.todos.save({
|
|
27
|
+
...todo.state,
|
|
28
|
+
createdAt: new Date(Date.now() - days * DAY).toISOString(),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The subjects in list order. */
|
|
33
|
+
async function order(deps: Testing): Promise<string[]> {
|
|
34
|
+
return (await deps.todos.all()).map((todo) => todo.subject);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe.concurrent("todo", () => {
|
|
38
|
+
it("adds, lists and removes, never reusing a number", async () => {
|
|
39
|
+
await using deps = await Testing.open();
|
|
40
|
+
|
|
41
|
+
await todoAdd(deps, " write the docs ", null);
|
|
42
|
+
await todoAdd(deps, "fix the flake", "alpha");
|
|
43
|
+
await todoRemove(deps, 2);
|
|
44
|
+
const third = await todoAdd(deps, "third", null);
|
|
45
|
+
|
|
46
|
+
expect(third.id).toBe(3);
|
|
47
|
+
deps.log.clear();
|
|
48
|
+
await todoList(deps, { json: true });
|
|
49
|
+
const listed = JSON.parse(deps.out());
|
|
50
|
+
expect(
|
|
51
|
+
listed.map(({ subject, position }: TodoState) => [subject, position]),
|
|
52
|
+
).toEqual([
|
|
53
|
+
["write the docs", 1],
|
|
54
|
+
["third", 2],
|
|
55
|
+
]);
|
|
56
|
+
await expect(todoRemove(deps, 2)).toBail("not_found");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rewords one, keeping its number", async () => {
|
|
60
|
+
await using deps = await Testing.open();
|
|
61
|
+
await todoAdd(deps, "write docs", "alpha", { text: "the CLI first" });
|
|
62
|
+
|
|
63
|
+
const updated = await todoUpdate(deps, 1, {
|
|
64
|
+
subject: " write the arbor docs ",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(updated.state).toMatchObject({
|
|
68
|
+
id: 1,
|
|
69
|
+
subject: "write the arbor docs",
|
|
70
|
+
text: "the CLI first",
|
|
71
|
+
from: "alpha",
|
|
72
|
+
});
|
|
73
|
+
const detailed = await todoUpdate(deps, 1, { text: " and the page " });
|
|
74
|
+
expect(detailed.state).toMatchObject({
|
|
75
|
+
subject: "write the arbor docs",
|
|
76
|
+
text: "and the page",
|
|
77
|
+
});
|
|
78
|
+
await expect(todoUpdate(deps, 1, { subject: " " })).toBail("usage");
|
|
79
|
+
await expect(todoUpdate(deps, 9, { subject: "x" })).toBail("not_found");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps a subject to one line, the rest leading the detail", async () => {
|
|
83
|
+
await using deps = await Testing.open();
|
|
84
|
+
|
|
85
|
+
const todo = await deps.todos.add("fix the flake\nin CI\n", null, {
|
|
86
|
+
text: "seen twice",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(todo.state).toMatchObject({
|
|
90
|
+
subject: "fix the flake",
|
|
91
|
+
text: "in CI\n\nseen twice",
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("puts a new todo at the bottom, or where asked, pushing the rest down", async () => {
|
|
96
|
+
await using deps = await Testing.open();
|
|
97
|
+
await todoAdd(deps, "one", null);
|
|
98
|
+
await todoAdd(deps, "two", null);
|
|
99
|
+
|
|
100
|
+
await todoAdd(deps, "urgent", null, { position: 1 });
|
|
101
|
+
await todoAdd(deps, "past the end", null, { position: 99 });
|
|
102
|
+
|
|
103
|
+
expect(await order(deps)).toEqual(["urgent", "one", "two", "past the end"]);
|
|
104
|
+
await expect(todoAdd(deps, "nowhere", null, { position: 0 })).toBail(
|
|
105
|
+
"usage",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("moves one up or down, and closes the gap a removed one leaves", async () => {
|
|
110
|
+
await using deps = await Testing.open();
|
|
111
|
+
for (const subject of ["a", "b", "c", "d"]) {
|
|
112
|
+
await todoAdd(deps, subject, null);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await todoUpdate(deps, 4, { position: 2 });
|
|
116
|
+
expect(await order(deps)).toEqual(["a", "d", "b", "c"]);
|
|
117
|
+
await todoUpdate(deps, 1, { position: 9 });
|
|
118
|
+
expect(await order(deps)).toEqual(["d", "b", "c", "a"]);
|
|
119
|
+
await todoRemove(deps, 2);
|
|
120
|
+
|
|
121
|
+
expect(
|
|
122
|
+
(await deps.todos.all()).map(({ subject, position }) => [
|
|
123
|
+
subject,
|
|
124
|
+
position,
|
|
125
|
+
]),
|
|
126
|
+
).toEqual([
|
|
127
|
+
["d", 1],
|
|
128
|
+
["c", 2],
|
|
129
|
+
["a", 3],
|
|
130
|
+
]);
|
|
131
|
+
// Written down, not only counted again on reading.
|
|
132
|
+
expect(
|
|
133
|
+
JSON.parse(await deps.fs.read(`${deps.todos.dir}/1.json`)),
|
|
134
|
+
).toMatchObject({ position: 3 });
|
|
135
|
+
// Taking one leaves it where it is.
|
|
136
|
+
await (await deps.todos.find(3)).take("alpha");
|
|
137
|
+
expect(await order(deps)).toEqual(["d", "c", "a"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("shows one in full, detail and files below", async () => {
|
|
141
|
+
await using deps = await Testing.open();
|
|
142
|
+
const shot = `${deps.root}/shot.png`;
|
|
143
|
+
await deps.fs.writeBytes(shot, new Uint8Array([1]));
|
|
144
|
+
await todoAdd(deps, "first", null);
|
|
145
|
+
await todoAdd(deps, "fix the chart", "alpha", {
|
|
146
|
+
text: "the bars overlap on a phone",
|
|
147
|
+
files: [shot],
|
|
148
|
+
});
|
|
149
|
+
deps.log.clear();
|
|
150
|
+
|
|
151
|
+
await todoShow(deps, 2);
|
|
152
|
+
|
|
153
|
+
const out = color.strip(deps.out());
|
|
154
|
+
expect(out).toContain("todo 2 fix the chart");
|
|
155
|
+
expect(out).toContain("position: 2");
|
|
156
|
+
expect(out).toContain("from: alpha");
|
|
157
|
+
expect(out).toContain(`file: ${deps.todos.dir}/2/0-shot.png`);
|
|
158
|
+
expect(out).toEndWith("\n\nthe bars overlap on a phone");
|
|
159
|
+
deps.log.clear();
|
|
160
|
+
await todoShow(deps, 2, { json: true });
|
|
161
|
+
expect(JSON.parse(deps.out())).toMatchObject({
|
|
162
|
+
id: 2,
|
|
163
|
+
position: 2,
|
|
164
|
+
text: "the bars overlap on a phone",
|
|
165
|
+
});
|
|
166
|
+
await expect(todoShow(deps, 9)).toBail("not_found");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("keeps files beside a todo, adds and drops them, and removes them with it", async () => {
|
|
170
|
+
await using deps = await Testing.open();
|
|
171
|
+
const shot = `${deps.root}/shot.png`;
|
|
172
|
+
const log = `${deps.root}/trace.log`;
|
|
173
|
+
await deps.fs.writeBytes(shot, new Uint8Array([1]));
|
|
174
|
+
await deps.fs.writeBytes(log, new Uint8Array([2]));
|
|
175
|
+
|
|
176
|
+
const todo = await todoAdd(deps, "fix the chart", null, {
|
|
177
|
+
files: [shot],
|
|
178
|
+
});
|
|
179
|
+
const dir = `${deps.todos.dir}/1`;
|
|
180
|
+
expect(todo.files).toEqual([`${dir}/0-shot.png`]);
|
|
181
|
+
expect(await deps.fs.readBytes(`${dir}/0-shot.png`)).toEqual(
|
|
182
|
+
new Uint8Array([1]),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Words alone leave the files be.
|
|
186
|
+
const reworded = await todoUpdate(deps, 1, {
|
|
187
|
+
subject: "fix the bar chart",
|
|
188
|
+
});
|
|
189
|
+
expect(reworded.files).toEqual(todo.files);
|
|
190
|
+
|
|
191
|
+
const swapped = await todoUpdate(deps, 1, {
|
|
192
|
+
files: [log],
|
|
193
|
+
removeFiles: ["0-shot.png"],
|
|
194
|
+
});
|
|
195
|
+
expect(swapped.state).toMatchObject({
|
|
196
|
+
subject: "fix the bar chart",
|
|
197
|
+
files: [`${dir}/1-trace.log`],
|
|
198
|
+
});
|
|
199
|
+
expect(await deps.fs.exists(`${dir}/0-shot.png`)).toBe(false);
|
|
200
|
+
await expect(todoUpdate(deps, 1, { removeFiles: ["nope.png"] })).toBail(
|
|
201
|
+
"not_found",
|
|
202
|
+
{ message: "no file 'nope.png'" },
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
await todoRemove(deps, 1);
|
|
206
|
+
expect(await deps.fs.exists(dir)).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("names a todo's files in the Goal of the task that takes it up", async () => {
|
|
210
|
+
await using deps = await Testing.open();
|
|
211
|
+
const shot = `${deps.root}/shot.png`;
|
|
212
|
+
await deps.fs.writeBytes(shot, new Uint8Array([1]));
|
|
213
|
+
await todoAdd(deps, "fix the chart", null, { files: [shot] });
|
|
214
|
+
|
|
215
|
+
await add(deps, "chart", { todos: [1] });
|
|
216
|
+
|
|
217
|
+
const worktree = (await deps.service.find("chart")).path;
|
|
218
|
+
expect(await deps.fs.read(`${worktree}/${PLAN_FILE}`)).toContain(
|
|
219
|
+
`## Goal\n\nfix the chart\n\nAttached: \`${deps.todos.dir}/1/0-shot.png\``,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("reads one saved before todos took files as having none", async () => {
|
|
224
|
+
await using deps = await Testing.open();
|
|
225
|
+
await todoAdd(deps, "old one", null);
|
|
226
|
+
const { files: _, ...older } = (await deps.todos.find(1)).state;
|
|
227
|
+
await deps.fs.write(`${deps.todos.dir}/1.json`, JSON.stringify(older));
|
|
228
|
+
|
|
229
|
+
expect((await deps.todos.find(1)).state.files).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("reads one saved before subjects and positions, below those with them", async () => {
|
|
233
|
+
await using deps = await Testing.open();
|
|
234
|
+
await todoAdd(deps, "older", null);
|
|
235
|
+
await todoAdd(deps, "newer", null);
|
|
236
|
+
const {
|
|
237
|
+
subject: _,
|
|
238
|
+
position: __,
|
|
239
|
+
...older
|
|
240
|
+
} = (await deps.todos.find(1)).state;
|
|
241
|
+
await deps.fs.write(
|
|
242
|
+
`${deps.todos.dir}/1.json`,
|
|
243
|
+
JSON.stringify({ ...older, text: "older\n\nwith more to say" }),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
expect((await deps.todos.all()).map((todo) => todo.state)).toMatchObject([
|
|
247
|
+
{ id: 2, position: 1 },
|
|
248
|
+
{ id: 1, position: 2, subject: "older", text: "with more to say" },
|
|
249
|
+
]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("refuses a todo with no subject", async () => {
|
|
253
|
+
await using deps = await Testing.open();
|
|
254
|
+
|
|
255
|
+
await expect(todoAdd(deps, " ", null)).toBail("usage");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("lists only the todos no task has taken when asked", async () => {
|
|
259
|
+
await using deps = await Testing.open();
|
|
260
|
+
await todoAdd(deps, "taken", null);
|
|
261
|
+
await todoAdd(deps, "free", null);
|
|
262
|
+
await add(deps, "busy", { todos: [1] });
|
|
263
|
+
|
|
264
|
+
deps.log.clear();
|
|
265
|
+
await todoList(deps, { json: true, open: true });
|
|
266
|
+
|
|
267
|
+
expect(
|
|
268
|
+
JSON.parse(deps.out()).map(({ subject }: TodoState) => subject),
|
|
269
|
+
).toEqual(["free"]);
|
|
270
|
+
await todoTake(deps, [2], "busy");
|
|
271
|
+
deps.log.clear();
|
|
272
|
+
await todoList(deps, { open: true });
|
|
273
|
+
expect(deps.out()).toContain("no open todos");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("says so plainly when there is nothing to do", async () => {
|
|
277
|
+
await using deps = await Testing.open();
|
|
278
|
+
|
|
279
|
+
await todoList(deps, { open: true });
|
|
280
|
+
|
|
281
|
+
expect(deps.out()).toContain("no todos");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("seeds the plan's Goal from the todo a task takes up", async () => {
|
|
285
|
+
await using deps = await Testing.open();
|
|
286
|
+
await todoAdd(deps, "support dark mode", null, { text: "charts too" });
|
|
287
|
+
|
|
288
|
+
await add(deps, "dark", { todos: [1] });
|
|
289
|
+
|
|
290
|
+
const worktree = (await deps.service.find("dark")).path;
|
|
291
|
+
expect(await deps.fs.read(`${worktree}/${PLAN_FILE}`)).toContain(
|
|
292
|
+
"## Goal\n\nsupport dark mode\n\ncharts too\n\n## Files",
|
|
293
|
+
);
|
|
294
|
+
expect((await deps.todos.find(1)).takenBy).toBe("dark");
|
|
295
|
+
await expect(add(deps, "other", { todos: [1] })).toBail("exists", {
|
|
296
|
+
message: "already taken by 'dark'",
|
|
297
|
+
});
|
|
298
|
+
// Refused before anything was made.
|
|
299
|
+
expect((await deps.service.find("other")).gone).toBe(true);
|
|
300
|
+
await expect(add(deps, "missing", { todos: [9] })).toBail("not_found");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("takes up several todos at once, naming each in the Goal", async () => {
|
|
304
|
+
await using deps = await Testing.open();
|
|
305
|
+
await todoAdd(deps, "support dark mode", null);
|
|
306
|
+
await todoAdd(deps, "dark charts too", "beta");
|
|
307
|
+
|
|
308
|
+
await add(deps, "dark", { todos: [1, 2] });
|
|
309
|
+
|
|
310
|
+
const worktree = (await deps.service.find("dark")).path;
|
|
311
|
+
expect(await deps.fs.read(`${worktree}/${PLAN_FILE}`)).toContain(
|
|
312
|
+
"## Goal\n\nTodo 1: support dark mode\n\nTodo 2: dark charts too",
|
|
313
|
+
);
|
|
314
|
+
expect((await deps.todos.takenBy("dark")).map((todo) => todo.id)).toEqual([
|
|
315
|
+
1, 2,
|
|
316
|
+
]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("takes todos for a task already under way, all or none", async () => {
|
|
320
|
+
await using deps = await Testing.open();
|
|
321
|
+
await todoAdd(deps, "support dark mode", null);
|
|
322
|
+
await todoAdd(deps, "dark charts too", null);
|
|
323
|
+
await todoAdd(deps, "someone else's", null);
|
|
324
|
+
await add(deps, "other", { todos: [3] });
|
|
325
|
+
|
|
326
|
+
await todoTake(deps, [1, 2, 1], "dark");
|
|
327
|
+
|
|
328
|
+
expect((await deps.todos.takenBy("dark")).map((todo) => todo.id)).toEqual([
|
|
329
|
+
1, 2,
|
|
330
|
+
]);
|
|
331
|
+
expect(color.strip(deps.out())).toContain("took todo 2: dark charts too");
|
|
332
|
+
await todoRelease(deps, [2], "dark");
|
|
333
|
+
await expect(todoTake(deps, [2, 3], "dark")).toBail("exists", {
|
|
334
|
+
message: "already taken by 'other'",
|
|
335
|
+
});
|
|
336
|
+
// Refused as a whole: 2 was not taken on the way.
|
|
337
|
+
expect((await deps.todos.find(2)).takenBy).toBeNull();
|
|
338
|
+
await expect(todoTake(deps, [9], "dark")).toBail("not_found");
|
|
339
|
+
await expect(todoTake(deps, [2], null)).toBail("usage");
|
|
340
|
+
await expect(todoTake(deps, [], "dark")).toBail("usage");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("releases only its own task's todos from a worktree", async () => {
|
|
344
|
+
await using deps = await Testing.open();
|
|
345
|
+
await todoAdd(deps, "support dark mode", null);
|
|
346
|
+
await add(deps, "dark", { todos: [1] });
|
|
347
|
+
|
|
348
|
+
await expect(todoRelease(deps, [1], "other")).toBail("exists", {
|
|
349
|
+
message: "taken by 'dark'",
|
|
350
|
+
});
|
|
351
|
+
await todoRelease(deps, [1], "dark");
|
|
352
|
+
expect((await deps.todos.find(1)).takenBy).toBeNull();
|
|
353
|
+
|
|
354
|
+
await todoTake(deps, [1], "dark");
|
|
355
|
+
// From the main tree, anyone's.
|
|
356
|
+
await todoRelease(deps, [1], null);
|
|
357
|
+
expect((await deps.todos.find(1)).takenBy).toBeNull();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("leaves a released todo on the list when its task lands", async () => {
|
|
361
|
+
await using deps = await Testing.open();
|
|
362
|
+
await todoAdd(deps, "support dark mode", null);
|
|
363
|
+
await todoAdd(deps, "dark charts too", null);
|
|
364
|
+
await add(deps, "dark", { todos: [1, 2] });
|
|
365
|
+
const worktree = (await deps.service.find("dark")).path;
|
|
366
|
+
await deps.commit(worktree, "dark.txt", "dark\n", "add dark");
|
|
367
|
+
await todoUpdate(deps, 2, { subject: "dark tooltips on charts" });
|
|
368
|
+
await todoRelease(deps, [2], "dark");
|
|
369
|
+
deps.log.clear();
|
|
370
|
+
|
|
371
|
+
await merge(deps, worktree);
|
|
372
|
+
|
|
373
|
+
await expect(deps.todos.find(1)).toBail("not_found");
|
|
374
|
+
const out = color.strip(deps.out());
|
|
375
|
+
expect(out).toContain("done todo 1: support dark mode");
|
|
376
|
+
expect(out).toContain("next todo 2: dark tooltips on charts");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("puts a removed task's todo back on the list", async () => {
|
|
380
|
+
await using deps = await Testing.open();
|
|
381
|
+
await todoAdd(deps, "support dark mode", null);
|
|
382
|
+
await add(deps, "dark", { todos: [1] });
|
|
383
|
+
|
|
384
|
+
await remove(deps, "dark");
|
|
385
|
+
|
|
386
|
+
expect((await deps.todos.find(1)).takenBy).toBeNull();
|
|
387
|
+
expect(deps.out()).toContain("todo 1 is open again");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("removes a merged task's todo and recommends the next", async () => {
|
|
391
|
+
await using deps = await Testing.open();
|
|
392
|
+
await todoAdd(deps, "support dark mode", null);
|
|
393
|
+
await todoAdd(deps, "older, from elsewhere", "beta");
|
|
394
|
+
await todoAdd(deps, "follow-up from dark", "dark", { position: 2 });
|
|
395
|
+
await add(deps, "dark", { todos: [1] });
|
|
396
|
+
const worktree = (await deps.service.find("dark")).path;
|
|
397
|
+
await deps.commit(worktree, "dark.txt", "dark\n", "add dark");
|
|
398
|
+
deps.log.clear();
|
|
399
|
+
|
|
400
|
+
await merge(deps, worktree);
|
|
401
|
+
|
|
402
|
+
await expect(deps.todos.find(1)).toBail("not_found");
|
|
403
|
+
const out = color.strip(deps.out());
|
|
404
|
+
expect(out).toContain("next todo 3: follow-up from dark");
|
|
405
|
+
expect(out).toContain("arbor add <task> --todo 3");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("recommends the highest open todo, and sets stale ones aside", async () => {
|
|
409
|
+
await using deps = await Testing.open();
|
|
410
|
+
await todoAdd(deps, "taken", null);
|
|
411
|
+
await todoAdd(deps, "ancient", null);
|
|
412
|
+
await todoAdd(deps, "lower", null);
|
|
413
|
+
await todoAdd(deps, "higher", null, { position: 3 });
|
|
414
|
+
await (await deps.todos.find(1)).take("alpha");
|
|
415
|
+
await age(deps, 2, 90);
|
|
416
|
+
|
|
417
|
+
const { next, stale } = await recommend(deps.todos, null, 30 * DAY);
|
|
418
|
+
|
|
419
|
+
expect(next?.subject).toBe("higher");
|
|
420
|
+
expect(stale.map((todo) => todo.subject)).toEqual(["ancient"]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("recommends what came up in the task that landed before the rest", async () => {
|
|
424
|
+
await using deps = await Testing.open();
|
|
425
|
+
await todoAdd(deps, "top", null);
|
|
426
|
+
await todoAdd(deps, "from alpha, lower", "alpha");
|
|
427
|
+
await todoAdd(deps, "from alpha, higher", "alpha", { position: 2 });
|
|
428
|
+
|
|
429
|
+
const { next } = await recommend(deps.todos, "alpha", 30 * DAY);
|
|
430
|
+
|
|
431
|
+
expect(next?.subject).toBe("from alpha, higher");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("recommends one sharing a tag with what the task settled, before the top", async () => {
|
|
435
|
+
await using deps = await Testing.open();
|
|
436
|
+
await todoAdd(deps, "top", null);
|
|
437
|
+
await todoAdd(deps, "page bug", null, { tags: ["dev-page"] });
|
|
438
|
+
await todoAdd(deps, "page polish", null, { tags: ["dev-page", "ui"] });
|
|
439
|
+
await (await deps.todos.find(2)).remove();
|
|
440
|
+
|
|
441
|
+
const { next } = await recommend(deps.todos, "alpha", 30 * DAY, [
|
|
442
|
+
"dev-page",
|
|
443
|
+
]);
|
|
444
|
+
|
|
445
|
+
expect(next?.subject).toBe("page polish");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("recommends nothing once every todo is taken or stale", async () => {
|
|
449
|
+
await using deps = await Testing.open();
|
|
450
|
+
await todoAdd(deps, "taken", null);
|
|
451
|
+
await (await deps.todos.find(1)).take("alpha");
|
|
452
|
+
|
|
453
|
+
const { next, stale } = await recommend(deps.todos, null, 30 * DAY);
|
|
454
|
+
|
|
455
|
+
expect(next).toBeNull();
|
|
456
|
+
expect(stale).toEqual([]);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("tags todos, filters by tag, and counts the todos each tag has", async () => {
|
|
460
|
+
await using deps = await Testing.open();
|
|
461
|
+
await todoAdd(deps, "one", null, { tags: ["ui", "dev-page", "ui"] });
|
|
462
|
+
await todoAdd(deps, "two", null, { tags: ["dev-page"] });
|
|
463
|
+
await todoAdd(deps, "three", null);
|
|
464
|
+
await todoRemove(deps, 2);
|
|
465
|
+
|
|
466
|
+
expect((await deps.todos.find(1)).tags).toEqual(["dev-page", "ui"]);
|
|
467
|
+
await expect(todoAdd(deps, "bad", null, { tags: ["Dev Page"] })).toBail(
|
|
468
|
+
"usage",
|
|
469
|
+
);
|
|
470
|
+
await todoUpdate(deps, 3, { tags: ["merge"] });
|
|
471
|
+
await todoUpdate(deps, 1, { tags: ["merge"], removeTags: ["ui"] });
|
|
472
|
+
expect((await deps.todos.find(1)).tags).toEqual(["dev-page", "merge"]);
|
|
473
|
+
await expect(todoUpdate(deps, 1, { removeTags: ["ui"] })).toBail(
|
|
474
|
+
"not_found",
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
deps.log.clear();
|
|
478
|
+
await todoList(deps, { json: true, tag: "merge" });
|
|
479
|
+
expect(
|
|
480
|
+
JSON.parse(deps.out()).map(({ subject }: TodoState) => subject),
|
|
481
|
+
).toEqual(["one", "three"]);
|
|
482
|
+
|
|
483
|
+
deps.log.clear();
|
|
484
|
+
await todoTags(deps, { json: true });
|
|
485
|
+
expect(JSON.parse(deps.out())).toEqual([
|
|
486
|
+
{ tag: "dev-page", todos: 1 },
|
|
487
|
+
{ tag: "merge", todos: 2 },
|
|
488
|
+
]);
|
|
489
|
+
});
|
|
490
|
+
});
|