|
1 | 1 | import type { GraphSpec } from '@opendata-ai/openchart-core'; |
2 | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
3 | 3 | import { createGraph } from '../../graph-mount'; |
| 4 | +import { SimulationManager } from '../simulation'; |
| 5 | +import { SpatialIndex } from '../spatial-index'; |
4 | 6 |
|
5 | 7 | // --------------------------------------------------------------------------- |
6 | 8 | // Test data |
@@ -398,3 +400,228 @@ describe('createGraph entrance', () => { |
398 | 400 | graph.destroy(); |
399 | 401 | }); |
400 | 402 | }); |
| 403 | + |
| 404 | +// --------------------------------------------------------------------------- |
| 405 | +// Phase 8 — physics-feel gates (springy drag + cursor repulsion) |
| 406 | +// |
| 407 | +// happy-dom has no Worker, so the mount drives the SYNC SimulationManager. We |
| 408 | +// spy on its setPointer/pinNode/unpinNode to observe exactly what the mount |
| 409 | +// emits under each gate, and dispatch canvas mouse events to trigger the flow. |
| 410 | +// --------------------------------------------------------------------------- |
| 411 | + |
| 412 | +describe('createGraph physics-feel gates', () => { |
| 413 | + let setPointerSpy: ReturnType<typeof vi.spyOn>; |
| 414 | + let pinSpy: ReturnType<typeof vi.spyOn>; |
| 415 | + let unpinSpy: ReturnType<typeof vi.spyOn>; |
| 416 | + let nowValue: number; |
| 417 | + let restoreGetContext: (() => void) | null = null; |
| 418 | + |
| 419 | + function stubCanvas2D(): void { |
| 420 | + const noop = () => {}; |
| 421 | + const ctx = new Proxy({} as Record<string, unknown>, { |
| 422 | + get: (_t, prop) => |
| 423 | + prop === 'measureText' ? () => ({ width: 0 }) : prop === 'setLineDash' ? noop : noop, |
| 424 | + set: () => true, |
| 425 | + }); |
| 426 | + const proto = HTMLCanvasElement.prototype as unknown as { getContext: (id: string) => unknown }; |
| 427 | + const original = proto.getContext; |
| 428 | + proto.getContext = () => ctx; |
| 429 | + restoreGetContext = () => { |
| 430 | + proto.getContext = original; |
| 431 | + }; |
| 432 | + } |
| 433 | + |
| 434 | + function stubMatchMedia(reduced: boolean): void { |
| 435 | + const impl = (query: string) => ({ |
| 436 | + matches: query.includes('reduce') ? reduced : false, |
| 437 | + media: query, |
| 438 | + addEventListener() {}, |
| 439 | + removeEventListener() {}, |
| 440 | + addListener() {}, |
| 441 | + removeListener() {}, |
| 442 | + dispatchEvent() { |
| 443 | + return false; |
| 444 | + }, |
| 445 | + }); |
| 446 | + vi.stubGlobal('matchMedia', impl); |
| 447 | + window.matchMedia = globalThis.matchMedia; |
| 448 | + } |
| 449 | + |
| 450 | + /** A canvas mousemove at screen (cx, cy). */ |
| 451 | + function moveMouse(canvas: Element, cx: number, cy: number): void { |
| 452 | + canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: cx, clientY: cy, bubbles: true })); |
| 453 | + } |
| 454 | + |
| 455 | + /** A specific number of nodes in a loose chain (keeps compile cheap). */ |
| 456 | + function bigSpec(n: number, interaction: GraphSpec['interaction']): GraphSpec { |
| 457 | + const nodes = Array.from({ length: n }, (_, i) => ({ id: `n${i}`, label: `N${i}` })); |
| 458 | + const edges = Array.from({ length: n - 1 }, (_, i) => ({ |
| 459 | + source: `n${i}`, |
| 460 | + target: `n${i + 1}`, |
| 461 | + })); |
| 462 | + // warmupTicks 0 keeps these large-graph tests fast; positions don't matter |
| 463 | + // for the gate assertions (we spy on the emitted physics calls). |
| 464 | + return { type: 'graph', nodes, edges, interaction, layout: { warmup: false } }; |
| 465 | + } |
| 466 | + |
| 467 | + beforeEach(() => { |
| 468 | + nowValue = 0; |
| 469 | + stubCanvas2D(); |
| 470 | + stubMatchMedia(false); |
| 471 | + vi.stubGlobal('performance', { now: () => nowValue }); |
| 472 | + setPointerSpy = vi.spyOn(SimulationManager.prototype, 'setPointer'); |
| 473 | + pinSpy = vi.spyOn(SimulationManager.prototype, 'pinNode'); |
| 474 | + unpinSpy = vi.spyOn(SimulationManager.prototype, 'unpinNode'); |
| 475 | + }); |
| 476 | + |
| 477 | + afterEach(() => { |
| 478 | + restoreGetContext?.(); |
| 479 | + restoreGetContext = null; |
| 480 | + vi.restoreAllMocks(); |
| 481 | + vi.unstubAllGlobals(); |
| 482 | + }); |
| 483 | + |
| 484 | + it('default graph: no pointer feed emitted on mousemove', async () => { |
| 485 | + container = makeContainer(); |
| 486 | + const graph = createGraph(container, bigSpec(4, undefined)); |
| 487 | + await Promise.resolve(); |
| 488 | + const canvas = container.querySelector('.oc-graph-canvas')!; |
| 489 | + |
| 490 | + nowValue = 100; |
| 491 | + moveMouse(canvas, 400, 300); |
| 492 | + moveMouse(canvas, 410, 310); |
| 493 | + |
| 494 | + // Cursor repulsion off by default → the mount never feeds pointer positions. |
| 495 | + expect(setPointerSpy).not.toHaveBeenCalled(); |
| 496 | + graph.destroy(); |
| 497 | + }); |
| 498 | + |
| 499 | + it('cursorRepulsion on + small graph: mousemove feeds the pointer (throttled)', async () => { |
| 500 | + container = makeContainer(); |
| 501 | + const graph = createGraph(container, bigSpec(4, { cursorRepulsion: true })); |
| 502 | + await Promise.resolve(); |
| 503 | + const canvas = container.querySelector('.oc-graph-canvas')!; |
| 504 | + |
| 505 | + // First move at t=100 posts; a move within the ~33ms window is throttled. |
| 506 | + nowValue = 100; |
| 507 | + moveMouse(canvas, 400, 300); |
| 508 | + nowValue = 110; |
| 509 | + moveMouse(canvas, 405, 305); |
| 510 | + // A move past the throttle window posts again. |
| 511 | + nowValue = 200; |
| 512 | + moveMouse(canvas, 420, 320); |
| 513 | + |
| 514 | + const activeCalls = setPointerSpy.mock.calls.filter((c) => c[2] === true); |
| 515 | + expect(activeCalls.length).toBe(2); |
| 516 | + graph.destroy(); |
| 517 | + }); |
| 518 | + |
| 519 | + it('cursorRepulsion on but reduced motion: no pointer feed', async () => { |
| 520 | + stubMatchMedia(true); |
| 521 | + container = makeContainer(); |
| 522 | + const graph = createGraph(container, bigSpec(4, { cursorRepulsion: true })); |
| 523 | + await Promise.resolve(); |
| 524 | + const canvas = container.querySelector('.oc-graph-canvas')!; |
| 525 | + |
| 526 | + nowValue = 100; |
| 527 | + moveMouse(canvas, 400, 300); |
| 528 | + nowValue = 200; |
| 529 | + moveMouse(canvas, 420, 320); |
| 530 | + |
| 531 | + // Ambient pointer-driven motion is exactly what reduced-motion suppresses. |
| 532 | + expect(setPointerSpy).not.toHaveBeenCalled(); |
| 533 | + graph.destroy(); |
| 534 | + }); |
| 535 | + |
| 536 | + it('cursorRepulsion on but graph > 2000 nodes: no pointer feed', async () => { |
| 537 | + container = makeContainer(); |
| 538 | + const graph = createGraph(container, bigSpec(2001, { cursorRepulsion: true })); |
| 539 | + await Promise.resolve(); |
| 540 | + const canvas = container.querySelector('.oc-graph-canvas')!; |
| 541 | + |
| 542 | + nowValue = 100; |
| 543 | + moveMouse(canvas, 400, 300); |
| 544 | + nowValue = 200; |
| 545 | + moveMouse(canvas, 420, 320); |
| 546 | + |
| 547 | + expect(setPointerSpy).not.toHaveBeenCalled(); |
| 548 | + graph.destroy(); |
| 549 | + }); |
| 550 | + |
| 551 | + it('mouseleave deactivates the pointer feed when cursor repulsion is on', async () => { |
| 552 | + container = makeContainer(); |
| 553 | + const graph = createGraph(container, bigSpec(4, { cursorRepulsion: true })); |
| 554 | + await Promise.resolve(); |
| 555 | + const canvas = container.querySelector('.oc-graph-canvas')!; |
| 556 | + |
| 557 | + nowValue = 100; |
| 558 | + moveMouse(canvas, 400, 300); |
| 559 | + canvas.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); |
| 560 | + |
| 561 | + const deactivate = setPointerSpy.mock.calls.filter((c) => c[2] === false); |
| 562 | + expect(deactivate.length).toBe(1); |
| 563 | + graph.destroy(); |
| 564 | + }); |
| 565 | + |
| 566 | + /** |
| 567 | + * Drive one full node drag (down → move → up). Hit-testing is made |
| 568 | + * deterministic by forcing SpatialIndex.findNearest to return a stub node, so |
| 569 | + * the drag reliably commits regardless of happy-dom's zero-size canvas rect. |
| 570 | + */ |
| 571 | + function dragSomeNode(canvas: Element): void { |
| 572 | + const hit = vi |
| 573 | + .spyOn(SpatialIndex.prototype, 'findNearest') |
| 574 | + .mockReturnValue({ id: 'n0', x: 0, y: 0, index: 0, radius: 5 } as never); |
| 575 | + canvas.dispatchEvent( |
| 576 | + new MouseEvent('mousedown', { clientX: 400, clientY: 300, bubbles: true }), |
| 577 | + ); |
| 578 | + // First move commits the drag (onNodeDragStart → pinNode). |
| 579 | + canvas.dispatchEvent( |
| 580 | + new MouseEvent('mousemove', { clientX: 410, clientY: 310, bubbles: true }), |
| 581 | + ); |
| 582 | + canvas.dispatchEvent(new MouseEvent('mouseup', { clientX: 410, clientY: 310, bubbles: true })); |
| 583 | + hit.mockRestore(); |
| 584 | + } |
| 585 | + |
| 586 | + it('springyDrag on + small graph: drag pins with alphaTarget 0.3, releases with 0', async () => { |
| 587 | + container = makeContainer(); |
| 588 | + const graph = createGraph(container, bigSpec(6, { springyDrag: true })); |
| 589 | + await Promise.resolve(); |
| 590 | + const canvas = container.querySelector('.oc-graph-canvas')!; |
| 591 | + |
| 592 | + dragSomeNode(canvas); |
| 593 | + |
| 594 | + // Springy: pin carries alphaTarget 0.3, unpin carries 0. |
| 595 | + expect(pinSpy.mock.calls[0][3]).toBe(0.3); |
| 596 | + expect(unpinSpy.mock.calls[0][1]).toBe(0); |
| 597 | + graph.destroy(); |
| 598 | + }); |
| 599 | + |
| 600 | + it('springyDrag off (default): drag pins with NO alphaTarget (legacy)', async () => { |
| 601 | + container = makeContainer(); |
| 602 | + const graph = createGraph(container, bigSpec(6, undefined)); |
| 603 | + await Promise.resolve(); |
| 604 | + const canvas = container.querySelector('.oc-graph-canvas')!; |
| 605 | + |
| 606 | + dragSomeNode(canvas); |
| 607 | + |
| 608 | + // Legacy: the springy arg is absent (undefined) on both pin and unpin. |
| 609 | + expect(pinSpy.mock.calls[0][3]).toBeUndefined(); |
| 610 | + expect(unpinSpy.mock.calls[0][1]).toBeUndefined(); |
| 611 | + graph.destroy(); |
| 612 | + }); |
| 613 | + |
| 614 | + it('springyDrag on but graph > 5000 nodes: drag stays legacy (no alphaTarget)', async () => { |
| 615 | + container = makeContainer(); |
| 616 | + const graph = createGraph(container, bigSpec(5001, { springyDrag: true })); |
| 617 | + await Promise.resolve(); |
| 618 | + const canvas = container.querySelector('.oc-graph-canvas')!; |
| 619 | + |
| 620 | + dragSomeNode(canvas); |
| 621 | + |
| 622 | + // Above the gate, springy is off → legacy pin/unpin (no alphaTarget field). |
| 623 | + expect(pinSpy.mock.calls[0][3]).toBeUndefined(); |
| 624 | + expect(unpinSpy.mock.calls[0][1]).toBeUndefined(); |
| 625 | + graph.destroy(); |
| 626 | + }); |
| 627 | +}); |
0 commit comments