@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,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for QA Issue Reporting Side Sheet (feat_039).
|
|
3
|
+
* Uses node:test built-in runner.
|
|
4
|
+
* Tests the sheet logic: open/close, note rendering, CRUD callbacks,
|
|
5
|
+
* read-only mode outside QA column, and outside-click behavior.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, mock } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
class QaIssueSheet {
|
|
10
|
+
sheetEl: any;
|
|
11
|
+
apiClient: any;
|
|
12
|
+
document: any;
|
|
13
|
+
onNotesChanged: any;
|
|
14
|
+
isOpen = false;
|
|
15
|
+
featureId: string | null = null;
|
|
16
|
+
featureName: string | null = null;
|
|
17
|
+
column: string | null = null;
|
|
18
|
+
notes: any[] = [];
|
|
19
|
+
reviews: any[] = [];
|
|
20
|
+
titleEl: any = null;
|
|
21
|
+
bodyEl: any = null;
|
|
22
|
+
|
|
23
|
+
constructor(sheetEl: any, apiClient: any, document: any, onNotesChanged: any) {
|
|
24
|
+
this.sheetEl = sheetEl;
|
|
25
|
+
this.apiClient = apiClient;
|
|
26
|
+
this.document = document;
|
|
27
|
+
this.onNotesChanged = onNotesChanged;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
init() {
|
|
31
|
+
this.titleEl = this.sheetEl.querySelector('.qa-sheet-title');
|
|
32
|
+
this.bodyEl = this.sheetEl.querySelector('.qa-sheet-body');
|
|
33
|
+
this.bindClose();
|
|
34
|
+
this.bindDocumentClick();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
bindClose() {
|
|
38
|
+
const btn = this.sheetEl.querySelector('.qa-sheet-close');
|
|
39
|
+
if (btn) btn.addEventListener('click', () => this.close());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_handleDocumentClick: any = null;
|
|
43
|
+
|
|
44
|
+
bindDocumentClick() {
|
|
45
|
+
this._handleDocumentClick = (e: any) => this.handleDocumentClick(e);
|
|
46
|
+
this.document.addEventListener('click', this._handleDocumentClick);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
handleDocumentClick(e: any) {
|
|
50
|
+
if (!this.isOpen) return;
|
|
51
|
+
if (this.sheetEl.contains(e.target)) return;
|
|
52
|
+
if (e.target.closest?.('.kanban-issues-btn')) return;
|
|
53
|
+
this.close();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
open(featureId: string, featureName: string, column: string, notes: any[], reviews?: any[]) {
|
|
57
|
+
this.featureId = featureId;
|
|
58
|
+
this.featureName = featureName;
|
|
59
|
+
this.column = column;
|
|
60
|
+
this.notes = notes || [];
|
|
61
|
+
this.reviews = reviews || [];
|
|
62
|
+
this.isOpen = true;
|
|
63
|
+
this.render();
|
|
64
|
+
this.sheetEl.classList.add('open');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
close() {
|
|
68
|
+
this.isOpen = false;
|
|
69
|
+
this.featureId = null;
|
|
70
|
+
this.sheetEl.classList.remove('open');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
isEditable() { return this.column === 'qa'; }
|
|
74
|
+
|
|
75
|
+
render() {
|
|
76
|
+
if (this.titleEl) this.titleEl.textContent = `${this.featureId} — ${this.featureName}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
formatDate(isoString: string | null | undefined): string {
|
|
80
|
+
if (!isoString) return '';
|
|
81
|
+
try {
|
|
82
|
+
const d = new Date(isoString);
|
|
83
|
+
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
84
|
+
} catch { return ''; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async refreshNotes() {
|
|
88
|
+
if (!this.featureId) return;
|
|
89
|
+
try {
|
|
90
|
+
const kanbanData = await this.apiClient.fetchKanban();
|
|
91
|
+
const entry = kanbanData[this.featureId];
|
|
92
|
+
if (entry) {
|
|
93
|
+
this.notes = entry.notes || [];
|
|
94
|
+
this.reviews = entry.reviews || [];
|
|
95
|
+
}
|
|
96
|
+
this.render();
|
|
97
|
+
if (this.onNotesChanged) this.onNotesChanged();
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async deleteNote(note: any) {
|
|
102
|
+
try {
|
|
103
|
+
await this.apiClient.deleteNote(this.featureId, note.id);
|
|
104
|
+
await this.refreshNotes();
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
destroy() {
|
|
109
|
+
this.document.removeEventListener('click', this._handleDocumentClick);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Minimal DOM element mock
|
|
114
|
+
const createMockElement = (attrs: Record<string, any> = {}) => {
|
|
115
|
+
const listeners = {};
|
|
116
|
+
const classList = new Set();
|
|
117
|
+
const children: any[] = [];
|
|
118
|
+
|
|
119
|
+
const el: Record<string, any> = {
|
|
120
|
+
dataset: attrs.dataset || {},
|
|
121
|
+
innerHTML: '',
|
|
122
|
+
textContent: '',
|
|
123
|
+
style: {},
|
|
124
|
+
listeners,
|
|
125
|
+
classList: {
|
|
126
|
+
add: (c: string) => classList.add(c),
|
|
127
|
+
remove: (c: string) => classList.delete(c),
|
|
128
|
+
contains: (c: string) => classList.has(c),
|
|
129
|
+
},
|
|
130
|
+
_classList: classList,
|
|
131
|
+
addEventListener: (event: string, handler: any) => {
|
|
132
|
+
listeners[event] = listeners[event] || [];
|
|
133
|
+
listeners[event].push(handler);
|
|
134
|
+
},
|
|
135
|
+
removeEventListener: (event: string, handler: any) => {
|
|
136
|
+
if (listeners[event]) {
|
|
137
|
+
listeners[event] = listeners[event].filter((h: any) => h !== handler);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
querySelector: (selector: string) => {
|
|
141
|
+
return children.find((c: any) => c._role === selector) || null;
|
|
142
|
+
},
|
|
143
|
+
querySelectorAll: () => [] as any[],
|
|
144
|
+
appendChild: (child: any) => { children.push(child); },
|
|
145
|
+
_addChild: (child: any) => { child._role = child._role || null; children.push(child); },
|
|
146
|
+
contains: (target: any) => target === el || children.includes(target),
|
|
147
|
+
closest: () => null,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return el;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Mock document
|
|
154
|
+
const createMockDocument = () => {
|
|
155
|
+
const listeners: Record<string, any[]> = {};
|
|
156
|
+
return {
|
|
157
|
+
createElement: (tag: string) => {
|
|
158
|
+
const el = createMockElement();
|
|
159
|
+
el._tag = tag;
|
|
160
|
+
el.appendChild = (child: any) => {};
|
|
161
|
+
el.focus = () => {};
|
|
162
|
+
return el;
|
|
163
|
+
},
|
|
164
|
+
addEventListener: (event: string, handler: any) => {
|
|
165
|
+
listeners[event] = listeners[event] || [];
|
|
166
|
+
listeners[event].push(handler);
|
|
167
|
+
},
|
|
168
|
+
removeEventListener: (event: string, handler: any) => {
|
|
169
|
+
if (listeners[event]) {
|
|
170
|
+
listeners[event] = listeners[event].filter((h: any) => h !== handler);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
_listeners: listeners,
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Mock API client
|
|
178
|
+
const createMockApiClient = (kanbanData = {}) => ({
|
|
179
|
+
createNote: mock.fn(async () => ({ id: 'n1', text: 'new note', created_at: new Date().toISOString() })),
|
|
180
|
+
updateNote: mock.fn(async () => ({ id: 'n1', text: 'updated', updated_at: new Date().toISOString() })),
|
|
181
|
+
deleteNote: mock.fn(async () => ({ ok: true })),
|
|
182
|
+
fetchKanban: mock.fn(async () => kanbanData),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('QaIssueSheet', () => {
|
|
186
|
+
|
|
187
|
+
describe('open/close', () => {
|
|
188
|
+
it('starts closed', () => {
|
|
189
|
+
const sheetEl = createMockElement();
|
|
190
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
191
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
192
|
+
const doc = createMockDocument();
|
|
193
|
+
const api = createMockApiClient();
|
|
194
|
+
|
|
195
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
196
|
+
assert.equal(sheet.isOpen, false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('opens when open() is called', () => {
|
|
200
|
+
const sheetEl = createMockElement();
|
|
201
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
202
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
203
|
+
const doc = createMockDocument();
|
|
204
|
+
const api = createMockApiClient();
|
|
205
|
+
|
|
206
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
207
|
+
sheet.init();
|
|
208
|
+
sheet.open('feat_001', 'Test Feature', 'qa', []);
|
|
209
|
+
|
|
210
|
+
assert.equal(sheet.isOpen, true);
|
|
211
|
+
assert.equal(sheet.featureId, 'feat_001');
|
|
212
|
+
assert.ok(sheetEl._classList.has('open'));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('closes when close() is called', () => {
|
|
216
|
+
const sheetEl = createMockElement();
|
|
217
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
218
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
219
|
+
const doc = createMockDocument();
|
|
220
|
+
const api = createMockApiClient();
|
|
221
|
+
|
|
222
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
223
|
+
sheet.init();
|
|
224
|
+
sheet.open('feat_001', 'Test Feature', 'qa', []);
|
|
225
|
+
sheet.close();
|
|
226
|
+
|
|
227
|
+
assert.equal(sheet.isOpen, false);
|
|
228
|
+
assert.equal(sheet.featureId, null);
|
|
229
|
+
assert.ok(!sheetEl._classList.has('open'));
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('isEditable', () => {
|
|
234
|
+
it('returns true when column is qa', () => {
|
|
235
|
+
const sheetEl = createMockElement();
|
|
236
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
237
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
238
|
+
const doc = createMockDocument();
|
|
239
|
+
const api = createMockApiClient();
|
|
240
|
+
|
|
241
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
242
|
+
sheet.init();
|
|
243
|
+
sheet.open('feat_001', 'Test Feature', 'qa', []);
|
|
244
|
+
|
|
245
|
+
assert.equal(sheet.isEditable(), true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('returns false when column is todo', () => {
|
|
249
|
+
const sheetEl = createMockElement();
|
|
250
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
251
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
252
|
+
const doc = createMockDocument();
|
|
253
|
+
const api = createMockApiClient();
|
|
254
|
+
|
|
255
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
256
|
+
sheet.init();
|
|
257
|
+
sheet.open('feat_001', 'Test Feature', 'todo', []);
|
|
258
|
+
|
|
259
|
+
assert.equal(sheet.isEditable(), false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('returns false when column is done', () => {
|
|
263
|
+
const sheetEl = createMockElement();
|
|
264
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
265
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
266
|
+
const doc = createMockDocument();
|
|
267
|
+
const api = createMockApiClient();
|
|
268
|
+
|
|
269
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
270
|
+
sheet.init();
|
|
271
|
+
sheet.open('feat_001', 'Test Feature', 'done', [{ id: 'n1', text: 'issue', created_at: '2026-01-01T00:00:00Z' }]);
|
|
272
|
+
|
|
273
|
+
assert.equal(sheet.isEditable(), false);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('document click (outside click to close)', () => {
|
|
278
|
+
it('closes sheet when clicking outside', () => {
|
|
279
|
+
const sheetEl = createMockElement();
|
|
280
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
281
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
282
|
+
const doc = createMockDocument();
|
|
283
|
+
const api = createMockApiClient();
|
|
284
|
+
|
|
285
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
286
|
+
sheet.init();
|
|
287
|
+
sheet.open('feat_001', 'Test Feature', 'qa', []);
|
|
288
|
+
|
|
289
|
+
const outsideTarget = createMockElement();
|
|
290
|
+
sheet.handleDocumentClick({ target: outsideTarget });
|
|
291
|
+
|
|
292
|
+
assert.equal(sheet.isOpen, false);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('does not close when clicking inside the sheet', () => {
|
|
296
|
+
const sheetEl = createMockElement();
|
|
297
|
+
const titleEl = Object.assign(createMockElement(), { _role: '.qa-sheet-title' });
|
|
298
|
+
sheetEl._addChild(titleEl);
|
|
299
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
300
|
+
const doc = createMockDocument();
|
|
301
|
+
const api = createMockApiClient();
|
|
302
|
+
|
|
303
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
304
|
+
sheet.init();
|
|
305
|
+
sheet.open('feat_001', 'Test Feature', 'qa', []);
|
|
306
|
+
|
|
307
|
+
sheet.handleDocumentClick({ target: titleEl });
|
|
308
|
+
|
|
309
|
+
assert.equal(sheet.isOpen, true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('does not close when sheet is already closed', () => {
|
|
313
|
+
const sheetEl = createMockElement();
|
|
314
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
315
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
316
|
+
const doc = createMockDocument();
|
|
317
|
+
const api = createMockApiClient();
|
|
318
|
+
|
|
319
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
320
|
+
sheet.init();
|
|
321
|
+
|
|
322
|
+
const outsideTarget = createMockElement();
|
|
323
|
+
sheet.handleDocumentClick({ target: outsideTarget });
|
|
324
|
+
|
|
325
|
+
assert.equal(sheet.isOpen, false);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('formatDate', () => {
|
|
330
|
+
it('formats ISO string to readable date', () => {
|
|
331
|
+
const sheetEl = createMockElement();
|
|
332
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
333
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
334
|
+
const doc = createMockDocument();
|
|
335
|
+
const api = createMockApiClient();
|
|
336
|
+
|
|
337
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
338
|
+
const result = sheet.formatDate('2026-02-27T10:30:00Z');
|
|
339
|
+
|
|
340
|
+
assert.ok(result.length > 0);
|
|
341
|
+
assert.ok(result.includes('Feb') || result.includes('27'));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('returns empty string for null/undefined', () => {
|
|
345
|
+
const sheetEl = createMockElement();
|
|
346
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
347
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
348
|
+
const doc = createMockDocument();
|
|
349
|
+
const api = createMockApiClient();
|
|
350
|
+
|
|
351
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
352
|
+
assert.equal(sheet.formatDate(null), '');
|
|
353
|
+
assert.equal(sheet.formatDate(undefined), '');
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe('notes data management', () => {
|
|
358
|
+
it('stores notes passed to open()', () => {
|
|
359
|
+
const sheetEl = createMockElement();
|
|
360
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
361
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
362
|
+
const doc = createMockDocument();
|
|
363
|
+
const api = createMockApiClient();
|
|
364
|
+
|
|
365
|
+
const notes = [
|
|
366
|
+
{ id: 'n1', text: 'Bug in validation', created_at: '2026-02-27T10:00:00Z' },
|
|
367
|
+
{ id: 'n2', text: 'Missing error handling', created_at: '2026-02-27T11:00:00Z' },
|
|
368
|
+
];
|
|
369
|
+
|
|
370
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
371
|
+
sheet.init();
|
|
372
|
+
sheet.open('feat_001', 'Test Feature', 'qa', notes);
|
|
373
|
+
|
|
374
|
+
assert.equal(sheet.notes.length, 2);
|
|
375
|
+
assert.equal(sheet.notes[0].text, 'Bug in validation');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('defaults to empty notes when null is passed', () => {
|
|
379
|
+
const sheetEl = createMockElement();
|
|
380
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
381
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
382
|
+
const doc = createMockDocument();
|
|
383
|
+
const api = createMockApiClient();
|
|
384
|
+
|
|
385
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
386
|
+
sheet.init();
|
|
387
|
+
sheet.open('feat_001', 'Test Feature', 'qa', null);
|
|
388
|
+
|
|
389
|
+
assert.deepEqual(sheet.notes, []);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
describe('refreshNotes', () => {
|
|
394
|
+
it('fetches fresh kanban data and updates notes', async () => {
|
|
395
|
+
const sheetEl = createMockElement();
|
|
396
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
397
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
398
|
+
const doc = createMockDocument();
|
|
399
|
+
const kanbanData = {
|
|
400
|
+
feat_001: {
|
|
401
|
+
column: 'qa',
|
|
402
|
+
notes: [
|
|
403
|
+
{ id: 'n1', text: 'Refreshed note', created_at: '2026-02-27T12:00:00Z' },
|
|
404
|
+
],
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
const api = createMockApiClient(kanbanData);
|
|
408
|
+
|
|
409
|
+
const onNotesChanged = mock.fn();
|
|
410
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, onNotesChanged);
|
|
411
|
+
sheet.init();
|
|
412
|
+
sheet.open('feat_001', 'Test Feature', 'qa', []);
|
|
413
|
+
|
|
414
|
+
await sheet.refreshNotes();
|
|
415
|
+
|
|
416
|
+
assert.equal(sheet.notes.length, 1);
|
|
417
|
+
assert.equal(sheet.notes[0].text, 'Refreshed note');
|
|
418
|
+
assert.equal(api.fetchKanban.mock.calls.length, 1);
|
|
419
|
+
assert.equal(onNotesChanged.mock.calls.length, 1);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe('deleteNote', () => {
|
|
424
|
+
it('calls apiClient.deleteNote and refreshes', async () => {
|
|
425
|
+
const sheetEl = createMockElement();
|
|
426
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
427
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
428
|
+
const doc = createMockDocument();
|
|
429
|
+
const kanbanData = {
|
|
430
|
+
feat_001: { column: 'qa', notes: [] },
|
|
431
|
+
};
|
|
432
|
+
const api = createMockApiClient(kanbanData);
|
|
433
|
+
|
|
434
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
435
|
+
sheet.init();
|
|
436
|
+
sheet.open('feat_001', 'Test Feature', 'qa', [{ id: 'n1', text: 'To delete' }]);
|
|
437
|
+
|
|
438
|
+
await sheet.deleteNote({ id: 'n1', text: 'To delete' });
|
|
439
|
+
|
|
440
|
+
assert.equal(api.deleteNote.mock.calls.length, 1);
|
|
441
|
+
assert.equal((api.deleteNote.mock.calls as any[])[0].arguments[0], 'feat_001');
|
|
442
|
+
assert.equal((api.deleteNote.mock.calls as any[])[0].arguments[1], 'n1');
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe('destroy', () => {
|
|
447
|
+
it('removes document click listener', () => {
|
|
448
|
+
const sheetEl = createMockElement();
|
|
449
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-title' }));
|
|
450
|
+
sheetEl._addChild(Object.assign(createMockElement(), { _role: '.qa-sheet-body' }));
|
|
451
|
+
const doc = createMockDocument();
|
|
452
|
+
const api = createMockApiClient();
|
|
453
|
+
|
|
454
|
+
const sheet = new QaIssueSheet(sheetEl, api, doc, null);
|
|
455
|
+
sheet.init();
|
|
456
|
+
|
|
457
|
+
assert.ok(doc._listeners['click'] && doc._listeners['click'].length > 0);
|
|
458
|
+
|
|
459
|
+
sheet.destroy();
|
|
460
|
+
|
|
461
|
+
assert.equal(doc._listeners['click'].length, 0);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Smart Search with Relevance Ranking (feat_051).
|
|
3
|
+
* Uses node:test built-in runner.
|
|
4
|
+
* Tests the relevance search algorithm: direct matches, graph expansion,
|
|
5
|
+
* edge type weighting, deduplication, and cold start fallback.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { relevanceSearch, coldStartFallback } from '../packages/shared/lib/relevance_search.js';
|
|
10
|
+
|
|
11
|
+
// --- Test fixtures ---
|
|
12
|
+
|
|
13
|
+
const makeNode = (id, type, name, status = 'defined') => ({
|
|
14
|
+
id, type, name, status, completeness: 1.0, open_questions_count: 0,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const makeEdge = (from, relation, to) => ({ from, relation, to });
|
|
18
|
+
|
|
19
|
+
const NODES = [
|
|
20
|
+
makeNode('feat_001', 'feature', 'Node CRUD Operations'),
|
|
21
|
+
makeNode('feat_002', 'feature', 'Edge Management'),
|
|
22
|
+
makeNode('feat_003', 'feature', 'Search and Discovery'),
|
|
23
|
+
makeNode('comp_001', 'component', 'Graph Engine'),
|
|
24
|
+
makeNode('dec_001', 'decision', 'Force-Directed Layout'),
|
|
25
|
+
makeNode('tech_001', 'tech_choice', 'D3.js Force Graph'),
|
|
26
|
+
makeNode('nfr_001', 'non_functional_requirement', 'Performance Guidelines'),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const EDGES = [
|
|
30
|
+
makeEdge('feat_001', 'implemented_with', 'comp_001'),
|
|
31
|
+
makeEdge('feat_002', 'implemented_with', 'comp_001'),
|
|
32
|
+
makeEdge('feat_003', 'depends_on', 'feat_001'),
|
|
33
|
+
makeEdge('feat_003', 'depends_on', 'feat_002'),
|
|
34
|
+
makeEdge('comp_001', 'governed_by', 'dec_001'),
|
|
35
|
+
makeEdge('dec_001', 'relates_to', 'tech_001'),
|
|
36
|
+
makeEdge('feat_001', 'constrained_by', 'nfr_001'),
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// Simple body lookup — only comp_001 has body containing "atomic writes"
|
|
40
|
+
const BODIES = {
|
|
41
|
+
comp_001: 'Core data layer managing the graph. Uses atomic writes for safety.',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const getBody = async (id) => BODIES[id] ?? null;
|
|
45
|
+
|
|
46
|
+
// --- Tests ---
|
|
47
|
+
|
|
48
|
+
describe('relevanceSearch', () => {
|
|
49
|
+
|
|
50
|
+
describe('direct matches', () => {
|
|
51
|
+
it('returns direct match by name with relevance "direct"', async () => {
|
|
52
|
+
const results = await relevanceSearch('CRUD', NODES, EDGES, getBody);
|
|
53
|
+
const direct = results.filter(r => r.relevance === 'direct');
|
|
54
|
+
assert.equal(direct.length, 1);
|
|
55
|
+
assert.equal(direct[0].id, 'feat_001');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('matches body content as direct match', async () => {
|
|
59
|
+
const results = await relevanceSearch('atomic', NODES, EDGES, getBody);
|
|
60
|
+
const direct = results.filter(r => r.relevance === 'direct');
|
|
61
|
+
assert.equal(direct.length, 1);
|
|
62
|
+
assert.equal(direct[0].id, 'comp_001');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('is case-insensitive', async () => {
|
|
66
|
+
const lower = await relevanceSearch('crud', NODES, EDGES, getBody);
|
|
67
|
+
const upper = await relevanceSearch('CRUD', NODES, EDGES, getBody);
|
|
68
|
+
assert.equal(lower.length, upper.length);
|
|
69
|
+
assert.equal(lower[0].id, upper[0].id);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('graph expansion', () => {
|
|
74
|
+
it('includes 1-hop neighbors of direct matches', async () => {
|
|
75
|
+
const results = await relevanceSearch('CRUD', NODES, EDGES, getBody);
|
|
76
|
+
const ids = results.map(r => r.id);
|
|
77
|
+
// feat_001 direct → comp_001 (implemented_with), feat_003 (depends_on), nfr_001 (constrained_by)
|
|
78
|
+
assert.ok(ids.includes('comp_001'), 'should include comp_001 via implemented_with');
|
|
79
|
+
assert.ok(ids.includes('feat_003'), 'should include feat_003 via depends_on');
|
|
80
|
+
assert.ok(ids.includes('nfr_001'), 'should include nfr_001 via constrained_by');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('neighbor relevance shows connection path', async () => {
|
|
84
|
+
const results = await relevanceSearch('CRUD', NODES, EDGES, getBody);
|
|
85
|
+
const comp = results.find(r => r.id === 'comp_001');
|
|
86
|
+
assert.ok(comp.relevance.includes('via feat_001'), 'should show source node');
|
|
87
|
+
assert.ok(comp.relevance.includes('implemented_with'), 'should show edge type');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('edge type weighting', () => {
|
|
92
|
+
it('strong-edge neighbors rank above weak-edge neighbors', async () => {
|
|
93
|
+
// Search for "Force-Directed" → direct match dec_001
|
|
94
|
+
// dec_001's neighbors: comp_001 (governed_by - strong, inbound), tech_001 (relates_to - weak)
|
|
95
|
+
const results = await relevanceSearch('Force-Directed', NODES, EDGES, getBody);
|
|
96
|
+
|
|
97
|
+
const comp = results.find(r => r.id === 'comp_001');
|
|
98
|
+
const tech = results.find(r => r.id === 'tech_001');
|
|
99
|
+
assert.ok(comp, 'comp_001 should be in results (1-hop via governed_by)');
|
|
100
|
+
assert.ok(tech, 'tech_001 should be in results (1-hop via relates_to)');
|
|
101
|
+
assert.ok(comp._score > tech._score,
|
|
102
|
+
`comp_001 (strong edge, score=${comp._score}) should rank above tech_001 (weak edge, score=${tech._score})`);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('depends_on, implemented_with, governed_by, contains are strong edges', async () => {
|
|
106
|
+
const results = await relevanceSearch('CRUD', NODES, EDGES, getBody);
|
|
107
|
+
// comp_001 is via implemented_with (strong)
|
|
108
|
+
const comp = results.find(r => r.id === 'comp_001');
|
|
109
|
+
assert.equal(comp._score, 50, 'implemented_with should give score 50');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('relates_to is a weak edge', async () => {
|
|
113
|
+
const results = await relevanceSearch('Force-Directed', NODES, EDGES, getBody);
|
|
114
|
+
const tech = results.find(r => r.id === 'tech_001');
|
|
115
|
+
assert.equal(tech._score, 20, 'relates_to should give score 20');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('sorting', () => {
|
|
120
|
+
it('direct matches come first in results', async () => {
|
|
121
|
+
const results = await relevanceSearch('CRUD', NODES, EDGES, getBody);
|
|
122
|
+
assert.equal(results[0].relevance, 'direct');
|
|
123
|
+
assert.equal(results[0].id, 'feat_001');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('results are sorted by score descending', async () => {
|
|
127
|
+
const results = await relevanceSearch('CRUD', NODES, EDGES, getBody);
|
|
128
|
+
for (let i = 1; i < results.length; i++) {
|
|
129
|
+
assert.ok(results[i - 1]._score >= results[i]._score,
|
|
130
|
+
`result[${i - 1}] score ${results[i - 1]._score} should be >= result[${i}] score ${results[i]._score}`);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('deduplication', () => {
|
|
136
|
+
it('keeps highest score when node appears as both direct and neighbor', async () => {
|
|
137
|
+
// Search "Search" — matches feat_003 "Search and Discovery" directly
|
|
138
|
+
// feat_003 depends_on feat_001 and feat_002, which are neighbors
|
|
139
|
+
// If we also search for something that makes feat_003 a neighbor of another direct match,
|
|
140
|
+
// the direct score (100) should win over neighbor score
|
|
141
|
+
|
|
142
|
+
// Create a scenario: feat_003 is both a direct match and a neighbor of feat_001
|
|
143
|
+
const results = await relevanceSearch('Search', NODES, EDGES, getBody);
|
|
144
|
+
const feat003 = results.find(r => r.id === 'feat_003');
|
|
145
|
+
assert.ok(feat003, 'feat_003 should be in results');
|
|
146
|
+
assert.equal(feat003._score, 100, 'direct match should keep score 100');
|
|
147
|
+
assert.equal(feat003.relevance, 'direct');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('cold start fallback', () => {
|
|
152
|
+
it('returns results when keyword matches nothing', async () => {
|
|
153
|
+
const results = await relevanceSearch('xyznonexistent', NODES, EDGES, getBody);
|
|
154
|
+
assert.ok(results.length > 0, 'cold start should return structural overview');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('cold start results have structural overview relevance', async () => {
|
|
158
|
+
const results = await relevanceSearch('xyznonexistent', NODES, EDGES, getBody);
|
|
159
|
+
for (const r of results) {
|
|
160
|
+
assert.ok(r.relevance.includes('structural overview'),
|
|
161
|
+
`result ${r.id} should have structural overview relevance, got: ${r.relevance}`);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('cold start returns at most 5 per type', async () => {
|
|
166
|
+
// Create many nodes of one type
|
|
167
|
+
const manyFeatures = Array.from({ length: 10 }, (_, i) =>
|
|
168
|
+
makeNode(`feat_${String(i + 100).padStart(3, '0')}`, 'feature', `Feature ${i + 100}`)
|
|
169
|
+
);
|
|
170
|
+
const manyEdges = manyFeatures.map(n => makeEdge(n.id, 'relates_to', 'comp_001'));
|
|
171
|
+
|
|
172
|
+
const results = await relevanceSearch('xyznonexistent', manyFeatures, manyEdges, getBody);
|
|
173
|
+
const featureResults = results.filter(r => r.type === 'feature');
|
|
174
|
+
assert.ok(featureResults.length <= 5, `should return at most 5 per type, got ${featureResults.length}`);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('cold start ranks by total edge count', async () => {
|
|
178
|
+
const results = coldStartFallback(NODES, EDGES);
|
|
179
|
+
// comp_001 has 4 edges (feat_001 impl, feat_002 impl, dec_001 gov_by)
|
|
180
|
+
// Should be among the top results
|
|
181
|
+
const comp = results.find(r => r.id === 'comp_001');
|
|
182
|
+
assert.ok(comp, 'comp_001 should be in cold start results');
|
|
183
|
+
assert.ok(comp._score > 0, 'should have positive score from edge count');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|