diff --git a/packages/server/src/services/onboarding-template.test.ts b/packages/server/src/services/onboarding-template.test.ts new file mode 100644 index 00000000..9c3e1a73 --- /dev/null +++ b/packages/server/src/services/onboarding-template.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { WELCOME_NODES, WELCOME_EDGES } from './onboarding.js'; + +/** + * The Welcome graph is the first thing every new user sees, so it must model + * the layout rules the app enforces everywhere else: + * - every node is "placed" (pinned) so the seed positions are authoritative + * and don't drift (a node at exactly (0,0) is treated as unplaced/unpinned); + * - simple template relationships are ORTHOGONAL — every edge is purely + * horizontal or vertical (the user's stated preference for simple graphs); + * - connected nodes sit far enough apart for the edge label to fit with only + * a small margin (the min-edge-length rule), not crammed together. + */ + +// Card footprint (≈ getNodeDimensions) + the min-edge math used by the renderer. +const CARD_W = 170; +const CARD_H = 106; +const LABEL_W = 90; // a typical relationship label ("Depends On") width +const LABEL_MARGIN = 14; + +// Per-axis half-extent the card covers along a horizontal / vertical edge. +const reachH = CARD_W / 2; // 85 +const reachV = CARD_H / 2; // 53 + +describe('Welcome onboarding template', () => { + it('places every node (no node pinned at the unplaced origin)', () => { + for (const n of WELCOME_NODES) { + const atOrigin = n.positionX === 0 && n.positionY === 0; + expect(atOrigin, `"${n.title}" sits at (0,0) and would be unpinned/drift`).toBe(false); + } + }); + + it('draws every edge orthogonally (horizontal or vertical)', () => { + for (const e of WELCOME_EDGES) { + const s = WELCOME_NODES[e.sourceIndex]; + const t = WELCOME_NODES[e.targetIndex]; + const dx = Math.abs(s.positionX - t.positionX); + const dy = Math.abs(s.positionY - t.positionY); + const orthogonal = dx < 1 || dy < 1; + expect( + orthogonal, + `edge ${e.sourceIndex}->${e.targetIndex} ("${s.title}"->"${t.title}") is diagonal: dx=${dx} dy=${dy}` + ).toBe(true); + } + }); + + it('spaces connected nodes so the edge label fits with a small margin', () => { + for (const e of WELCOME_EDGES) { + const s = WELCOME_NODES[e.sourceIndex]; + const t = WELCOME_NODES[e.targetIndex]; + const dx = Math.abs(s.positionX - t.positionX); + const dy = Math.abs(s.positionY - t.positionY); + const horizontal = dy < 1; + const centerDist = horizontal ? dx : dy; + const reach = horizontal ? reachH : reachV; + const gap = centerDist - 2 * reach; + expect( + gap, + `edge ${e.sourceIndex}->${e.targetIndex} gap ${gap.toFixed(0)}px < label ${LABEL_W}+${LABEL_MARGIN}` + ).toBeGreaterThanOrEqual(LABEL_W + LABEL_MARGIN); + } + }); + + it('keeps the graph connected (every node touched by an edge)', () => { + const touched = new Set(); + for (const e of WELCOME_EDGES) { + touched.add(e.sourceIndex); + touched.add(e.targetIndex); + } + for (let i = 0; i < WELCOME_NODES.length; i++) { + expect(touched.has(i), `"${WELCOME_NODES[i].title}" has no edges`).toBe(true); + } + }); + + it('routes no edge straight through a non-endpoint node', () => { + for (const e of WELCOME_EDGES) { + const s = WELCOME_NODES[e.sourceIndex]; + const t = WELCOME_NODES[e.targetIndex]; + const horizontal = Math.abs(s.positionY - t.positionY) < 1; + for (let i = 0; i < WELCOME_NODES.length; i++) { + if (i === e.sourceIndex || i === e.targetIndex) continue; + const n = WELCOME_NODES[i]; + const onLine = horizontal + ? Math.abs(n.positionY - s.positionY) < 1 && + n.positionX > Math.min(s.positionX, t.positionX) && + n.positionX < Math.max(s.positionX, t.positionX) + : Math.abs(n.positionX - s.positionX) < 1 && + n.positionY > Math.min(s.positionY, t.positionY) && + n.positionY < Math.max(s.positionY, t.positionY); + expect(onLine, `edge ${e.sourceIndex}->${e.targetIndex} passes through "${n.title}"`).toBe(false); + } + } + }); +}); diff --git a/packages/server/src/services/onboarding.ts b/packages/server/src/services/onboarding.ts index 2cf843ed..99ddbec2 100644 --- a/packages/server/src/services/onboarding.ts +++ b/packages/server/src/services/onboarding.ts @@ -25,7 +25,7 @@ export async function sharedWelcomeGraphExists(driver: Driver): Promise } } -interface OnboardingNode { +export interface OnboardingNode { title: string; description: string; type: string; @@ -35,13 +35,13 @@ interface OnboardingNode { positionZ: number; } -interface OnboardingEdge { +export interface OnboardingEdge { sourceIndex: number; targetIndex: number; type: string; } -const WELCOME_NODES: OnboardingNode[] = [ +export const WELCOME_NODES: OnboardingNode[] = [ { title: 'Welcome to GraphDone!', description: `# Welcome to GraphDone! 🎉 @@ -62,8 +62,8 @@ GraphDone is a graph-native project management system that reimagines how work f This is your workspace - feel free to edit, delete, or reorganize these tutorial nodes as you learn!`, type: 'DOCUMENTATION', status: 'COMPLETED', - positionX: 0, - positionY: 0, + positionX: -180, + positionY: -240, positionZ: 0 }, { @@ -80,8 +80,8 @@ Each work item has: Try creating a work item right now!`, type: 'TASK', status: 'NOT_STARTED', - positionX: -200, - positionY: 150, + positionX: -180, + positionY: 0, positionZ: 0 }, { @@ -102,8 +102,8 @@ To create a dependency: Dependencies determine priority - the more dependents a node has, the higher its priority becomes.`, type: 'TASK', status: 'NOT_STARTED', - positionX: 200, - positionY: 150, + positionX: 180, + positionY: 0, positionZ: 0 }, { @@ -125,8 +125,8 @@ Dependencies determine priority - the more dependents a node has, the higher its The graph is alive - it reorganizes as you add dependencies and complete work!`, type: 'DOCUMENTATION', status: 'COMPLETED', - positionX: 0, - positionY: 300, + positionX: 180, + positionY: 240, positionZ: 0 }, { @@ -144,8 +144,8 @@ Switch between views using the mode buttons at the top center of the screen. Each view presents the same underlying graph data in different ways - choose what works best for your current task!`, type: 'DOCUMENTATION', status: 'COMPLETED', - positionX: -200, - positionY: -150, + positionX: -180, + positionY: 240, positionZ: 0 }, { @@ -170,21 +170,19 @@ Remember: In GraphDone, work flows through natural dependencies, not artificial You can always come back to this Welcome graph for reference, or delete it when you're ready.`, type: 'MILESTONE', status: 'NOT_STARTED', - positionX: 200, - positionY: -150, + positionX: 180, + positionY: 480, positionZ: 0 } ]; -const WELCOME_EDGES: OnboardingEdge[] = [ - { sourceIndex: 1, targetIndex: 0, type: 'DEPENDS_ON' }, - { sourceIndex: 2, targetIndex: 0, type: 'DEPENDS_ON' }, - { sourceIndex: 3, targetIndex: 0, type: 'RELATES_TO' }, - { sourceIndex: 4, targetIndex: 0, type: 'RELATES_TO' }, - { sourceIndex: 5, targetIndex: 1, type: 'DEPENDS_ON' }, - { sourceIndex: 5, targetIndex: 2, type: 'DEPENDS_ON' }, - { sourceIndex: 5, targetIndex: 3, type: 'DEPENDS_ON' }, - { sourceIndex: 5, targetIndex: 4, type: 'DEPENDS_ON' } +export const WELCOME_EDGES: OnboardingEdge[] = [ + { sourceIndex: 1, targetIndex: 0, type: 'RELATES_TO' }, + { sourceIndex: 2, targetIndex: 1, type: 'DEPENDS_ON' }, + { sourceIndex: 3, targetIndex: 2, type: 'DEPENDS_ON' }, + { sourceIndex: 4, targetIndex: 1, type: 'RELATES_TO' }, + { sourceIndex: 4, targetIndex: 3, type: 'RELATES_TO' }, + { sourceIndex: 5, targetIndex: 3, type: 'DEPENDS_ON' } ]; export async function createSharedWelcomeGraph(driver: Driver): Promise { diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index f235dc5a..93bd79b6 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -3924,6 +3924,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // Track previous node count to detect transition from empty to non-empty const prevNodeCountRef = useRef(0); + // Track the previous edge signature (id + type + direction) so a relationship + // TYPE change or a direction FLIP — which keep the edge COUNT the same — still + // forces a rebuild. Without this the edge label/arrow keep the stale value. + const prevEdgeSigRef = useRef(''); // Comprehensive reinitialization effect - ONLY when actually needed useEffect(() => { @@ -3940,6 +3944,19 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap const isNowPopulated = nodes.length > 0; const transitioningFromEmpty = wasEmpty && isNowPopulated; + // Detect a relationship TYPE change or direction FLIP. Both keep the edge + // count the same, so length-based checks miss them; compare an id+type+ + // direction signature against the last render and force a rebuild on change. + const edgeSig = (validatedEdges as any[]) + .map((e) => { + const sId = typeof e.source === 'object' ? e.source?.id : e.source; + const tId = typeof e.target === 'object' ? e.target?.id : e.target; + return `${e.id}:${e.type}:${sId}>${tId}`; + }) + .sort() + .join(','); + const edgesChanged = prevEdgeSigRef.current !== '' && prevEdgeSigRef.current !== edgeSig; + // Only reinitialize if this is truly necessary const shouldReinit = !svgRef.current || @@ -3947,8 +3964,9 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap nodes.length === 0 || !d3.select(svgRef.current).select('.main-graph-group').node() || reinitTrigger > 0 || - transitioningFromEmpty; // Force reinit when adding first node to empty graph - + transitioningFromEmpty || // Force reinit when adding first node to empty graph + edgesChanged; // relationship type changed or direction flipped + if (shouldReinit) { console.log('[Graph Debug] Full reinitialization required'); initializeVisualization(); @@ -3962,8 +3980,9 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap updateVisualizationData(); } - // Update previous node count for next comparison + // Update previous node count + edge signature for next comparison prevNodeCountRef.current = nodes.length; + prevEdgeSigRef.current = edgeSig; const handleResize = () => { if (!containerRef.current || !svgRef.current || !simulationRef.current) return; @@ -3995,7 +4014,13 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap loading, // Re-init when loading completes edgesLoading, // Re-init when edges loading completes // Track node property changes for selective updates (only titles, descriptions, types) - nodes.map(n => `${n.id}:${n.title}:${n.description}:${n.type}:${n.status}`).join(',') + nodes.map(n => `${n.id}:${n.title}:${n.description}:${n.type}:${n.status}`).join(','), + // Track edge type/direction changes so a relationship edit or flip rebuilds + validatedEdges.map((e: any) => { + const sId = typeof e.source === 'object' ? e.source?.id : e.source; + const tId = typeof e.target === 'object' ? e.target?.id : e.target; + return `${e.id}:${e.type}:${sId}>${tId}`; + }).join(',') ]); // Manual reinitialization function (expose globally for debugging) diff --git a/tests/diagnostics/interaction-audit.spec.ts b/tests/diagnostics/interaction-audit.spec.ts new file mode 100644 index 00000000..461b7a71 --- /dev/null +++ b/tests/diagnostics/interaction-audit.spec.ts @@ -0,0 +1,138 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; +import { sweepTestData, TEST_GRAPH_PREFIX } from '../helpers/dbHealing'; + +/** + * Basic-interaction audit — walks the everyday graph interactions a user does + * and asserts the CORRECT outcome from the real rendered DOM. It exists to make + * "basic" regressions impossible to miss: each check is named after the user + * action it guards, and a failure prints exactly what went wrong. + * + * Covered here (the relationship-editing basics that were visibly broken): + * - changing an edge's relationship TYPE updates its label immediately, with + * no reload (the label used to stay on the old type); + * - flipping an edge's DIRECTION leaves EXACTLY ONE edge for the pair (it + * used to leave a stale duplicate because the delete+recreate kept the same + * edge count and the DOM was never reconciled). + * + * Output: test-artifacts/interaction-audit/report.json + screenshots. Seeds a + * controlled [E2E] graph; healing cleans up even if a run is killed. + */ + +const OUT = path.resolve(process.cwd(), 'test-artifacts/interaction-audit'); + +async function gql(page: Page, query: string, variables?: unknown) { + return page.evaluate(async ({ query, variables }) => { + const token = localStorage.getItem('authToken') ?? ''; + const res = await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ query, variables }), + }); + return res.json(); + }, { query, variables }); +} + +async function readEdges(page: Page) { + return page.evaluate(() => { + const edges: Array<{ id: string; rtype: string; label: string; sId: string; tId: string }> = []; + document.querySelectorAll('.graph-container svg .edge').forEach((e) => { + const d = (e as any).__data__; + if (!d) return; + const sId = typeof d.source === 'object' ? d.source?.id : d.source; + const tId = typeof d.target === 'object' ? d.target?.id : d.target; + edges.push({ id: d.id, rtype: (e as Element).getAttribute('data-rtype') ?? d.type, label: '', sId, tId }); + }); + // labels live in a sibling group, keyed by the same datum id + const labelById: Record = {}; + document.querySelectorAll('.graph-container svg .edge-label-group').forEach((g) => { + const d = (g as any).__data__; + const txt = (g.querySelector('.edge-label') as Element | null)?.textContent ?? ''; + if (d?.id) labelById[d.id] = txt; + }); + for (const e of edges) e.label = labelById[e.id] ?? ''; + return edges; + }); +} + +test.describe('interaction audit @geometry', () => { + test.describe.configure({ timeout: 180_000 }); + test.beforeAll(async () => { await sweepTestData('audit:before'); }); + test.afterAll(async () => { await sweepTestData('audit:after'); }); + + test('relationship type change + flip direction', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + + // Seed one PINNED horizontal edge so the label is on-screen and clickable. + const me = await gql(page, '{ me { id } }'); + const userId = me.data.me.id; + const g = await gql(page, `mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, + { i: [{ name: `${TEST_GRAPH_PREFIX} Audit ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] }); + const graphId = g.data.createGraphs.graphs[0].id; + + const nodeDefs = [{ key: 'A', x: -190, y: 0 }, { key: 'B', x: 190, y: 0 }]; + const created = await gql(page, `mutation($i:[WorkItemCreateInput!]!){createWorkItems(input:$i){workItems{id title}}}`, + { i: nodeDefs.map((n) => ({ type: 'TASK', title: n.key, status: 'IN_PROGRESS', priority: 0.5, positionX: n.x, positionY: n.y, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: graphId } } } } })) }); + const ids: Record = {}; + for (const w of created.data.createWorkItems.workItems) ids[w.title] = w.id; + + await gql(page, `mutation($i:[EdgeCreateInput!]!){createEdges(input:$i){edges{id}}}`, + { i: [{ type: 'DEPENDS_ON', weight: 0.6, source: { connect: { where: { node: { id: ids.A } } } }, target: { connect: { where: { node: { id: ids.B } } } } }] }); + + await page.evaluate((gid) => { localStorage.setItem('currentGraphId', gid); localStorage.setItem('graphdone.quality.override', 'HIGH'); }, graphId); + await page.reload(); + await page.waitForTimeout(7000); + await page.evaluate(() => (window as any).miniMapNavigate?.(0, 0)); + await page.waitForTimeout(1000); + + const results: Record = {}; + + // ── Baseline ───────────────────────────────────────────────────────── + let edges = await readEdges(page); + results.baseline = { edgeCount: edges.length, label: edges[0]?.label, rtype: edges[0]?.rtype }; + await page.screenshot({ path: path.join(OUT, '1-baseline.png') }); + expect(edges.length, 'one edge rendered at baseline').toBe(1); + expect(edges[0].label, 'baseline label is the DEPENDS_ON label').toBe('Depends On'); + + // ── Interaction 1: change relationship type → label updates immediately ─ + await page.locator('.graph-container svg .edge-label-group').first().click({ force: true }); + await page.waitForTimeout(800); + await page.locator('button', { hasText: /^Blocks$/ }).first().click(); + await page.waitForTimeout(2500); // mutation + refetch + re-render (no reload) + await page.screenshot({ path: path.join(OUT, '2-after-type-change.png') }); + edges = await readEdges(page); + results.afterTypeChange = { edgeCount: edges.length, label: edges[0]?.label, rtype: edges[0]?.rtype }; + expect(edges.length, 'still exactly one edge after type change').toBe(1); + expect(edges[0].label, 'label updates to Blocks immediately, no reload').toBe('Blocks'); + + // ── Interaction 2: flip direction → exactly one edge remains ──────────── + const beforeFlip = edges[0]; + await page.locator('.graph-container svg .edge-label-group').first().click({ force: true }); + await page.waitForTimeout(800); + await page.getByRole('button', { name: /Flip Direction/ }).click(); + await page.waitForTimeout(3000); // delete + create + refetch + re-render + await page.screenshot({ path: path.join(OUT, '3-after-flip.png') }); + edges = await readEdges(page); + results.afterFlip = { + edgeCount: edges.length, + label: edges[0]?.label, + directionSwapped: edges[0] ? (edges[0].sId === beforeFlip.tId && edges[0].tId === beforeFlip.sId) : false, + }; + expect(edges.length, 'flip leaves EXACTLY ONE edge (no duplicate)').toBe(1); + expect(edges[0].label, 'flipped edge keeps its Blocks label').toBe('Blocks'); + + fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(results, null, 2)); + // eslint-disable-next-line no-console + console.log('[interaction-audit] ' + JSON.stringify(results)); + + // Cleanup (edges before work items — orphan edges break the edges query). + await gql(page, `mutation($id:ID!){deleteEdges(where:{source:{graph:{id:$id}}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: graphId }); + }); +});