@assistkick/create 1.0.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/dist/bin/create.d.ts +2 -0
- package/dist/bin/create.js +25 -0
- package/dist/bin/create.js.map +1 -0
- package/dist/src/scaffolder.d.ts +22 -0
- package/dist/src/scaffolder.js +120 -0
- package/dist/src/scaffolder.js.map +1 -0
- package/package.json +24 -0
- package/templates/product-system/.env.example +8 -0
- package/templates/product-system/CLAUDE.md +45 -0
- package/templates/product-system/package.json +32 -0
- package/templates/product-system/packages/backend/package.json +37 -0
- package/templates/product-system/packages/backend/src/middleware/auth_middleware.test.ts +86 -0
- package/templates/product-system/packages/backend/src/middleware/auth_middleware.ts +35 -0
- package/templates/product-system/packages/backend/src/routes/auth.ts +463 -0
- package/templates/product-system/packages/backend/src/routes/coherence.ts +187 -0
- package/templates/product-system/packages/backend/src/routes/graph.ts +67 -0
- package/templates/product-system/packages/backend/src/routes/kanban.ts +201 -0
- package/templates/product-system/packages/backend/src/routes/pipeline.ts +41 -0
- package/templates/product-system/packages/backend/src/routes/projects.ts +122 -0
- package/templates/product-system/packages/backend/src/routes/users.ts +97 -0
- package/templates/product-system/packages/backend/src/server.ts +159 -0
- package/templates/product-system/packages/backend/src/services/auth_service.test.ts +115 -0
- package/templates/product-system/packages/backend/src/services/auth_service.ts +82 -0
- package/templates/product-system/packages/backend/src/services/coherence-review.ts +339 -0
- package/templates/product-system/packages/backend/src/services/email_service.ts +75 -0
- package/templates/product-system/packages/backend/src/services/init.ts +80 -0
- package/templates/product-system/packages/backend/src/services/invitation_service.test.ts +235 -0
- package/templates/product-system/packages/backend/src/services/invitation_service.ts +193 -0
- package/templates/product-system/packages/backend/src/services/password_reset_service.test.ts +151 -0
- package/templates/product-system/packages/backend/src/services/password_reset_service.ts +135 -0
- package/templates/product-system/packages/backend/src/services/project_service.test.ts +215 -0
- package/templates/product-system/packages/backend/src/services/project_service.ts +171 -0
- package/templates/product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -0
- package/templates/product-system/packages/backend/src/services/pty_session_manager.ts +279 -0
- package/templates/product-system/packages/backend/src/services/terminal_ws_handler.ts +133 -0
- package/templates/product-system/packages/backend/src/services/user_management_service.test.ts +158 -0
- package/templates/product-system/packages/backend/src/services/user_management_service.ts +128 -0
- package/templates/product-system/packages/backend/tsconfig.json +22 -0
- package/templates/product-system/packages/frontend/index.html +13 -0
- package/templates/product-system/packages/frontend/package-lock.json +2666 -0
- package/templates/product-system/packages/frontend/package.json +30 -0
- package/templates/product-system/packages/frontend/public/favicon.svg +16 -0
- package/templates/product-system/packages/frontend/src/App.tsx +29 -0
- package/templates/product-system/packages/frontend/src/api/client.ts +386 -0
- package/templates/product-system/packages/frontend/src/api/client_projects.test.ts +104 -0
- package/templates/product-system/packages/frontend/src/api/client_refresh.test.ts +145 -0
- package/templates/product-system/packages/frontend/src/components/CoherenceView.tsx +414 -0
- package/templates/product-system/packages/frontend/src/components/GraphLegend.tsx +124 -0
- package/templates/product-system/packages/frontend/src/components/GraphSettings.tsx +112 -0
- package/templates/product-system/packages/frontend/src/components/GraphView.tsx +370 -0
- package/templates/product-system/packages/frontend/src/components/InviteUserDialog.tsx +85 -0
- package/templates/product-system/packages/frontend/src/components/KanbanView.tsx +470 -0
- package/templates/product-system/packages/frontend/src/components/LoginPage.tsx +116 -0
- package/templates/product-system/packages/frontend/src/components/ProjectSelector.tsx +187 -0
- package/templates/product-system/packages/frontend/src/components/QaIssueSheet.tsx +192 -0
- package/templates/product-system/packages/frontend/src/components/SidePanel.tsx +231 -0
- package/templates/product-system/packages/frontend/src/components/TerminalView.tsx +200 -0
- package/templates/product-system/packages/frontend/src/components/Toolbar.tsx +84 -0
- package/templates/product-system/packages/frontend/src/components/UsersView.tsx +249 -0
- package/templates/product-system/packages/frontend/src/constants/graph.ts +191 -0
- package/templates/product-system/packages/frontend/src/hooks/useAuth.tsx +54 -0
- package/templates/product-system/packages/frontend/src/hooks/useGraph.ts +27 -0
- package/templates/product-system/packages/frontend/src/hooks/useKanban.ts +21 -0
- package/templates/product-system/packages/frontend/src/hooks/useProjects.ts +86 -0
- package/templates/product-system/packages/frontend/src/hooks/useTheme.ts +26 -0
- package/templates/product-system/packages/frontend/src/hooks/useToast.tsx +62 -0
- package/templates/product-system/packages/frontend/src/hooks/use_projects_logic.test.ts +61 -0
- package/templates/product-system/packages/frontend/src/main.tsx +12 -0
- package/templates/product-system/packages/frontend/src/pages/accept_invitation_page.tsx +167 -0
- package/templates/product-system/packages/frontend/src/pages/forgot_password_page.tsx +100 -0
- package/templates/product-system/packages/frontend/src/pages/register_page.tsx +137 -0
- package/templates/product-system/packages/frontend/src/pages/reset_password_page.tsx +146 -0
- package/templates/product-system/packages/frontend/src/routes/ProtectedRoute.tsx +12 -0
- package/templates/product-system/packages/frontend/src/routes/accept_invitation.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/dashboard.tsx +221 -0
- package/templates/product-system/packages/frontend/src/routes/forgot_password.tsx +13 -0
- package/templates/product-system/packages/frontend/src/routes/login.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/register.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/reset_password.tsx +13 -0
- package/templates/product-system/packages/frontend/src/styles/index.css +3358 -0
- package/templates/product-system/packages/frontend/src/utils/auth_validation.test.ts +51 -0
- package/templates/product-system/packages/frontend/src/utils/auth_validation.ts +19 -0
- package/templates/product-system/packages/frontend/src/utils/login_validation.test.ts +61 -0
- package/templates/product-system/packages/frontend/src/utils/login_validation.ts +24 -0
- package/templates/product-system/packages/frontend/src/utils/logout.test.ts +63 -0
- package/templates/product-system/packages/frontend/src/utils/node_sizing.test.ts +62 -0
- package/templates/product-system/packages/frontend/src/utils/node_sizing.ts +24 -0
- package/templates/product-system/packages/frontend/src/utils/task_status.test.ts +53 -0
- package/templates/product-system/packages/frontend/src/utils/task_status.ts +14 -0
- package/templates/product-system/packages/frontend/tsconfig.json +21 -0
- package/templates/product-system/packages/frontend/vite.config.ts +20 -0
- package/templates/product-system/packages/shared/.env.example +3 -0
- package/templates/product-system/packages/shared/README.md +1 -0
- package/templates/product-system/packages/shared/db/migrate.ts +32 -0
- package/templates/product-system/packages/shared/db/migrations/0000_dashing_gorgon.sql +128 -0
- package/templates/product-system/packages/shared/db/migrations/meta/0000_snapshot.json +819 -0
- package/templates/product-system/packages/shared/db/migrations/meta/_journal.json +13 -0
- package/templates/product-system/packages/shared/db/schema.ts +137 -0
- package/templates/product-system/packages/shared/drizzle.config.js +14 -0
- package/templates/product-system/packages/shared/lib/claude-service.ts +215 -0
- package/templates/product-system/packages/shared/lib/coherence.ts +278 -0
- package/templates/product-system/packages/shared/lib/completeness.ts +30 -0
- package/templates/product-system/packages/shared/lib/constants.ts +327 -0
- package/templates/product-system/packages/shared/lib/db.ts +81 -0
- package/templates/product-system/packages/shared/lib/git_workflow.ts +110 -0
- package/templates/product-system/packages/shared/lib/graph.ts +186 -0
- package/templates/product-system/packages/shared/lib/kanban.ts +161 -0
- package/templates/product-system/packages/shared/lib/markdown.ts +205 -0
- package/templates/product-system/packages/shared/lib/pipeline-state-store.ts +124 -0
- package/templates/product-system/packages/shared/lib/pipeline.ts +489 -0
- package/templates/product-system/packages/shared/lib/prompt_builder.ts +170 -0
- package/templates/product-system/packages/shared/lib/relevance_search.ts +159 -0
- package/templates/product-system/packages/shared/lib/session.ts +152 -0
- package/templates/product-system/packages/shared/lib/validator.ts +117 -0
- package/templates/product-system/packages/shared/lib/work_summary_parser.ts +130 -0
- package/templates/product-system/packages/shared/package.json +30 -0
- package/templates/product-system/packages/shared/scripts/assign-project.ts +52 -0
- package/templates/product-system/packages/shared/tools/add_edge.ts +61 -0
- package/templates/product-system/packages/shared/tools/add_node.ts +101 -0
- package/templates/product-system/packages/shared/tools/end_session.ts +87 -0
- package/templates/product-system/packages/shared/tools/get_gaps.ts +87 -0
- package/templates/product-system/packages/shared/tools/get_kanban.ts +125 -0
- package/templates/product-system/packages/shared/tools/get_node.ts +78 -0
- package/templates/product-system/packages/shared/tools/get_status.ts +98 -0
- package/templates/product-system/packages/shared/tools/migrate_to_turso.ts +385 -0
- package/templates/product-system/packages/shared/tools/move_card.ts +143 -0
- package/templates/product-system/packages/shared/tools/rebuild_index.ts +77 -0
- package/templates/product-system/packages/shared/tools/remove_edge.ts +59 -0
- package/templates/product-system/packages/shared/tools/remove_node.ts +96 -0
- package/templates/product-system/packages/shared/tools/resolve_question.ts +75 -0
- package/templates/product-system/packages/shared/tools/search_nodes.ts +106 -0
- package/templates/product-system/packages/shared/tools/start_session.ts +144 -0
- package/templates/product-system/packages/shared/tools/update_node.ts +133 -0
- package/templates/product-system/packages/shared/tsconfig.json +24 -0
- package/templates/product-system/pnpm-workspace.yaml +2 -0
- package/templates/product-system/smoke_test.ts +219 -0
- package/templates/product-system/tests/coherence_review.test.ts +562 -0
- package/templates/product-system/tests/db_sqlite_fallback.test.ts +75 -0
- package/templates/product-system/tests/edge_type_color_coding.test.ts +147 -0
- package/templates/product-system/tests/emit-tool-use-events.test.ts +85 -0
- package/templates/product-system/tests/feature_kind.test.ts +139 -0
- package/templates/product-system/tests/gap_indicators.test.ts +199 -0
- package/templates/product-system/tests/graceful_init.test.ts +142 -0
- package/templates/product-system/tests/graph_legend.test.ts +314 -0
- package/templates/product-system/tests/graph_settings_sheet.test.ts +804 -0
- package/templates/product-system/tests/hide_defined_filter.test.ts +205 -0
- package/templates/product-system/tests/kanban.test.ts +529 -0
- package/templates/product-system/tests/neighborhood_focus.test.ts +132 -0
- package/templates/product-system/tests/node_search.test.ts +340 -0
- package/templates/product-system/tests/node_sizing.test.ts +170 -0
- package/templates/product-system/tests/node_type_toggle_filters.test.ts +285 -0
- package/templates/product-system/tests/node_type_visual_encoding.test.ts +103 -0
- package/templates/product-system/tests/pipeline-state-store.test.ts +268 -0
- package/templates/product-system/tests/pipeline-unit.test.ts +593 -0
- package/templates/product-system/tests/pipeline.test.ts +195 -0
- package/templates/product-system/tests/pipeline_stats_all_cards.test.ts +193 -0
- package/templates/product-system/tests/play_all.test.ts +296 -0
- package/templates/product-system/tests/qa_issue_sheet.test.ts +464 -0
- package/templates/product-system/tests/relevance_search.test.ts +186 -0
- package/templates/product-system/tests/search_reorder.test.ts +88 -0
- package/templates/product-system/tests/serve_ui.test.ts +281 -0
- package/templates/product-system/tests/serve_ui_drizzle.test.ts +114 -0
- package/templates/product-system/tests/session_context_recall.test.ts +135 -0
- package/templates/product-system/tests/side_panel.test.ts +345 -0
- package/templates/product-system/tests/spec_completeness_label.test.ts +69 -0
- package/templates/product-system/tests/url_routing_test.ts +122 -0
- package/templates/product-system/tests/user_login.test.ts +150 -0
- package/templates/product-system/tests/user_registration.test.ts +205 -0
- package/templates/product-system/tests/web_terminal.test.ts +572 -0
- package/templates/product-system/tests/work_summary.test.ts +211 -0
- package/templates/product-system/tests/zoom_pan.test.ts +43 -0
- package/templates/product-system/tsconfig.json +24 -0
- package/templates/skills/product-bootstrap/SKILL.md +312 -0
- package/templates/skills/product-code-reviewer/SKILL.md +147 -0
- package/templates/skills/product-debugger/SKILL.md +206 -0
- package/templates/skills/product-debugger/references/agent-browser.md +1156 -0
- package/templates/skills/product-developer/SKILL.md +182 -0
- package/templates/skills/product-interview/SKILL.md +220 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for kanban board server endpoints and data transformations.
|
|
3
|
+
* Uses node:test built-in runner.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { createServer } from 'node:http';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
// Valid kanban columns — users can move cards between any columns (mirrors server logic)
|
|
15
|
+
const VALID_COLUMNS = ['todo', 'in_progress', 'in_review', 'qa', 'done'];
|
|
16
|
+
|
|
17
|
+
// In-memory kanban data used by the test server
|
|
18
|
+
let kanbanData;
|
|
19
|
+
|
|
20
|
+
const INITIAL_KANBAN = () => ({
|
|
21
|
+
feat_001: { column: 'done', rejection_count: 0, notes: [] },
|
|
22
|
+
feat_009: { column: 'todo', rejection_count: 0, notes: [] },
|
|
23
|
+
feat_010: { column: 'done', rejection_count: 0, notes: [] },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const startTestServer = async () => {
|
|
27
|
+
const sendJson = (res, code, data) => {
|
|
28
|
+
const body = JSON.stringify(data);
|
|
29
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
30
|
+
res.end(body);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
34
|
+
const chunks = [];
|
|
35
|
+
req.on('data', c => chunks.push(c));
|
|
36
|
+
req.on('end', () => {
|
|
37
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
|
|
38
|
+
catch { reject(new Error('Invalid JSON')); }
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const handler = async (req, res) => {
|
|
43
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
44
|
+
const path = url.pathname;
|
|
45
|
+
const method = req.method;
|
|
46
|
+
|
|
47
|
+
if (method === 'GET' && path === '/api/kanban') {
|
|
48
|
+
return sendJson(res, 200, kanbanData);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const moveMatch = path.match(/^\/api\/kanban\/([a-z_]+\d+)\/move$/);
|
|
52
|
+
if (method === 'POST' && moveMatch) {
|
|
53
|
+
const featureId = moveMatch[1];
|
|
54
|
+
const body = await readBody(req);
|
|
55
|
+
const entry = kanbanData[featureId];
|
|
56
|
+
if (!entry) return sendJson(res, 404, { error: 'Not found' });
|
|
57
|
+
if (!VALID_COLUMNS.includes(body.column)) {
|
|
58
|
+
return sendJson(res, 400, { error: `Invalid column "${body.column}"` });
|
|
59
|
+
}
|
|
60
|
+
if (entry.column === body.column) {
|
|
61
|
+
return sendJson(res, 400, { error: `Card is already in "${body.column}"` });
|
|
62
|
+
}
|
|
63
|
+
const prev = entry.column;
|
|
64
|
+
entry.column = body.column;
|
|
65
|
+
if (prev === 'qa' && body.column === 'todo') {
|
|
66
|
+
entry.rejection_count = (entry.rejection_count || 0) + 1;
|
|
67
|
+
}
|
|
68
|
+
return sendJson(res, 200, { feature_id: featureId, previous_column: prev, new_column: body.column, rejection_count: entry.rejection_count || 0 });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const notesMatch = path.match(/^\/api\/kanban\/([a-z_]+\d+)\/notes$/);
|
|
72
|
+
if (method === 'POST' && notesMatch) {
|
|
73
|
+
const featureId = notesMatch[1];
|
|
74
|
+
const body = await readBody(req);
|
|
75
|
+
const entry = kanbanData[featureId];
|
|
76
|
+
if (!entry) return sendJson(res, 404, { error: 'Not found' });
|
|
77
|
+
const note = { id: randomUUID().slice(0, 8), text: body.text, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
|
78
|
+
if (!entry.notes) entry.notes = [];
|
|
79
|
+
entry.notes.push(note);
|
|
80
|
+
return sendJson(res, 201, note);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const noteMatch = path.match(/^\/api\/kanban\/([a-z_]+\d+)\/notes\/([a-z0-9]+)$/);
|
|
84
|
+
if (method === 'PUT' && noteMatch) {
|
|
85
|
+
const [, featureId, noteId] = noteMatch;
|
|
86
|
+
const body = await readBody(req);
|
|
87
|
+
const entry = kanbanData[featureId];
|
|
88
|
+
if (!entry) return sendJson(res, 404, { error: 'Not found' });
|
|
89
|
+
const note = (entry.notes || []).find(n => n.id === noteId);
|
|
90
|
+
if (!note) return sendJson(res, 404, { error: 'Note not found' });
|
|
91
|
+
note.text = body.text;
|
|
92
|
+
note.updated_at = new Date().toISOString();
|
|
93
|
+
return sendJson(res, 200, note);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (method === 'DELETE' && noteMatch) {
|
|
97
|
+
const [, featureId, noteId] = noteMatch;
|
|
98
|
+
const entry = kanbanData[featureId];
|
|
99
|
+
if (!entry) return sendJson(res, 404, { error: 'Not found' });
|
|
100
|
+
const idx = (entry.notes || []).findIndex(n => n.id === noteId);
|
|
101
|
+
if (idx === -1) return sendJson(res, 404, { error: 'Note not found' });
|
|
102
|
+
entry.notes.splice(idx, 1);
|
|
103
|
+
return sendJson(res, 200, { deleted: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
sendJson(res, 404, { error: 'Not found' });
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const server = createServer(handler);
|
|
110
|
+
await new Promise(resolve => server.listen(0, resolve));
|
|
111
|
+
return { server, baseUrl: `http://localhost:${server.address().port}` };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
describe('Kanban API', () => {
|
|
115
|
+
let server, baseUrl;
|
|
116
|
+
|
|
117
|
+
beforeEach(async () => {
|
|
118
|
+
kanbanData = INITIAL_KANBAN();
|
|
119
|
+
const s = await startTestServer();
|
|
120
|
+
server = s.server;
|
|
121
|
+
baseUrl = s.baseUrl;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterEach(async () => {
|
|
125
|
+
server.close();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('GET /api/kanban returns kanban data', async () => {
|
|
129
|
+
const resp = await fetch(`${baseUrl}/api/kanban`);
|
|
130
|
+
assert.equal(resp.status, 200);
|
|
131
|
+
const data = await resp.json();
|
|
132
|
+
assert.ok(typeof data === 'object');
|
|
133
|
+
assert.ok('feat_001' in data);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('POST /api/kanban/:id/move — valid in_review→qa transition', async () => {
|
|
137
|
+
// Set feat_009 to in_review for this test
|
|
138
|
+
kanbanData.feat_009.column = 'in_review';
|
|
139
|
+
|
|
140
|
+
const resp = await fetch(`${baseUrl}/api/kanban/feat_009/move`, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'Content-Type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({ column: 'qa' }),
|
|
144
|
+
});
|
|
145
|
+
assert.equal(resp.status, 200);
|
|
146
|
+
const data = await resp.json();
|
|
147
|
+
assert.equal(data.previous_column, 'in_review');
|
|
148
|
+
assert.equal(data.new_column, 'qa');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('POST /api/kanban/:id/move — rejects move to same column', async () => {
|
|
152
|
+
// feat_010 is in 'done', moving to 'done' should be rejected
|
|
153
|
+
const resp = await fetch(`${baseUrl}/api/kanban/feat_010/move`, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'Content-Type': 'application/json' },
|
|
156
|
+
body: JSON.stringify({ column: 'done' }),
|
|
157
|
+
});
|
|
158
|
+
assert.equal(resp.status, 400);
|
|
159
|
+
const data = await resp.json();
|
|
160
|
+
assert.ok(data.error.includes('already in'));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('POST /api/kanban/:id/move — rejects invalid column name', async () => {
|
|
164
|
+
const resp = await fetch(`${baseUrl}/api/kanban/feat_010/move`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/json' },
|
|
167
|
+
body: JSON.stringify({ column: 'nonexistent' }),
|
|
168
|
+
});
|
|
169
|
+
assert.equal(resp.status, 400);
|
|
170
|
+
const data = await resp.json();
|
|
171
|
+
assert.ok(data.error.includes('Invalid column'));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('POST /api/kanban/:id/move — allows free-form transitions (done→todo)', async () => {
|
|
175
|
+
const resp = await fetch(`${baseUrl}/api/kanban/feat_010/move`, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: { 'Content-Type': 'application/json' },
|
|
178
|
+
body: JSON.stringify({ column: 'todo' }),
|
|
179
|
+
});
|
|
180
|
+
assert.equal(resp.status, 200);
|
|
181
|
+
const data = await resp.json();
|
|
182
|
+
assert.equal(data.previous_column, 'done');
|
|
183
|
+
assert.equal(data.new_column, 'todo');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('POST /api/kanban/:id/move — qa→todo increments rejection_count', async () => {
|
|
187
|
+
// Set feat_009 to qa with rejection_count 2
|
|
188
|
+
kanbanData.feat_009.column = 'qa';
|
|
189
|
+
kanbanData.feat_009.rejection_count = 2;
|
|
190
|
+
|
|
191
|
+
const resp = await fetch(`${baseUrl}/api/kanban/feat_009/move`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'Content-Type': 'application/json' },
|
|
194
|
+
body: JSON.stringify({ column: 'todo' }),
|
|
195
|
+
});
|
|
196
|
+
assert.equal(resp.status, 200);
|
|
197
|
+
const data = await resp.json();
|
|
198
|
+
assert.equal(data.rejection_count, 3);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('POST /api/kanban/:id/notes — creates a rejection note', async () => {
|
|
202
|
+
const resp = await fetch(`${baseUrl}/api/kanban/feat_009/notes`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'Content-Type': 'application/json' },
|
|
205
|
+
body: JSON.stringify({ text: 'Missing edge case handling' }),
|
|
206
|
+
});
|
|
207
|
+
assert.equal(resp.status, 201);
|
|
208
|
+
const note = await resp.json();
|
|
209
|
+
assert.ok(note.id);
|
|
210
|
+
assert.equal(note.text, 'Missing edge case handling');
|
|
211
|
+
assert.ok(note.created_at);
|
|
212
|
+
|
|
213
|
+
// Verify it persisted in memory
|
|
214
|
+
assert.equal(kanbanData.feat_009.notes.length, 1);
|
|
215
|
+
assert.equal(kanbanData.feat_009.notes[0].text, 'Missing edge case handling');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('PUT /api/kanban/:id/notes/:nid — updates a note', async () => {
|
|
219
|
+
// Create a note first
|
|
220
|
+
const createResp = await fetch(`${baseUrl}/api/kanban/feat_009/notes`, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: { 'Content-Type': 'application/json' },
|
|
223
|
+
body: JSON.stringify({ text: 'Original text' }),
|
|
224
|
+
});
|
|
225
|
+
const created = await createResp.json();
|
|
226
|
+
|
|
227
|
+
const resp = await fetch(`${baseUrl}/api/kanban/feat_009/notes/${created.id}`, {
|
|
228
|
+
method: 'PUT',
|
|
229
|
+
headers: { 'Content-Type': 'application/json' },
|
|
230
|
+
body: JSON.stringify({ text: 'Updated text' }),
|
|
231
|
+
});
|
|
232
|
+
assert.equal(resp.status, 200);
|
|
233
|
+
const updated = await resp.json();
|
|
234
|
+
assert.equal(updated.text, 'Updated text');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('DELETE /api/kanban/:id/notes/:nid — deletes a note', async () => {
|
|
238
|
+
// Create a note first
|
|
239
|
+
const createResp = await fetch(`${baseUrl}/api/kanban/feat_009/notes`, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: { 'Content-Type': 'application/json' },
|
|
242
|
+
body: JSON.stringify({ text: 'To be deleted' }),
|
|
243
|
+
});
|
|
244
|
+
const created = await createResp.json();
|
|
245
|
+
|
|
246
|
+
const resp = await fetch(`${baseUrl}/api/kanban/feat_009/notes/${created.id}`, {
|
|
247
|
+
method: 'DELETE',
|
|
248
|
+
});
|
|
249
|
+
assert.equal(resp.status, 200);
|
|
250
|
+
|
|
251
|
+
// Verify deletion
|
|
252
|
+
assert.equal(kanbanData.feat_009.notes.length, 0);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('returns 404 for non-existent feature', async () => {
|
|
256
|
+
const resp = await fetch(`${baseUrl}/api/kanban/feat_999/move`, {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: { 'Content-Type': 'application/json' },
|
|
259
|
+
body: JSON.stringify({ column: 'done' }),
|
|
260
|
+
});
|
|
261
|
+
assert.equal(resp.status, 404);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('Kanban data transformations', () => {
|
|
266
|
+
it('includes all feature nodes regardless of spec status', () => {
|
|
267
|
+
const nodes = [
|
|
268
|
+
{ id: 'feat_001', type: 'feature', status: 'defined' },
|
|
269
|
+
{ id: 'feat_002', type: 'feature', status: 'draft' },
|
|
270
|
+
{ id: 'feat_003', type: 'feature', status: 'partially_defined' },
|
|
271
|
+
{ id: 'comp_001', type: 'component', status: 'defined' },
|
|
272
|
+
];
|
|
273
|
+
const features = nodes.filter(n => n.type === 'feature');
|
|
274
|
+
assert.equal(features.length, 3);
|
|
275
|
+
assert.deepEqual(features.map(f => f.id), ['feat_001', 'feat_002', 'feat_003']);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('groups cards by column', () => {
|
|
279
|
+
const kanban = {
|
|
280
|
+
feat_001: { column: 'done' },
|
|
281
|
+
feat_002: { column: 'todo' },
|
|
282
|
+
feat_003: { column: 'todo' },
|
|
283
|
+
feat_004: { column: 'in_progress' },
|
|
284
|
+
};
|
|
285
|
+
const grouped = {};
|
|
286
|
+
Object.entries(kanban).forEach(([id, entry]) => {
|
|
287
|
+
if (!grouped[entry.column]) grouped[entry.column] = [];
|
|
288
|
+
grouped[entry.column].push(id);
|
|
289
|
+
});
|
|
290
|
+
assert.equal(grouped.todo.length, 2);
|
|
291
|
+
assert.equal(grouped.done.length, 1);
|
|
292
|
+
assert.equal(grouped.in_progress.length, 1);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('validates user transitions — free-form between any different columns', () => {
|
|
296
|
+
const isValidUserTransition = (from, to) => {
|
|
297
|
+
return VALID_COLUMNS.includes(from) && VALID_COLUMNS.includes(to) && from !== to;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// All cross-column moves are valid for users
|
|
301
|
+
assert.ok(isValidUserTransition('in_review', 'qa'));
|
|
302
|
+
assert.ok(isValidUserTransition('qa', 'done'));
|
|
303
|
+
assert.ok(isValidUserTransition('qa', 'todo'));
|
|
304
|
+
assert.ok(isValidUserTransition('todo', 'in_progress'));
|
|
305
|
+
assert.ok(isValidUserTransition('todo', 'done'));
|
|
306
|
+
assert.ok(isValidUserTransition('done', 'todo'));
|
|
307
|
+
assert.ok(isValidUserTransition('in_progress', 'qa'));
|
|
308
|
+
|
|
309
|
+
// Same column is not valid
|
|
310
|
+
assert.ok(!isValidUserTransition('todo', 'todo'));
|
|
311
|
+
assert.ok(!isValidUserTransition('done', 'done'));
|
|
312
|
+
|
|
313
|
+
// Invalid column names are not valid
|
|
314
|
+
assert.ok(!isValidUserTransition('todo', 'nonexistent'));
|
|
315
|
+
assert.ok(!isValidUserTransition('nonexistent', 'todo'));
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('identifies problematic cards (3+ rejections)', () => {
|
|
319
|
+
const cards = [
|
|
320
|
+
{ id: 'feat_001', rejectionCount: 0 },
|
|
321
|
+
{ id: 'feat_002', rejectionCount: 2 },
|
|
322
|
+
{ id: 'feat_003', rejectionCount: 3 },
|
|
323
|
+
{ id: 'feat_004', rejectionCount: 5 },
|
|
324
|
+
];
|
|
325
|
+
const problematic = cards.filter(c => c.rejectionCount >= 3);
|
|
326
|
+
assert.equal(problematic.length, 2);
|
|
327
|
+
assert.deepEqual(problematic.map(c => c.id), ['feat_003', 'feat_004']);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('Retry attempt label (feat_025)', () => {
|
|
332
|
+
const PIPELINE_STATUS_LABELS = {
|
|
333
|
+
creating_worktree: 'Setting up...',
|
|
334
|
+
developing: 'Developing',
|
|
335
|
+
reviewing: 'Reviewing',
|
|
336
|
+
fixing_review: 'Fixing review',
|
|
337
|
+
fixing_merge: 'Fixing merge',
|
|
338
|
+
merging: 'Merging',
|
|
339
|
+
completed: 'Completed',
|
|
340
|
+
failed: 'Failed',
|
|
341
|
+
blocked: 'Blocked',
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Mirror the helper from kanban_renderer.js
|
|
345
|
+
const getPipelineBadgeText = (pipelineStatus, rejectionCount) => {
|
|
346
|
+
const label = PIPELINE_STATUS_LABELS[pipelineStatus] || pipelineStatus;
|
|
347
|
+
if (rejectionCount >= 1) {
|
|
348
|
+
return `Retry ${rejectionCount + 1}`;
|
|
349
|
+
}
|
|
350
|
+
return label;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
it('first attempt (rejection_count=0) shows status label only', () => {
|
|
354
|
+
assert.equal(getPipelineBadgeText('developing', 0), 'Developing');
|
|
355
|
+
assert.equal(getPipelineBadgeText('reviewing', 0), 'Reviewing');
|
|
356
|
+
assert.equal(getPipelineBadgeText('merging', 0), 'Merging');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('second attempt (rejection_count=1) shows "Retry 2"', () => {
|
|
360
|
+
assert.equal(getPipelineBadgeText('developing', 1), 'Retry 2');
|
|
361
|
+
assert.equal(getPipelineBadgeText('reviewing', 1), 'Retry 2');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('third attempt (rejection_count=2) shows "Retry 3"', () => {
|
|
365
|
+
assert.equal(getPipelineBadgeText('developing', 2), 'Retry 3');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('higher rejection counts continue the pattern', () => {
|
|
369
|
+
assert.equal(getPipelineBadgeText('developing', 5), 'Retry 6');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('does not include (N/M) format', () => {
|
|
373
|
+
const text = getPipelineBadgeText('developing', 0);
|
|
374
|
+
assert.ok(!text.includes('/'), 'Should not contain slash');
|
|
375
|
+
assert.ok(!text.includes('('), 'Should not contain parenthesis');
|
|
376
|
+
|
|
377
|
+
const retryText = getPipelineBadgeText('developing', 2);
|
|
378
|
+
assert.ok(!retryText.includes('/'), 'Retry text should not contain slash');
|
|
379
|
+
assert.ok(!retryText.includes('('), 'Retry text should not contain parenthesis');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('handles unknown pipeline status gracefully', () => {
|
|
383
|
+
assert.equal(getPipelineBadgeText('some_new_status', 0), 'some_new_status');
|
|
384
|
+
assert.equal(getPipelineBadgeText('some_new_status', 1), 'Retry 2');
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('Kanban Card Copy Button (feat_038)', () => {
|
|
389
|
+
// Mirror the copy text format from kanban_renderer.js
|
|
390
|
+
const getCopyText = (cardId, cardName) => `${cardId} ${cardName}`;
|
|
391
|
+
|
|
392
|
+
it('formats copy text as "feat_XXX Feature Name"', () => {
|
|
393
|
+
assert.equal(getCopyText('feat_001', 'Node CRUD Operations'), 'feat_001 Node CRUD Operations');
|
|
394
|
+
assert.equal(getCopyText('feat_021', 'Kanban Board View'), 'feat_021 Kanban Board View');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('handles feature names with special characters', () => {
|
|
398
|
+
assert.equal(
|
|
399
|
+
getCopyText('feat_033', 'Test Bug'),
|
|
400
|
+
'feat_033 Test Bug'
|
|
401
|
+
);
|
|
402
|
+
assert.equal(
|
|
403
|
+
getCopyText('feat_037', 'Fix: Graph Settings Not Persisted and Labels Reset on Clear Selection'),
|
|
404
|
+
'feat_037 Fix: Graph Settings Not Persisted and Labels Reset on Clear Selection'
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('copy button element is created with correct attributes', () => {
|
|
409
|
+
// Simulate the card data structure used by buildCard
|
|
410
|
+
const card = {
|
|
411
|
+
id: 'feat_038',
|
|
412
|
+
name: 'Kanban Card Copy Button',
|
|
413
|
+
completeness: 0.8,
|
|
414
|
+
kind: 'new',
|
|
415
|
+
rejectionCount: 0,
|
|
416
|
+
notes: [],
|
|
417
|
+
column: 'in_progress',
|
|
418
|
+
devBlocked: false,
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// Verify the copy text is correctly assembled from card properties
|
|
422
|
+
const copyText = `${card.id} ${card.name}`;
|
|
423
|
+
assert.equal(copyText, 'feat_038 Kanban Card Copy Button');
|
|
424
|
+
assert.ok(copyText.startsWith('feat_'));
|
|
425
|
+
assert.ok(copyText.includes(' '));
|
|
426
|
+
// The text should have exactly one space between id and name
|
|
427
|
+
const parts = copyText.split(' ');
|
|
428
|
+
assert.equal(parts[0], 'feat_038');
|
|
429
|
+
assert.equal(parts.slice(1).join(' '), 'Kanban Card Copy Button');
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe('All Features on Kanban Board (feat_036)', () => {
|
|
434
|
+
it('TODO column sorts by completeness descending', () => {
|
|
435
|
+
const cards = [
|
|
436
|
+
{ id: 'feat_001', completeness: 0.3, column: 'todo' },
|
|
437
|
+
{ id: 'feat_002', completeness: 0.9, column: 'todo' },
|
|
438
|
+
{ id: 'feat_003', completeness: 0.5, column: 'todo' },
|
|
439
|
+
];
|
|
440
|
+
cards.sort((a, b) => (b.completeness || 0) - (a.completeness || 0));
|
|
441
|
+
assert.deepEqual(cards.map(c => c.id), ['feat_002', 'feat_003', 'feat_001']);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('Done column sorts by moved_at descending (newest first)', () => {
|
|
445
|
+
const cards = [
|
|
446
|
+
{ id: 'feat_001', movedAt: '2026-02-27T10:00:00.000Z' },
|
|
447
|
+
{ id: 'feat_003', movedAt: '2026-02-27T12:00:00.000Z' },
|
|
448
|
+
{ id: 'feat_002', movedAt: '2026-02-27T11:00:00.000Z' },
|
|
449
|
+
];
|
|
450
|
+
// Done column sort: descending by moved_at
|
|
451
|
+
cards.sort((a, b) => {
|
|
452
|
+
const aTime = a.movedAt || '';
|
|
453
|
+
const bTime = b.movedAt || '';
|
|
454
|
+
return bTime.localeCompare(aTime);
|
|
455
|
+
});
|
|
456
|
+
assert.deepEqual(cards.map(c => c.id), ['feat_003', 'feat_002', 'feat_001']);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('non-TODO/non-Done columns sort by moved_at timestamp chronologically', () => {
|
|
460
|
+
const cards = [
|
|
461
|
+
{ id: 'feat_003', movedAt: '2026-02-27T12:00:00.000Z' },
|
|
462
|
+
{ id: 'feat_001', movedAt: '2026-02-27T10:00:00.000Z' },
|
|
463
|
+
{ id: 'feat_002', movedAt: '2026-02-27T11:00:00.000Z' },
|
|
464
|
+
];
|
|
465
|
+
cards.sort((a, b) => {
|
|
466
|
+
const aTime = a.movedAt || '';
|
|
467
|
+
const bTime = b.movedAt || '';
|
|
468
|
+
return aTime.localeCompare(bTime);
|
|
469
|
+
});
|
|
470
|
+
assert.deepEqual(cards.map(c => c.id), ['feat_001', 'feat_002', 'feat_003']);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('cards without moved_at sort before cards with moved_at', () => {
|
|
474
|
+
const cards = [
|
|
475
|
+
{ id: 'feat_002', movedAt: '2026-02-27T12:00:00.000Z' },
|
|
476
|
+
{ id: 'feat_001', movedAt: null },
|
|
477
|
+
{ id: 'feat_003', movedAt: '2026-02-27T10:00:00.000Z' },
|
|
478
|
+
];
|
|
479
|
+
cards.sort((a, b) => {
|
|
480
|
+
const aTime = a.movedAt || '';
|
|
481
|
+
const bTime = b.movedAt || '';
|
|
482
|
+
return aTime.localeCompare(bTime);
|
|
483
|
+
});
|
|
484
|
+
assert.deepEqual(cards.map(c => c.id), ['feat_001', 'feat_003', 'feat_002']);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('auto-adds missing features to TODO column with moved_at', () => {
|
|
488
|
+
const graphNodes = [
|
|
489
|
+
{ id: 'feat_001', type: 'feature', created_at: '2026-01-01T00:00:00.000Z' },
|
|
490
|
+
{ id: 'feat_002', type: 'feature', created_at: '2026-01-02T00:00:00.000Z' },
|
|
491
|
+
{ id: 'feat_003', type: 'feature', created_at: '2026-01-03T00:00:00.000Z' },
|
|
492
|
+
{ id: 'comp_001', type: 'component' },
|
|
493
|
+
];
|
|
494
|
+
const kanban = {
|
|
495
|
+
feat_001: { column: 'done', rejection_count: 0, notes: [] },
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// Simulate auto-add logic
|
|
499
|
+
const featureNodes = graphNodes.filter(n => n.type === 'feature');
|
|
500
|
+
for (const node of featureNodes) {
|
|
501
|
+
if (!kanban[node.id]) {
|
|
502
|
+
kanban[node.id] = {
|
|
503
|
+
column: 'todo',
|
|
504
|
+
rejection_count: 0,
|
|
505
|
+
notes: [],
|
|
506
|
+
moved_at: node.created_at || new Date().toISOString(),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
assert.equal(Object.keys(kanban).length, 3);
|
|
512
|
+
assert.equal(kanban.feat_002.column, 'todo');
|
|
513
|
+
assert.equal(kanban.feat_002.moved_at, '2026-01-02T00:00:00.000Z');
|
|
514
|
+
assert.equal(kanban.feat_003.column, 'todo');
|
|
515
|
+
assert.ok(!kanban.comp_001); // components excluded
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('move card sets moved_at timestamp', () => {
|
|
519
|
+
const entry = { column: 'todo', rejection_count: 0, notes: [] };
|
|
520
|
+
const before = new Date().toISOString();
|
|
521
|
+
entry.column = 'in_progress';
|
|
522
|
+
entry.moved_at = new Date().toISOString();
|
|
523
|
+
const after = new Date().toISOString();
|
|
524
|
+
|
|
525
|
+
assert.equal(entry.column, 'in_progress');
|
|
526
|
+
assert.ok(entry.moved_at >= before);
|
|
527
|
+
assert.ok(entry.moved_at <= after);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Neighborhood Focus Mode (feat_012).
|
|
3
|
+
* Uses node:test built-in runner.
|
|
4
|
+
* Tests the neighbor identification logic that drives
|
|
5
|
+
* the focus mode highlighting behavior.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
const createRenderer = (links: any[] = []) => {
|
|
10
|
+
return {
|
|
11
|
+
links,
|
|
12
|
+
getNeighborIds(nodeId: string) {
|
|
13
|
+
const neighbors = new Set<string>();
|
|
14
|
+
if (!this.links) return neighbors;
|
|
15
|
+
for (const link of this.links) {
|
|
16
|
+
const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
|
|
17
|
+
const targetId = typeof link.target === 'object' ? link.target.id : link.target;
|
|
18
|
+
if (sourceId === nodeId) neighbors.add(targetId);
|
|
19
|
+
if (targetId === nodeId) neighbors.add(sourceId);
|
|
20
|
+
}
|
|
21
|
+
return neighbors;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe('Neighborhood Focus Mode', () => {
|
|
27
|
+
|
|
28
|
+
describe('getNeighborIds', () => {
|
|
29
|
+
|
|
30
|
+
it('returns direct neighbors from outgoing edges', () => {
|
|
31
|
+
const renderer = createRenderer([
|
|
32
|
+
{ source: 'A', target: 'B', relation: 'contains' },
|
|
33
|
+
{ source: 'A', target: 'C', relation: 'depends_on' },
|
|
34
|
+
]);
|
|
35
|
+
const neighbors = renderer.getNeighborIds('A');
|
|
36
|
+
assert.deepEqual(neighbors, new Set(['B', 'C']));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns direct neighbors from incoming edges', () => {
|
|
40
|
+
const renderer = createRenderer([
|
|
41
|
+
{ source: 'B', target: 'A', relation: 'contains' },
|
|
42
|
+
{ source: 'C', target: 'A', relation: 'depends_on' },
|
|
43
|
+
]);
|
|
44
|
+
const neighbors = renderer.getNeighborIds('A');
|
|
45
|
+
assert.deepEqual(neighbors, new Set(['B', 'C']));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns neighbors from both incoming and outgoing edges', () => {
|
|
49
|
+
const renderer = createRenderer([
|
|
50
|
+
{ source: 'A', target: 'B', relation: 'contains' },
|
|
51
|
+
{ source: 'C', target: 'A', relation: 'depends_on' },
|
|
52
|
+
]);
|
|
53
|
+
const neighbors = renderer.getNeighborIds('A');
|
|
54
|
+
assert.deepEqual(neighbors, new Set(['B', 'C']));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not include non-neighbor nodes', () => {
|
|
58
|
+
const renderer = createRenderer([
|
|
59
|
+
{ source: 'A', target: 'B', relation: 'contains' },
|
|
60
|
+
{ source: 'C', target: 'D', relation: 'depends_on' },
|
|
61
|
+
]);
|
|
62
|
+
const neighbors = renderer.getNeighborIds('A');
|
|
63
|
+
assert.equal(neighbors.has('B'), true);
|
|
64
|
+
assert.equal(neighbors.has('C'), false);
|
|
65
|
+
assert.equal(neighbors.has('D'), false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not include the node itself', () => {
|
|
69
|
+
const renderer = createRenderer([
|
|
70
|
+
{ source: 'A', target: 'B', relation: 'contains' },
|
|
71
|
+
]);
|
|
72
|
+
const neighbors = renderer.getNeighborIds('A');
|
|
73
|
+
assert.equal(neighbors.has('A'), false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns empty set for isolated node with no edges', () => {
|
|
77
|
+
const renderer = createRenderer([
|
|
78
|
+
{ source: 'B', target: 'C', relation: 'contains' },
|
|
79
|
+
]);
|
|
80
|
+
const neighbors = renderer.getNeighborIds('A');
|
|
81
|
+
assert.equal(neighbors.size, 0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns empty set when links is null', () => {
|
|
85
|
+
const renderer = createRenderer(null);
|
|
86
|
+
const neighbors = renderer.getNeighborIds('A');
|
|
87
|
+
assert.equal(neighbors.size, 0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('handles D3 object-style link references', () => {
|
|
91
|
+
const renderer = createRenderer([
|
|
92
|
+
{ source: { id: 'A' }, target: { id: 'B' }, relation: 'contains' },
|
|
93
|
+
{ source: { id: 'C' }, target: { id: 'A' }, relation: 'depends_on' },
|
|
94
|
+
]);
|
|
95
|
+
const neighbors = renderer.getNeighborIds('A');
|
|
96
|
+
assert.deepEqual(neighbors, new Set(['B', 'C']));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('deduplicates when multiple edges connect same neighbor', () => {
|
|
100
|
+
const renderer = createRenderer([
|
|
101
|
+
{ source: 'A', target: 'B', relation: 'contains' },
|
|
102
|
+
{ source: 'A', target: 'B', relation: 'depends_on' },
|
|
103
|
+
]);
|
|
104
|
+
const neighbors = renderer.getNeighborIds('A');
|
|
105
|
+
assert.equal(neighbors.size, 1);
|
|
106
|
+
assert.equal(neighbors.has('B'), true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('finds all neighbors in a hub topology', () => {
|
|
110
|
+
const renderer = createRenderer([
|
|
111
|
+
{ source: 'hub', target: 'spoke1', relation: 'contains' },
|
|
112
|
+
{ source: 'hub', target: 'spoke2', relation: 'contains' },
|
|
113
|
+
{ source: 'hub', target: 'spoke3', relation: 'contains' },
|
|
114
|
+
{ source: 'spoke4', target: 'hub', relation: 'depends_on' },
|
|
115
|
+
]);
|
|
116
|
+
const neighbors = renderer.getNeighborIds('hub');
|
|
117
|
+
assert.deepEqual(neighbors, new Set(['spoke1', 'spoke2', 'spoke3', 'spoke4']));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('does not include second-degree neighbors', () => {
|
|
121
|
+
const renderer = createRenderer([
|
|
122
|
+
{ source: 'A', target: 'B', relation: 'contains' },
|
|
123
|
+
{ source: 'B', target: 'C', relation: 'contains' },
|
|
124
|
+
{ source: 'C', target: 'D', relation: 'contains' },
|
|
125
|
+
]);
|
|
126
|
+
const neighbors = renderer.getNeighborIds('A');
|
|
127
|
+
assert.equal(neighbors.has('B'), true);
|
|
128
|
+
assert.equal(neighbors.has('C'), false, 'second-degree neighbor C should not be included');
|
|
129
|
+
assert.equal(neighbors.has('D'), false, 'third-degree neighbor D should not be included');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|