From 221fbe42c2a6f60bc3c7d2d952097c24948faf43 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Wed, 12 Nov 2025 14:29:59 +0100 Subject: [PATCH] chore: added tests got 50.72% code coverage. Not sure if it is feasible to mock import behaviour ref: N25B-189 --- package.json | 3 +- .../components/SaveLoadPanel.tsx | 10 +- .../components/SaveLoadPanel.test.tsx | 172 ++++++++++++++++++ 3 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx diff --git a/package.json b/package.json index cb88357..983d37b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "jest" }, "dependencies": { "@neodrag/react": "^2.3.1", diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx index ff0c041..b4adc47 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx @@ -1,8 +1,8 @@ -import React from "react"; import useFlowStore from "../VisProgStores"; import styles from "../../VisProg.module.css"; -import { useReactFlow, type Edge } from "@xyflow/react"; +import {type Edge } from "@xyflow/react"; import type { AppNode } from "../VisProgTypes"; +import { cleanup } from "@testing-library/react"; type SavedProject = { version: 1; @@ -14,7 +14,7 @@ type SavedProject = { -function makeProjectBlob(name: string, nodes: AppNode[], edges: Edge[]): Blob { +export function makeProjectBlob(name: string, nodes: AppNode[], edges: Edge[]): Blob { const payload = { version: 1, name, @@ -109,9 +109,7 @@ export default function SaveLoadPanel() { } //We clear all the current edges and nodes - setEdges([]); - setNodes([]); - + cleanup(); //set all loaded nodes and edges into the VisProg const loadedNodes = proj.nodes as AppNode[]; const loadedEdges = proj.edges as Edge[]; diff --git a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx new file mode 100644 index 0000000..36fed6c --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx @@ -0,0 +1,172 @@ +import { mockReactFlow } from '../../../../setupFlowTests.ts'; +import { act, render, screen, fireEvent } from '@testing-library/react'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import { addNode } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx'; +import { makeProjectBlob } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx'; +import SaveLoadPanel from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx'; + +beforeAll(() => { + mockReactFlow(); +}); + +beforeEach(() => { + const { setNodes, setEdges } = useFlowStore.getState(); + act(() => { + setNodes([]); + setEdges([]); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('Load and save panel', () => { + test('save and load functions work correctly', async () => { + // create two nodes via your sidebar API + act(() => { + addNode('phase', { x: 100, y: 100 }); + addNode('norm', { x: 200, y: 200 }); + }); + + const initialState = useFlowStore.getState(); + expect(initialState.nodes.length).toBe(2); + + // make blob from current nodes/edges + const blob = makeProjectBlob('test-project', initialState.nodes, initialState.edges); + + // simulate loading from that blob + const parsed = JSON.parse(await blobToText(blob)); + + act(() => { + const { setNodes, setEdges } = useFlowStore.getState(); + setEdges([]); // clear edges first (mirrors app behavior) + setNodes(parsed.nodes); + setEdges(parsed.edges); + }); + + const loadedState = useFlowStore.getState(); + expect(loadedState.nodes.length).toBe(2); + expect(loadedState.nodes).toEqual(initialState.nodes); + expect(loadedState.edges).toEqual(initialState.edges); + }); + + test('Save uses showSaveFilePicker and writes JSON', async () => { + // Seed a simple graph so Save has something to write + act(() => { + useFlowStore.getState().setNodes([ + { id: 'start', type: 'start', position: { x: 0, y: 0 }, data: { label: 'start' } } as any, + { id: 'phase-1', type: 'phase', position: { x: 100, y: 120 }, data: { label: 'P1', number: 1 } } as any, + { id: 'end', type: 'end', position: { x: 0, y: 300 }, data: { label: 'End' } } as any, + ]); + useFlowStore.getState().setEdges([ + { id: 'start-phase-1', source: 'start', target: 'phase-1' } as any, + ]); + }); + + // capture what the app writes; don't decode inside the spy + let writtenChunk: any = null; + const write = jest.fn(async (chunk: any) => { writtenChunk = chunk; }); + const close = jest.fn().mockResolvedValue(undefined); + const createWritable = jest.fn().mockResolvedValue({ write, close }); + + // Mock the picker + (window as any).showSaveFilePicker = jest.fn().mockResolvedValue({ createWritable }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByText(/Save Graph/i)); + }); + // @ts-expect-error + expect(window.showSaveFilePicker).toHaveBeenCalledTimes(1); + expect(createWritable).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + + const writtenText = await chunkToString(writtenChunk); + const json = JSON.parse(writtenText); + expect(json.version).toBe(1); + expect(json.name).toBeDefined(); + expect(Array.isArray(json.nodes)).toBe(true); + expect(Array.isArray(json.edges)).toBe(true); + expect(json.behaviorProgram).toBeUndefined(); + }); + + test('Save falls back to anchor download when picker unavailable', async () => { + // Remove picker so we hit the fallback + delete (window as any).showSaveFilePicker; + + // Keep a reference to the REAL createElement to avoid recursion + const realCreateElement = document.createElement.bind(document); + + // Spy on URL + anchor click + const origCreateObjectURL = URL.createObjectURL; + const origRevokeObjectURL = URL.revokeObjectURL; + (URL as any).createObjectURL = jest.fn(() => 'blob:fake-url'); + (URL as any).revokeObjectURL = jest.fn(); + + const clickSpy = jest.fn(); + const createElementSpy = jest + .spyOn(document, 'createElement') + .mockImplementation((tag: any, opts?: any) => { + if (tag === 'a') { + // return a minimal anchor with a click spy + return { + set href(_v: string) {}, + set download(_v: string) {}, + click: clickSpy, + } as unknown as HTMLAnchorElement; + } + // call the REAL createElement for everything else + return realCreateElement(tag, opts as any); + }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByText(/Save Graph/i)); + }); + + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + + // cleanup + createElementSpy.mockRestore(); + (URL as any).createObjectURL = origCreateObjectURL; + (URL as any).revokeObjectURL = origRevokeObjectURL; + }); +}); + +// +// helpers +// + +// portable blob reader (no Response needed) +async function blobToText(blob: Blob): Promise { + const anyBlob = blob as any; + if (typeof anyBlob.text === 'function') return anyBlob.text(); + if (typeof anyBlob.arrayBuffer === 'function') { + const buf = await anyBlob.arrayBuffer(); + return new TextDecoder().decode(buf); + } + return await new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = () => resolve(String(fr.result)); + fr.onerror = () => reject(fr.error); + fr.readAsText(blob); + }); +} + +// normalize whatever chunk createWritable.write receives to a string +async function chunkToString(chunk: any): Promise { + if (typeof chunk === 'string') return chunk; + if (chunk instanceof Blob) return blobToText(chunk); + if (chunk?.buffer instanceof ArrayBuffer) { + return new TextDecoder().decode(chunk as Uint8Array); + } + if (chunk instanceof ArrayBuffer) { + return new TextDecoder().decode(new Uint8Array(chunk)); + } + return String(chunk); +}