feat: rewrite file-store with FileEntry model for multi-image support

This commit is contained in:
Siddharth Kumar Sah
2026-03-23 14:02:33 +08:00
parent 890ed21617
commit 8844b44fff
2 changed files with 549 additions and 266 deletions
+327 -219
View File
@@ -82,295 +82,403 @@ function failResponse(status: number) {
describe("FileStore", () => {
beforeEach(() => {
// Reset the store to initial state before every test.
// Zustand keeps state across calls, so we manually reset.
useFileStore.getState().reset();
vi.clearAllMocks();
// After reset, createObjectURL/revokeObjectURL calls are from reset itself;
// clear them so each test starts clean.
createObjectURL.mockClear();
revokeObjectURL.mockClear();
// Reset the mock to return incrementing URLs
let urlCounter = 0;
createObjectURL.mockImplementation(
(_obj: Blob | MediaSource) => `blob:url-${++urlCounter}`,
);
});
// -- Initial state -------------------------------------------------------
it("has correct initial state (everything null/empty/false)", () => {
it("has correct initial state", () => {
const s = useFileStore.getState();
expect(s.files).toEqual([]);
expect(s.jobId).toBeNull();
expect(s.processedUrl).toBeNull();
expect(s.originalBlobUrl).toBeNull();
expect(s.entries).toEqual([]);
expect(s.selectedIndex).toBe(0);
expect(s.batchZipBlob).toBeNull();
expect(s.batchZipFilename).toBeNull();
expect(s.processing).toBe(false);
expect(s.error).toBeNull();
expect(s.originalSize).toBeNull();
expect(s.processedSize).toBeNull();
expect(s.selectedFileName).toBeNull();
expect(s.selectedFileSize).toBeNull();
});
// -- setFiles -------------------------------------------------------------
it("setFiles stores files, creates blob URL, sets selectedFileName/Size, clears error", () => {
// Seed an error first so we can verify it gets cleared
useFileStore.getState().setError("old error");
expect(useFileStore.getState().error).toBe("old error");
const file = makeFile("photo.png", 2048);
useFileStore.getState().setFiles([file]);
const s = useFileStore.getState();
expect(s.files).toHaveLength(1);
expect(s.files[0]).toBe(file);
expect(createObjectURL).toHaveBeenCalledWith(file);
expect(s.originalBlobUrl).toBe("blob:fake-url");
expect(s.selectedFileName).toBe("photo.png");
expect(s.selectedFileSize).toBe(2048);
expect(s.error).toBeNull(); // error cleared
});
it("setFiles revokes the previous blob URL when new files are set", () => {
createObjectURL
.mockReturnValueOnce("blob:first-url")
.mockReturnValueOnce("blob:second-url");
useFileStore.getState().setFiles([makeFile("a.png")]);
expect(useFileStore.getState().originalBlobUrl).toBe("blob:first-url");
useFileStore.getState().setFiles([makeFile("b.png")]);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:first-url");
expect(useFileStore.getState().originalBlobUrl).toBe("blob:second-url");
});
it("setFiles with empty array does NOT create a blob URL", () => {
useFileStore.getState().setFiles([]);
const s = useFileStore.getState();
expect(createObjectURL).not.toHaveBeenCalled();
expect(s.originalBlobUrl).toBeNull();
expect(s.selectedFileName).toBeNull();
expect(s.selectedFileSize).toBeNull();
});
it("setFiles with empty array after prior files still revokes old URL", () => {
createObjectURL.mockReturnValueOnce("blob:old");
useFileStore.getState().setFiles([makeFile("old.png")]);
revokeObjectURL.mockClear();
useFileStore.getState().setFiles([]);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:old");
});
it("setFiles uses the FIRST file for blob URL when given multiple files", () => {
const f1 = makeFile("first.png", 100);
const f2 = makeFile("second.png", 200);
it("setFiles creates entries with blob URLs", () => {
const f1 = makeFile("a.png", 100);
const f2 = makeFile("b.png", 200);
useFileStore.getState().setFiles([f1, f2]);
// createObjectURL is called exactly once (only for the first file)
expect(createObjectURL).toHaveBeenCalledTimes(1);
// Verify the argument was f1 by identity (same reference)
expect(createObjectURL.mock.calls[0][0]).toBe(f1);
expect(useFileStore.getState().selectedFileName).toBe("first.png");
expect(useFileStore.getState().selectedFileSize).toBe(100);
});
// -- setJobId -------------------------------------------------------------
it("setJobId stores the job ID", () => {
useFileStore.getState().setJobId("job-abc");
expect(useFileStore.getState().jobId).toBe("job-abc");
});
// -- setProcessedUrl ------------------------------------------------------
it("setProcessedUrl stores a URL", () => {
useFileStore.getState().setProcessedUrl("blob:processed");
expect(useFileStore.getState().processedUrl).toBe("blob:processed");
});
it("setProcessedUrl can clear URL with null", () => {
useFileStore.getState().setProcessedUrl("blob:x");
useFileStore.getState().setProcessedUrl(null);
expect(useFileStore.getState().processedUrl).toBeNull();
});
// -- setProcessing --------------------------------------------------------
it("setProcessing sets the processing flag", () => {
useFileStore.getState().setProcessing(true);
expect(useFileStore.getState().processing).toBe(true);
useFileStore.getState().setProcessing(false);
expect(useFileStore.getState().processing).toBe(false);
});
// -- setError -------------------------------------------------------------
it("setError sets error AND forces processing to false", () => {
useFileStore.getState().setProcessing(true);
expect(useFileStore.getState().processing).toBe(true);
useFileStore.getState().setError("something broke");
const s = useFileStore.getState();
expect(s.error).toBe("something broke");
expect(s.processing).toBe(false); // critical side-effect
expect(s.entries).toHaveLength(2);
expect(s.entries[0].file).toBe(f1);
expect(s.entries[0].blobUrl).toBe("blob:url-1");
expect(s.entries[0].originalSize).toBe(100);
expect(s.entries[0].status).toBe("pending");
expect(s.entries[0].processedUrl).toBeNull();
expect(s.entries[0].processedSize).toBeNull();
expect(s.entries[0].error).toBeNull();
expect(s.entries[1].file).toBe(f2);
expect(s.entries[1].blobUrl).toBe("blob:url-2");
expect(createObjectURL).toHaveBeenCalledTimes(2);
});
it("setError(null) clears error but still forces processing to false", () => {
useFileStore.getState().setProcessing(true);
useFileStore.getState().setError(null);
it("setFiles revokes old blob URLs", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
const oldUrl = useFileStore.getState().entries[0].blobUrl;
revokeObjectURL.mockClear();
useFileStore.getState().setFiles([makeFile("b.png")]);
expect(revokeObjectURL).toHaveBeenCalledWith(oldUrl);
});
it("setFiles clears on empty array", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
revokeObjectURL.mockClear();
const oldUrl = useFileStore.getState().entries[0].blobUrl;
useFileStore.getState().setFiles([]);
expect(useFileStore.getState().entries).toEqual([]);
expect(revokeObjectURL).toHaveBeenCalledWith(oldUrl);
});
it("setFiles resets selectedIndex to 0", () => {
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
useFileStore.getState().setSelectedIndex(1);
expect(useFileStore.getState().selectedIndex).toBe(1);
useFileStore.getState().setFiles([makeFile("c.png")]);
expect(useFileStore.getState().selectedIndex).toBe(0);
});
it("setFiles clears error", () => {
useFileStore.getState().setError("old error");
useFileStore.getState().setFiles([makeFile("a.png")]);
expect(useFileStore.getState().error).toBeNull();
expect(useFileStore.getState().processing).toBe(false);
});
// -- setSizes -------------------------------------------------------------
// -- addFiles -------------------------------------------------------------
it("addFiles appends new entries without revoking existing", () => {
useFileStore.getState().setFiles([makeFile("a.png", 100)]);
revokeObjectURL.mockClear();
createObjectURL.mockClear();
const f2 = makeFile("b.png", 200);
useFileStore.getState().addFiles([f2]);
expect(revokeObjectURL).not.toHaveBeenCalled();
expect(useFileStore.getState().entries).toHaveLength(2);
expect(useFileStore.getState().entries[1].file).toBe(f2);
expect(createObjectURL).toHaveBeenCalledTimes(1);
});
// -- removeFile -----------------------------------------------------------
it("removeFile removes entry and revokes its blob URLs", () => {
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
const removedUrl = useFileStore.getState().entries[0].blobUrl;
revokeObjectURL.mockClear();
useFileStore.getState().removeFile(0);
expect(useFileStore.getState().entries).toHaveLength(1);
expect(useFileStore.getState().entries[0].file.name).toBe("b.png");
expect(revokeObjectURL).toHaveBeenCalledWith(removedUrl);
});
it("removeFile adjusts selectedIndex when removing before it", () => {
useFileStore.getState().setFiles([
makeFile("a.png"),
makeFile("b.png"),
makeFile("c.png"),
]);
useFileStore.getState().setSelectedIndex(2);
useFileStore.getState().removeFile(0);
expect(useFileStore.getState().selectedIndex).toBe(1);
});
it("removeFile clamps selectedIndex if it was the last entry", () => {
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
useFileStore.getState().setSelectedIndex(1);
useFileStore.getState().removeFile(1);
expect(useFileStore.getState().selectedIndex).toBe(0);
});
it("removeFile revokes processedUrl if present", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
useFileStore.getState().updateEntry(0, {
processedUrl: "blob:processed",
status: "completed",
});
revokeObjectURL.mockClear();
useFileStore.getState().removeFile(0);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:processed");
});
// -- Navigation -----------------------------------------------------------
it("navigateNext advances selectedIndex", () => {
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
expect(useFileStore.getState().selectedIndex).toBe(0);
useFileStore.getState().navigateNext();
expect(useFileStore.getState().selectedIndex).toBe(1);
});
it("navigateNext does not exceed bounds", () => {
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
useFileStore.getState().setSelectedIndex(1);
useFileStore.getState().navigateNext();
expect(useFileStore.getState().selectedIndex).toBe(1);
});
it("navigatePrev decrements selectedIndex", () => {
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
useFileStore.getState().setSelectedIndex(1);
useFileStore.getState().navigatePrev();
expect(useFileStore.getState().selectedIndex).toBe(0);
});
it("navigatePrev does not go below 0", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
useFileStore.getState().navigatePrev();
expect(useFileStore.getState().selectedIndex).toBe(0);
});
// -- updateEntry ----------------------------------------------------------
it("updateEntry merges partial data into the entry at index", () => {
useFileStore.getState().setFiles([makeFile("a.png", 500)]);
useFileStore.getState().updateEntry(0, {
status: "completed",
processedUrl: "blob:done",
processedSize: 250,
});
const entry = useFileStore.getState().entries[0];
expect(entry.status).toBe("completed");
expect(entry.processedUrl).toBe("blob:done");
expect(entry.processedSize).toBe(250);
expect(entry.file.name).toBe("a.png"); // unchanged
});
// -- setBatchZip ----------------------------------------------------------
it("setBatchZip stores blob and filename", () => {
const blob = new Blob(["zip-data"]);
useFileStore.getState().setBatchZip(blob, "results.zip");
it("setSizes sets both originalSize and processedSize", () => {
useFileStore.getState().setSizes(5000, 2500);
const s = useFileStore.getState();
expect(s.originalSize).toBe(5000);
expect(s.processedSize).toBe(2500);
});
it("setSizes with zero values stores zeros (not null)", () => {
useFileStore.getState().setSizes(0, 0);
expect(useFileStore.getState().originalSize).toBe(0);
expect(useFileStore.getState().processedSize).toBe(0);
expect(s.batchZipBlob).toBe(blob);
expect(s.batchZipFilename).toBe("results.zip");
});
// -- undoProcessing -------------------------------------------------------
it("undoProcessing clears processedUrl, jobId, processedSize, error but KEEPS files and originalBlobUrl", () => {
createObjectURL.mockReturnValueOnce("blob:orig");
// Set up full state
const file = makeFile("keep-me.png", 3000);
useFileStore.getState().setFiles([file]);
useFileStore.getState().setJobId("job-1");
useFileStore.getState().setProcessedUrl("blob:result");
useFileStore.getState().setSizes(3000, 1500);
useFileStore.getState().setError("transient error");
it("undoProcessing resets all entries to pending and revokes processed blob URLs", () => {
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
useFileStore.getState().updateEntry(0, {
status: "completed",
processedUrl: "blob:proc-a",
processedSize: 50,
});
useFileStore.getState().updateEntry(1, {
status: "completed",
processedUrl: "blob:proc-b",
processedSize: 75,
});
revokeObjectURL.mockClear();
useFileStore.getState().undoProcessing();
const s = useFileStore.getState();
// Cleared
expect(s.processedUrl).toBeNull();
expect(s.jobId).toBeNull();
expect(s.processedSize).toBeNull();
expect(s.error).toBeNull();
// Preserved
expect(s.files).toHaveLength(1);
expect(s.files[0]).toBe(file);
expect(s.originalBlobUrl).toBe("blob:orig");
expect(s.selectedFileName).toBe("keep-me.png");
expect(s.selectedFileSize).toBe(3000);
// originalSize is NOT cleared by undoProcessing (only processedSize is)
expect(s.originalSize).toBe(3000);
// All entries reset to pending
expect(s.entries[0].status).toBe("pending");
expect(s.entries[0].processedUrl).toBeNull();
expect(s.entries[0].processedSize).toBeNull();
expect(s.entries[0].error).toBeNull();
expect(s.entries[1].status).toBe("pending");
expect(s.entries[1].processedUrl).toBeNull();
// Processed URLs revoked
expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc-a");
expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc-b");
});
it("undoProcessing does NOT revoke the originalBlobUrl", () => {
createObjectURL.mockReturnValueOnce("blob:keep-alive");
useFileStore.getState().setFiles([makeFile("x.png")]);
it("undoProcessing keeps original blob URLs", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
const origUrl = useFileStore.getState().entries[0].blobUrl;
revokeObjectURL.mockClear();
useFileStore.getState().undoProcessing();
expect(revokeObjectURL).not.toHaveBeenCalled();
// Should NOT revoke original blob URL
expect(revokeObjectURL).not.toHaveBeenCalledWith(origUrl);
expect(useFileStore.getState().entries[0].blobUrl).toBe(origUrl);
});
// -- reset ----------------------------------------------------------------
it("reset clears everything and revokes the blob URL", () => {
createObjectURL.mockReturnValueOnce("blob:to-revoke");
useFileStore.getState().setFiles([makeFile("doomed.png")]);
useFileStore.getState().setJobId("job-x");
useFileStore.getState().setProcessedUrl("blob:proc");
useFileStore.getState().setProcessing(true);
useFileStore.getState().setError("oops");
useFileStore.getState().setSizes(100, 50);
it("reset clears everything and revokes all blob URLs", () => {
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
useFileStore.getState().updateEntry(0, { processedUrl: "blob:proc" });
const origUrl0 = useFileStore.getState().entries[0].blobUrl;
const origUrl1 = useFileStore.getState().entries[1].blobUrl;
revokeObjectURL.mockClear();
useFileStore.getState().reset();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:to-revoke");
expect(revokeObjectURL).toHaveBeenCalledWith(origUrl0);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc");
expect(revokeObjectURL).toHaveBeenCalledWith(origUrl1);
const s = useFileStore.getState();
expect(s.files).toEqual([]);
expect(s.jobId).toBeNull();
expect(s.processedUrl).toBeNull();
expect(s.originalBlobUrl).toBeNull();
expect(s.entries).toEqual([]);
expect(s.selectedIndex).toBe(0);
expect(s.batchZipBlob).toBeNull();
expect(s.batchZipFilename).toBeNull();
expect(s.processing).toBe(false);
expect(s.error).toBeNull();
expect(s.originalSize).toBeNull();
expect(s.processedSize).toBeNull();
expect(s.selectedFileName).toBeNull();
expect(s.selectedFileSize).toBeNull();
});
it("reset when originalBlobUrl is already null does NOT call revokeObjectURL", () => {
// Start from a clean state (no files set)
it("reset with no entries does not call revokeObjectURL", () => {
revokeObjectURL.mockClear();
useFileStore.getState().reset();
expect(revokeObjectURL).not.toHaveBeenCalled();
});
// -- State transition sequences -------------------------------------------
// -- Backward compat getters ----------------------------------------------
it("setFiles -> setProcessing(true) -> setError -> processing is false", () => {
useFileStore.getState().setFiles([makeFile("t.png")]);
useFileStore.getState().setProcessing(true);
expect(useFileStore.getState().processing).toBe(true);
it("files getter maps entries to File[]", () => {
const f1 = makeFile("a.png");
const f2 = makeFile("b.png");
useFileStore.getState().setFiles([f1, f2]);
useFileStore.getState().setError("fail");
expect(useFileStore.getState().processing).toBe(false);
expect(useFileStore.getState().error).toBe("fail");
const s = useFileStore.getState();
expect(s.files).toEqual([f1, f2]);
expect(s.files[0]).toBe(f1);
});
it("setFiles -> setProcessing(true) -> setProcessedUrl -> setProcessing(false) (happy path)", () => {
useFileStore.getState().setFiles([makeFile("t.png")]);
useFileStore.getState().setProcessing(true);
expect(useFileStore.getState().processing).toBe(true);
it("currentEntry returns entry at selectedIndex", () => {
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
useFileStore.getState().setSelectedIndex(1);
useFileStore.getState().setProcessedUrl("blob:done");
// processedUrl does NOT auto-clear processing
expect(useFileStore.getState().processing).toBe(true);
expect(useFileStore.getState().currentEntry?.file.name).toBe("b.png");
});
useFileStore.getState().setProcessing(false);
expect(useFileStore.getState().processing).toBe(false);
it("currentEntry returns undefined when no entries", () => {
expect(useFileStore.getState().currentEntry).toBeUndefined();
});
it("selectedFileName returns current entry file name", () => {
useFileStore.getState().setFiles([makeFile("photo.png")]);
expect(useFileStore.getState().selectedFileName).toBe("photo.png");
});
it("selectedFileName returns null when no entries", () => {
expect(useFileStore.getState().selectedFileName).toBeNull();
});
it("selectedFileSize returns current entry file size", () => {
useFileStore.getState().setFiles([makeFile("photo.png", 2048)]);
expect(useFileStore.getState().selectedFileSize).toBe(2048);
});
it("selectedFileSize returns null when no entries", () => {
expect(useFileStore.getState().selectedFileSize).toBeNull();
});
it("originalBlobUrl returns current entry blobUrl", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
expect(useFileStore.getState().originalBlobUrl).toBe(
useFileStore.getState().entries[0].blobUrl,
);
});
it("originalBlobUrl returns null when no entries", () => {
expect(useFileStore.getState().originalBlobUrl).toBeNull();
});
it("processedUrl returns current entry processedUrl", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
useFileStore.getState().updateEntry(0, { processedUrl: "blob:done" });
expect(useFileStore.getState().processedUrl).toBe("blob:done");
});
it("rapid setFiles calls only keep the latest state and revoke each prior URL", () => {
createObjectURL
.mockReturnValueOnce("blob:1")
.mockReturnValueOnce("blob:2")
.mockReturnValueOnce("blob:3");
useFileStore.getState().setFiles([makeFile("a.png")]);
useFileStore.getState().setFiles([makeFile("b.png")]);
useFileStore.getState().setFiles([makeFile("c.png")]);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:1");
expect(revokeObjectURL).toHaveBeenCalledWith("blob:2");
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
expect(useFileStore.getState().originalBlobUrl).toBe("blob:3");
expect(useFileStore.getState().selectedFileName).toBe("c.png");
it("originalSize returns current entry originalSize", () => {
useFileStore.getState().setFiles([makeFile("a.png", 999)]);
expect(useFileStore.getState().originalSize).toBe(999);
});
it("setError during processing, then undoProcessing, then retry cycle works", () => {
useFileStore.getState().setFiles([makeFile("retry.png")]);
useFileStore.getState().setProcessing(true);
useFileStore.getState().setError("timeout");
expect(useFileStore.getState().processing).toBe(false);
it("processedSize returns current entry processedSize", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
useFileStore.getState().updateEntry(0, { processedSize: 500 });
expect(useFileStore.getState().processedSize).toBe(500);
});
useFileStore.getState().undoProcessing();
expect(useFileStore.getState().error).toBeNull();
expect(useFileStore.getState().files).toHaveLength(1);
it("hasFiles returns true when entries exist", () => {
expect(useFileStore.getState().hasFiles).toBe(false);
useFileStore.getState().setFiles([makeFile("a.png")]);
expect(useFileStore.getState().hasFiles).toBe(true);
});
// Retry
useFileStore.getState().setProcessing(true);
expect(useFileStore.getState().processing).toBe(true);
useFileStore.getState().setProcessedUrl("blob:retry-ok");
useFileStore.getState().setProcessing(false);
expect(useFileStore.getState().processedUrl).toBe("blob:retry-ok");
it("allProcessed returns true when all entries are completed", () => {
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
expect(useFileStore.getState().allProcessed).toBe(false);
useFileStore.getState().updateEntry(0, { status: "completed" });
expect(useFileStore.getState().allProcessed).toBe(false);
useFileStore.getState().updateEntry(1, { status: "completed" });
expect(useFileStore.getState().allProcessed).toBe(true);
});
it("allProcessed returns false when no entries", () => {
expect(useFileStore.getState().allProcessed).toBe(false);
});
// -- setProcessedUrl (backward compat, updates current entry) -------------
it("setProcessedUrl updates current entry processedUrl and status", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
useFileStore.getState().setProcessedUrl("blob:result");
const entry = useFileStore.getState().entries[0];
expect(entry.processedUrl).toBe("blob:result");
expect(entry.status).toBe("completed");
});
it("setProcessedUrl with null resets current entry", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
useFileStore.getState().setProcessedUrl("blob:result");
useFileStore.getState().setProcessedUrl(null);
const entry = useFileStore.getState().entries[0];
expect(entry.processedUrl).toBeNull();
expect(entry.status).toBe("pending");
});
// -- setSizes (backward compat, updates current entry) --------------------
it("setSizes updates current entry sizes", () => {
useFileStore.getState().setFiles([makeFile("a.png", 1000)]);
useFileStore.getState().setSizes(1000, 500);
const entry = useFileStore.getState().entries[0];
expect(entry.originalSize).toBe(1000);
expect(entry.processedSize).toBe(500);
});
// -- setJobId (no-op for compat) ------------------------------------------
it("setJobId is a no-op (does not throw)", () => {
expect(() => useFileStore.getState().setJobId("job-abc")).not.toThrow();
});
});