depth-first-thinking 2.0.7 → 2.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/package.json +5 -5
- package/src/commands/delete.ts +53 -0
- package/src/commands/list.ts +19 -0
- package/src/commands/new.ts +47 -0
- package/src/commands/open.ts +38 -0
- package/src/commands/tree.ts +38 -0
- package/src/commands/update.ts +89 -0
- package/src/data/operations.test.ts +284 -0
- package/src/data/operations.ts +111 -0
- package/src/data/storage.ts +227 -0
- package/src/data/types.ts +48 -0
- package/src/index.ts +123 -0
- package/src/tui/app.ts +717 -0
- package/src/tui/navigation.ts +143 -0
- package/src/utils/formatting.test.ts +30 -0
- package/src/utils/formatting.ts +10 -0
- package/src/utils/platform.test.ts +30 -0
- package/src/utils/platform.ts +21 -0
- package/src/utils/validation.test.ts +93 -0
- package/src/utils/validation.ts +74 -0
- package/dist/dft +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "depth-first-thinking",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "A terminal-based task manager with depth-first navigation. Break down tasks into subtasks, navigate recursively, and track progress.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -28,16 +28,16 @@
|
|
|
28
28
|
"type": "module",
|
|
29
29
|
"packageManager": "bun@1.3.2",
|
|
30
30
|
"bin": {
|
|
31
|
-
"dft": "
|
|
31
|
+
"dft": "src/index.ts"
|
|
32
32
|
},
|
|
33
|
-
"files": ["
|
|
33
|
+
"files": ["src", "README.md", "LICENSE"],
|
|
34
34
|
"engines": {
|
|
35
35
|
"bun": ">=1.0.0"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"preinstall": "node -e \"if(process.env.npm_execpath && !process.env.npm_execpath.includes('bun')){console.error('\\x1b[31mError: Use bun to install dependencies\\x1b[0m\\n\\nInstall bun: https://bun.sh');process.exit(1)}\"",
|
|
39
39
|
"dev": "bun run --watch src/index.ts",
|
|
40
|
-
"build": "bun build src/index.ts --
|
|
40
|
+
"build": "bun build src/index.ts --target bun --minify --outdir dist",
|
|
41
41
|
"format": "bunx biome format --write src",
|
|
42
42
|
"format:check": "bunx biome format src",
|
|
43
43
|
"lint": "bunx biome lint src",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"check:fix": "bunx biome check --write src",
|
|
47
47
|
"check-types": "bunx tsc --noEmit",
|
|
48
48
|
"test": "bun test",
|
|
49
|
-
"prepublishOnly": "bun run check && bun run check-types
|
|
49
|
+
"prepublishOnly": "bun run check && bun run check-types",
|
|
50
50
|
"prepare": "husky",
|
|
51
51
|
"ci": "bun run format && bun run lint && bun run check-types"
|
|
52
52
|
},
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { deleteProject, projectExists } from "../data/storage";
|
|
2
|
+
import { ExitCodes } from "../data/types";
|
|
3
|
+
|
|
4
|
+
async function promptConfirmation(message: string): Promise<boolean> {
|
|
5
|
+
process.stdout.write(`${message} (y/N): `);
|
|
6
|
+
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const stdin = process.stdin;
|
|
9
|
+
stdin.setRawMode?.(false);
|
|
10
|
+
|
|
11
|
+
const onData = (data: Buffer) => {
|
|
12
|
+
const input = data.toString().trim().toLowerCase();
|
|
13
|
+
stdin.removeListener("data", onData);
|
|
14
|
+
resolve(input === "y" || input === "yes");
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
stdin.once("data", onData);
|
|
18
|
+
stdin.resume();
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function deleteCommand(
|
|
23
|
+
projectName: string,
|
|
24
|
+
options: { yes?: boolean },
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const normalizedName = projectName.toLowerCase();
|
|
27
|
+
|
|
28
|
+
if (!(await projectExists(normalizedName))) {
|
|
29
|
+
console.error(
|
|
30
|
+
`Project '${normalizedName}' not found. Use 'dft list' to see available projects.`,
|
|
31
|
+
);
|
|
32
|
+
process.exit(ExitCodes.NOT_FOUND);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!options.yes) {
|
|
36
|
+
const confirmed = await promptConfirmation(
|
|
37
|
+
`Are you sure you want to delete project '${normalizedName}'?`,
|
|
38
|
+
);
|
|
39
|
+
if (!confirmed) {
|
|
40
|
+
console.log("Cancelled.");
|
|
41
|
+
process.exit(ExitCodes.CANCELLED);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await deleteProject(normalizedName);
|
|
47
|
+
console.log(`Deleted project '${normalizedName}'`);
|
|
48
|
+
process.exit(ExitCodes.SUCCESS);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error(error instanceof Error ? error.message : "Failed to delete project");
|
|
51
|
+
process.exit(ExitCodes.FILESYSTEM_ERROR);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { listProjects } from "../data/storage";
|
|
2
|
+
|
|
3
|
+
export async function listCommand(): Promise<void> {
|
|
4
|
+
const projects = await listProjects();
|
|
5
|
+
|
|
6
|
+
if (projects.length === 0) {
|
|
7
|
+
console.log("No projects found. Create one with 'dft new <name>'");
|
|
8
|
+
process.exit(0);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
console.log("Projects:");
|
|
12
|
+
for (const project of projects) {
|
|
13
|
+
const taskCount = project.nodeCount - 1;
|
|
14
|
+
const taskWord = taskCount === 1 ? "task" : "tasks";
|
|
15
|
+
console.log(` • ${project.name} (${taskCount} ${taskWord})`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
import { projectExists, saveProject } from "../data/storage";
|
|
3
|
+
import type { Project } from "../data/types";
|
|
4
|
+
import { ExitCodes } from "../data/types";
|
|
5
|
+
import { validateProjectName } from "../utils/validation";
|
|
6
|
+
|
|
7
|
+
export async function newCommand(projectName: string): Promise<void> {
|
|
8
|
+
const nameValidation = validateProjectName(projectName);
|
|
9
|
+
if (!nameValidation.isValid) {
|
|
10
|
+
console.error(nameValidation.error);
|
|
11
|
+
process.exit(ExitCodes.INVALID_NAME);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const normalizedName = projectName.toLowerCase();
|
|
15
|
+
|
|
16
|
+
if (await projectExists(normalizedName)) {
|
|
17
|
+
console.error(
|
|
18
|
+
`Project '${normalizedName}' already exists. Use 'dft open ${normalizedName}' to work on it.`,
|
|
19
|
+
);
|
|
20
|
+
process.exit(ExitCodes.ALREADY_EXISTS);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const now = new Date().toISOString();
|
|
24
|
+
const project: Project = {
|
|
25
|
+
project_name: normalizedName,
|
|
26
|
+
version: "1.0.0",
|
|
27
|
+
created_at: now,
|
|
28
|
+
modified_at: now,
|
|
29
|
+
root: {
|
|
30
|
+
id: uuidv4(),
|
|
31
|
+
title: "root",
|
|
32
|
+
status: "open",
|
|
33
|
+
children: [],
|
|
34
|
+
created_at: now,
|
|
35
|
+
completed_at: null,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await saveProject(project);
|
|
41
|
+
console.log(`Created project '${normalizedName}'`);
|
|
42
|
+
process.exit(ExitCodes.SUCCESS);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(error instanceof Error ? error.message : "Failed to create project");
|
|
45
|
+
process.exit(ExitCodes.FILESYSTEM_ERROR);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { loadProject, saveProject } from "../data/storage";
|
|
2
|
+
import type { Project } from "../data/types";
|
|
3
|
+
import { ExitCodes } from "../data/types";
|
|
4
|
+
import { startTUI } from "../tui/app";
|
|
5
|
+
|
|
6
|
+
export async function openCommand(projectName: string): Promise<void> {
|
|
7
|
+
const normalizedName = projectName.toLowerCase();
|
|
8
|
+
|
|
9
|
+
let project: Project;
|
|
10
|
+
try {
|
|
11
|
+
project = await loadProject(normalizedName);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
const message = error instanceof Error ? error.message : "Failed to load project";
|
|
14
|
+
|
|
15
|
+
if (message.includes("not found")) {
|
|
16
|
+
console.error(message);
|
|
17
|
+
process.exit(ExitCodes.NOT_FOUND);
|
|
18
|
+
} else {
|
|
19
|
+
console.error(message);
|
|
20
|
+
process.exit(ExitCodes.CORRUPTED);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
project.open_count = (project.open_count ?? 0) + 1;
|
|
25
|
+
try {
|
|
26
|
+
await saveProject(project);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error(error instanceof Error ? error.message : "Failed to update open count");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await startTUI(project);
|
|
33
|
+
process.exit(ExitCodes.SUCCESS);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error(error instanceof Error ? error.message : "TUI error occurred");
|
|
36
|
+
process.exit(ExitCodes.TUI_ERROR);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { loadProject } from "../data/storage";
|
|
2
|
+
import type { Node } from "../data/types";
|
|
3
|
+
import { ExitCodes } from "../data/types";
|
|
4
|
+
import { formatCheckbox } from "../utils/formatting";
|
|
5
|
+
|
|
6
|
+
function printNode(node: Node, indent: number, showStatus: boolean): void {
|
|
7
|
+
const indentStr = " ".repeat(indent);
|
|
8
|
+
const status = showStatus ? `${formatCheckbox(node.status)} ` : "";
|
|
9
|
+
console.log(`${indentStr}${status}${node.title}`);
|
|
10
|
+
|
|
11
|
+
for (const child of node.children) {
|
|
12
|
+
printNode(child, indent + 1, showStatus);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function treeCommand(
|
|
17
|
+
projectName: string,
|
|
18
|
+
options: { status?: boolean },
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const normalizedName = projectName.toLowerCase();
|
|
21
|
+
const showStatus = options.status !== false;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const project = await loadProject(normalizedName);
|
|
25
|
+
printNode(project.root, 0, showStatus);
|
|
26
|
+
process.exit(ExitCodes.SUCCESS);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
const message = error instanceof Error ? error.message : "Failed to load project";
|
|
29
|
+
|
|
30
|
+
if (message.includes("not found")) {
|
|
31
|
+
console.error(message);
|
|
32
|
+
process.exit(ExitCodes.NOT_FOUND);
|
|
33
|
+
} else {
|
|
34
|
+
console.error(message);
|
|
35
|
+
process.exit(ExitCodes.CORRUPTED);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { ExitCodes } from "../data/types";
|
|
2
|
+
|
|
3
|
+
async function getCurrentVersion(): Promise<string> {
|
|
4
|
+
try {
|
|
5
|
+
// Resolve the package.json that belongs to this installed package,
|
|
6
|
+
// regardless of the current working directory.
|
|
7
|
+
const packageUrl = new URL("../../package.json", import.meta.url);
|
|
8
|
+
const packageJsonFile = Bun.file(packageUrl);
|
|
9
|
+
|
|
10
|
+
if (await packageJsonFile.exists()) {
|
|
11
|
+
const packageJson = (await packageJsonFile.json()) as { version?: string };
|
|
12
|
+
return packageJson.version ?? "unknown";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return "unknown";
|
|
16
|
+
} catch {
|
|
17
|
+
return "unknown";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function fetchLatestVersion(): Promise<string | null> {
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch("https://registry.npmjs.org/depth-first-thinking/latest");
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const data = (await response.json()) as { version: string };
|
|
28
|
+
return data.version;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function compareVersions(current: string, latest: string): number {
|
|
35
|
+
const currentParts = current.split(".").map(Number);
|
|
36
|
+
const latestParts = latest.split(".").map(Number);
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
|
|
39
|
+
const currentPart = currentParts[i] ?? 0;
|
|
40
|
+
const latestPart = latestParts[i] ?? 0;
|
|
41
|
+
|
|
42
|
+
if (latestPart > currentPart) return 1;
|
|
43
|
+
if (latestPart < currentPart) return -1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function updateCommand(): Promise<void> {
|
|
50
|
+
const currentVersion = await getCurrentVersion();
|
|
51
|
+
console.log(`Current version: ${currentVersion}`);
|
|
52
|
+
|
|
53
|
+
if (currentVersion === "unknown") {
|
|
54
|
+
console.log(
|
|
55
|
+
"Could not determine the current version. Please make sure dft is installed correctly.",
|
|
56
|
+
);
|
|
57
|
+
process.exit(ExitCodes.FILESYSTEM_ERROR);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const latestVersion = await fetchLatestVersion();
|
|
62
|
+
|
|
63
|
+
if (!latestVersion) {
|
|
64
|
+
console.error("Failed to check for updates. Please check your internet connection.");
|
|
65
|
+
process.exit(ExitCodes.FILESYSTEM_ERROR);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const comparison = compareVersions(currentVersion, latestVersion);
|
|
70
|
+
|
|
71
|
+
if (comparison === 0) {
|
|
72
|
+
console.log(`You are using the latest version (${currentVersion})`);
|
|
73
|
+
process.exit(ExitCodes.SUCCESS);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (comparison < 0) {
|
|
78
|
+
console.log(`Update available: ${latestVersion}`);
|
|
79
|
+
console.log("Run: bun install -g depth-first-thinking@latest");
|
|
80
|
+
console.log("Or: npm install -g depth-first-thinking@latest");
|
|
81
|
+
process.exit(ExitCodes.SUCCESS);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log(
|
|
86
|
+
`You are using a newer version (${currentVersion}) than the published version (${latestVersion})`,
|
|
87
|
+
);
|
|
88
|
+
process.exit(ExitCodes.SUCCESS);
|
|
89
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
addChildNode,
|
|
4
|
+
buildPathToNode,
|
|
5
|
+
deleteNode,
|
|
6
|
+
editNodeTitle,
|
|
7
|
+
findNode,
|
|
8
|
+
findParent,
|
|
9
|
+
getNextSibling,
|
|
10
|
+
getNodePath,
|
|
11
|
+
getPreviousSibling,
|
|
12
|
+
getSiblingIndex,
|
|
13
|
+
markNodeDone,
|
|
14
|
+
markNodeOpen,
|
|
15
|
+
toggleNodeStatus,
|
|
16
|
+
} from "./operations";
|
|
17
|
+
import type { Node } from "./types";
|
|
18
|
+
|
|
19
|
+
function createTestNode(title: string, id?: string): Node {
|
|
20
|
+
return {
|
|
21
|
+
id: id || crypto.randomUUID(),
|
|
22
|
+
title,
|
|
23
|
+
status: "open",
|
|
24
|
+
children: [],
|
|
25
|
+
created_at: new Date().toISOString(),
|
|
26
|
+
completed_at: null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("addChildNode", () => {
|
|
31
|
+
test("creates node with correct properties", () => {
|
|
32
|
+
const parent = createTestNode("Parent");
|
|
33
|
+
const node = addChildNode(parent, "Test Title");
|
|
34
|
+
expect(node.title).toBe("Test Title");
|
|
35
|
+
expect(node.status).toBe("open");
|
|
36
|
+
expect(node.children).toEqual([]);
|
|
37
|
+
expect(node.completed_at).toBeNull();
|
|
38
|
+
expect(node.id).toHaveLength(36);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("trims whitespace from title", () => {
|
|
42
|
+
const parent = createTestNode("Parent");
|
|
43
|
+
const node = addChildNode(parent, " Spaced Title ");
|
|
44
|
+
expect(node.title).toBe("Spaced Title");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("adds child to parent", () => {
|
|
48
|
+
const parent = createTestNode("Parent");
|
|
49
|
+
const child = addChildNode(parent, "Child");
|
|
50
|
+
|
|
51
|
+
expect(parent.children).toHaveLength(1);
|
|
52
|
+
expect(parent.children[0]).toBe(child);
|
|
53
|
+
expect(child.title).toBe("Child");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("preserves insertion order", () => {
|
|
57
|
+
const parent = createTestNode("Parent");
|
|
58
|
+
addChildNode(parent, "First");
|
|
59
|
+
addChildNode(parent, "Second");
|
|
60
|
+
addChildNode(parent, "Third");
|
|
61
|
+
|
|
62
|
+
expect(parent.children.map((c) => c.title)).toEqual(["First", "Second", "Third"]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("editNodeTitle", () => {
|
|
67
|
+
test("updates the title", () => {
|
|
68
|
+
const node = createTestNode("Original");
|
|
69
|
+
editNodeTitle(node, "Updated");
|
|
70
|
+
expect(node.title).toBe("Updated");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("trims whitespace", () => {
|
|
74
|
+
const node = createTestNode("Original");
|
|
75
|
+
editNodeTitle(node, " New Title ");
|
|
76
|
+
expect(node.title).toBe("New Title");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("markNodeDone", () => {
|
|
81
|
+
test("marks node as done", () => {
|
|
82
|
+
const node = createTestNode("Test");
|
|
83
|
+
markNodeDone(node);
|
|
84
|
+
|
|
85
|
+
expect(node.status).toBe("done");
|
|
86
|
+
expect(node.completed_at).not.toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("cascades to children", () => {
|
|
90
|
+
const parent = createTestNode("Parent");
|
|
91
|
+
const child = addChildNode(parent, "Child");
|
|
92
|
+
const grandchild = addChildNode(child, "Grandchild");
|
|
93
|
+
|
|
94
|
+
markNodeDone(parent);
|
|
95
|
+
|
|
96
|
+
expect(parent.status).toBe("done");
|
|
97
|
+
expect(child.status).toBe("done");
|
|
98
|
+
expect(grandchild.status).toBe("done");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("markNodeOpen", () => {
|
|
103
|
+
test("marks node as open", () => {
|
|
104
|
+
const node = createTestNode("Test");
|
|
105
|
+
markNodeDone(node); // First mark as done
|
|
106
|
+
|
|
107
|
+
markNodeOpen(node);
|
|
108
|
+
|
|
109
|
+
expect(node.status).toBe("open" as const);
|
|
110
|
+
expect(node.completed_at).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("does not cascade to children", () => {
|
|
114
|
+
const parent = createTestNode("Parent");
|
|
115
|
+
const child = addChildNode(parent, "Child");
|
|
116
|
+
markNodeDone(parent);
|
|
117
|
+
|
|
118
|
+
markNodeOpen(parent);
|
|
119
|
+
|
|
120
|
+
expect(parent.status).toBe("open" as const);
|
|
121
|
+
expect(child.status).toBe("done" as const); // Still done
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("toggleNodeStatus", () => {
|
|
126
|
+
test("toggles open to done", () => {
|
|
127
|
+
const node = createTestNode("Test");
|
|
128
|
+
toggleNodeStatus(node);
|
|
129
|
+
expect(node.status).toBe("done" as const);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("toggles done to open", () => {
|
|
133
|
+
const node = createTestNode("Test");
|
|
134
|
+
markNodeDone(node); // First mark as done
|
|
135
|
+
toggleNodeStatus(node);
|
|
136
|
+
expect(node.status).toBe("open" as const);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("deleteNode", () => {
|
|
141
|
+
test("removes child from parent", () => {
|
|
142
|
+
const parent = createTestNode("Parent");
|
|
143
|
+
const child = addChildNode(parent, "Child");
|
|
144
|
+
|
|
145
|
+
const result = deleteNode(parent, child.id);
|
|
146
|
+
|
|
147
|
+
expect(result).toBe(true);
|
|
148
|
+
expect(parent.children).toHaveLength(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("returns false if child not found", () => {
|
|
152
|
+
const parent = createTestNode("Parent");
|
|
153
|
+
const result = deleteNode(parent, "nonexistent");
|
|
154
|
+
expect(result).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("findNode", () => {
|
|
159
|
+
test("finds root node", () => {
|
|
160
|
+
const root = createTestNode("Root", "root-id");
|
|
161
|
+
const found = findNode(root, "root-id");
|
|
162
|
+
expect(found).toBe(root);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("finds nested node", () => {
|
|
166
|
+
const root = createTestNode("Root");
|
|
167
|
+
const child = addChildNode(root, "Child");
|
|
168
|
+
const grandchild = addChildNode(child, "Grandchild");
|
|
169
|
+
|
|
170
|
+
const found = findNode(root, grandchild.id);
|
|
171
|
+
expect(found).toBe(grandchild);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("returns null if not found", () => {
|
|
175
|
+
const root = createTestNode("Root");
|
|
176
|
+
const found = findNode(root, "nonexistent");
|
|
177
|
+
expect(found).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("findParent", () => {
|
|
182
|
+
test("finds parent of child", () => {
|
|
183
|
+
const root = createTestNode("Root");
|
|
184
|
+
const child = addChildNode(root, "Child");
|
|
185
|
+
|
|
186
|
+
const parent = findParent(root, child.id);
|
|
187
|
+
expect(parent).toBe(root);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("finds parent of grandchild", () => {
|
|
191
|
+
const root = createTestNode("Root");
|
|
192
|
+
const child = addChildNode(root, "Child");
|
|
193
|
+
const grandchild = addChildNode(child, "Grandchild");
|
|
194
|
+
|
|
195
|
+
const parent = findParent(root, grandchild.id);
|
|
196
|
+
expect(parent).toBe(child);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("returns null for root", () => {
|
|
200
|
+
const root = createTestNode("Root");
|
|
201
|
+
const parent = findParent(root, root.id);
|
|
202
|
+
expect(parent).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("getSiblingIndex", () => {
|
|
207
|
+
test("returns correct index", () => {
|
|
208
|
+
const siblings = [createTestNode("A", "a"), createTestNode("B", "b"), createTestNode("C", "c")];
|
|
209
|
+
|
|
210
|
+
expect(getSiblingIndex(siblings, "a")).toBe(0);
|
|
211
|
+
expect(getSiblingIndex(siblings, "b")).toBe(1);
|
|
212
|
+
expect(getSiblingIndex(siblings, "c")).toBe(2);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("returns -1 if not found", () => {
|
|
216
|
+
const siblings = [createTestNode("A", "a")];
|
|
217
|
+
expect(getSiblingIndex(siblings, "nonexistent")).toBe(-1);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("getPreviousSibling", () => {
|
|
222
|
+
test("returns previous sibling", () => {
|
|
223
|
+
const siblings = [createTestNode("A", "a"), createTestNode("B", "b"), createTestNode("C", "c")];
|
|
224
|
+
|
|
225
|
+
expect(getPreviousSibling(siblings, "b")?.id).toBe("a");
|
|
226
|
+
expect(getPreviousSibling(siblings, "c")?.id).toBe("b");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("returns null for first sibling", () => {
|
|
230
|
+
const siblings = [createTestNode("A", "a"), createTestNode("B", "b")];
|
|
231
|
+
expect(getPreviousSibling(siblings, "a")).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("getNextSibling", () => {
|
|
236
|
+
test("returns next sibling", () => {
|
|
237
|
+
const siblings = [createTestNode("A", "a"), createTestNode("B", "b"), createTestNode("C", "c")];
|
|
238
|
+
|
|
239
|
+
expect(getNextSibling(siblings, "a")?.id).toBe("b");
|
|
240
|
+
expect(getNextSibling(siblings, "b")?.id).toBe("c");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("returns null for last sibling", () => {
|
|
244
|
+
const siblings = [createTestNode("A", "a"), createTestNode("B", "b")];
|
|
245
|
+
expect(getNextSibling(siblings, "b")).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("buildPathToNode", () => {
|
|
250
|
+
test("returns path to root", () => {
|
|
251
|
+
const root = createTestNode("Root", "root");
|
|
252
|
+
const path = buildPathToNode(root, "root");
|
|
253
|
+
expect(path).toEqual(["root"]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("returns path to nested node", () => {
|
|
257
|
+
const root = createTestNode("Root", "root");
|
|
258
|
+
const child = addChildNode(root, "Child");
|
|
259
|
+
child.id = "child";
|
|
260
|
+
const grandchild = addChildNode(child, "Grandchild");
|
|
261
|
+
grandchild.id = "grandchild";
|
|
262
|
+
|
|
263
|
+
const path = buildPathToNode(root, "grandchild");
|
|
264
|
+
expect(path).toEqual(["root", "child", "grandchild"]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("returns null if not found", () => {
|
|
268
|
+
const root = createTestNode("Root");
|
|
269
|
+
const path = buildPathToNode(root, "nonexistent");
|
|
270
|
+
expect(path).toBeNull();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("getNodePath", () => {
|
|
275
|
+
test("returns array of nodes", () => {
|
|
276
|
+
const root = createTestNode("Root", "root");
|
|
277
|
+
const child = addChildNode(root, "Child");
|
|
278
|
+
|
|
279
|
+
const path = getNodePath(root, child.id);
|
|
280
|
+
expect(path).toHaveLength(2);
|
|
281
|
+
expect(path?.[0]).toBe(root);
|
|
282
|
+
expect(path?.[1]).toBe(child);
|
|
283
|
+
});
|
|
284
|
+
});
|