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); }