This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7503-d2ef447ff7eb3a77bdcd66952926a5f2ad43c53f in repository https://gitbox.apache.org/repos/asf/texera.git
commit 42d08a3701cd06542bcfa92728116302723b2536 Author: Meng Wang <[email protected]> AuthorDate: Sun Aug 9 22:28:45 2026 -0700 test(frontend): cover the remaining branches in three dashboard components (#7503) ### What changes were proposed in this PR? Covers the branches the three specs never reached. 22 new tests; every function in all three files is now executed. | file | statements | branches | functions | | --- | --- | --- | --- | | `user-computing-unit.component.ts` | 37/37 | 12/12 | 11/11 | | `user-dataset-file-renderer.component.ts` | 142/142 | 65/68 | 25/25 | | `files-uploader.component.ts` | 139/140 | 78/82 | 37/37 | **UserComputingUnitComponent** — the session subscription updating `isLogin`/`currentUid`, the mapping of fetched units into dashboard entries, the 1s poller (it refreshes on each tick and stops once the component is destroyed), and both arms of `terminateComputingUnit`. The poll test calls `ngOnInit()` directly rather than `detectChanges()`: the fixture's NgZone is created outside the `fakeAsync` zone, so a poll scheduled through it lands on the real timer queue where `tick()` cannot drive it. `discardPeriodicTasks()` drops the unbounded interval, and `fixture.destroy()` in `afterEach` keeps it from ticking into a later test. **UserDatasetFileRendererComponent** — the spreadsheet branch (previously unhit in full), the CSV empty-result and read-failure arms, and the guard that stops a fetch when the dataset ids are missing. The spreadsheet tests build a real `.xlsx` with JSZip (already a dependency, and already used this way in `user-workflow.component.spec.ts`) and run the real `read-excel-file` rather than mocking the module; one workbook leaves a gap in a row so both arms of the cell-to-string mapping run. jsdom's `Blob` has no `arrayBuffer()`, which is how `read-excel-file` reads its input, so the helper attaches the buffer it just built to that one blob instead of patching `Blob.prototype`. For CSV, `FileReader` is replaced with a fake that settles on a microtask, which is what makes the empty-result and error arms deterministic — `Papa.parse` cannot be spied here (the existing spec documents why). **FilesUploaderComponent** — the drop paths that never yield an uploadable file (oversized file, dropped directory, unreadable entry, including the singular/plural failure banner), the lookup paths (no dataset context, failed lookup, null result, and an unexpected failure of the whole drop with and without an error message), the fallback used when a conflicting path ends in a separator, the settings-request failure the constructor swallows, and the teardown that stops a late setting reaching a destroyed component. No production code was changed. ### Coverage that is not reachable Three branches and one statement stay uncovered because no test can reach them: - `user-dataset-file-renderer.component.ts:203` and `:223` — the `?? this.DEFAULT_MAX_SIZE` / `|| this.DEFAULT_MAX_SIZE` fallbacks. Both are reached only after `isPreviewSupported` has confirmed via `hasOwnProperty` that the key exists in `MIME_TYPE_SIZE_LIMITS_MB`, and every value in that map is a positive number, so neither fallback can be taken. - `user-dataset-file-renderer.component.ts:372` — `if (cell != "")` inside `for (const cell in row)`. `for...in` yields index strings ("0", "1", …), which are never `""`, so the condition is always true and the "filter out all empty row" step filters nothing. That is a defect rather than a coverage gap. - `files-uploader.component.ts:58` — a statement whose source range runs backwards (`58:35 -> 50:None`) into the `@Component` decorator: the compiler-emitted `ngDevMode` guard on the class declaration, not application code. The one genuinely reachable gap attributed near it — the constructor's `error: () => {}` arm — is now covered, taking function coverage to 37/37. - `user-dataset-file-renderer.component.ts` sets `isLoading = true` immediately above the `did && dvid && filePath` guard and never clears it when that guard fails, so a renderer given a `filePath` before its dataset ids shows a spinner that never stops. The id-guard test pins this with a comment saying it characterizes a defect and what to flip once it is fixed. ### Defects worth recording `getMimeType` uppercases a file's extension and looks it up as a **key** of `MIME_TYPES`. The Excel key is `MSEXCEL`, so only a file named `*.msexcel` resolves to `application/vnd.ms-excel`; a real `.xlsx` or `.xls` falls through to `OCTET_STREAM` and is rejected as "preview unsupported", which makes the whole spreadsheet branch dead for real spreadsheets. `.jpg` has the same problem (the key is `JPEG`). The new tests use the suffix the code actually accepts, with a comment saying so, rather than asserting an intent the code does not implement. ### Any related issues, documentation, discussions? Closes #7497. ### How was this PR tested? `ng test --watch=false` over the three specs — 86 passed (64 existing + 22 new), run 3x for determinism. Coverage (`--coverage`) gives the table above. The failure path was verified by breaking one assertion in each of the three specs (red, non-zero exit) and restoring them; eslint and prettier are clean. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8 [1M context]) --- .../files-uploader.component.spec.ts | 193 ++++++++++++++++++++- .../user-computing-unit.component.spec.ts | 109 +++++++++++- .../user-dataset-file-renderer.component.spec.ts | 180 +++++++++++++++++++ 3 files changed, 480 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts index 1882de68f8..e23668e997 100644 --- a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts @@ -17,7 +17,8 @@ * under the License. */ -import { of } from "rxjs"; +import { of, Subject, throwError } from "rxjs"; +import { OnDestroy } from "@angular/core"; import { NgxFileDropEntry } from "ngx-file-drop"; import { NzModalService } from "ng-zorro-antd/modal"; import { AdminSettingsService } from "../../../service/admin/settings/admin-settings.service"; @@ -106,6 +107,20 @@ describe("FilesUploaderComponent", () => { expect(build("0").singleFileUploadMaxSizeMiB).toBe(0); }); + it("keeps the default upload size limit when the setting request fails", () => { + // The component swallows the error on purpose so a settings outage cannot stop uploads. + const uploader = new FilesUploaderComponent( + { error: vi.fn() } as unknown as NotificationService, + { + getPublicSetting: vi.fn().mockReturnValue(throwError(() => new Error("settings unavailable"))), + } as unknown as AdminSettingsService, + datasetService as unknown as DatasetService, + { create: vi.fn() } as unknown as NzModalService + ); + + expect(uploader.singleFileUploadMaxSizeMiB).toBe(20); + }); + it("asks to resume failed multipart files and skip completed matching files in one retry batch", async () => { const emitted = new Promise<FileUploadItem[]>(resolve => component.uploadedFiles.subscribe(resolve)); @@ -310,4 +325,180 @@ describe("FilesUploaderComponent", () => { expect(component.fileUploadBannerType).toBe("warning"); expect(component.fileUploadBannerMessage).toBe("heads up"); }); + + /** + * Everything above drops well-formed files into a fully configured uploader. These cover what + * happens when the drop itself, or the lookups the drop depends on, do not go to plan — the + * paths that decide whether a file even reaches the upload queue. + */ + describe("drops that do not yield an uploadable file", () => { + /** Resolves with whatever the component finally emits for a drop. */ + const emissionOf = (): Promise<FileUploadItem[]> => + new Promise<FileUploadItem[]>(resolve => component.uploadedFiles.subscribe(resolve)); + + it("rejects a single oversized file and reports it in the banner", async () => { + const notify = { error: vi.fn() }; + component = new FilesUploaderComponent( + notify as unknown as NotificationService, + { getPublicSetting: vi.fn().mockReturnValue(of("0")) } as unknown as AdminSettingsService, + datasetService as unknown as DatasetService, + { create: vi.fn() } as unknown as NzModalService + ); + const emitted = emissionOf(); + + component.fileDropped([droppedFile("big.csv", new File(["x"], "big.csv"))]); + + expect(await emitted).toEqual([]); + expect(notify.error).toHaveBeenCalledWith("File big.csv's size exceeds the maximum limit of 0MiB."); + expect(component.fileUploadBannerType).toBe("error"); + expect(component.fileUploadBannerMessage).toBe("1 file failed to be selected."); + }); + + it("pluralises the failure banner for more than one rejected file", async () => { + component = new FilesUploaderComponent( + { error: vi.fn() } as unknown as NotificationService, + { getPublicSetting: vi.fn().mockReturnValue(of("0")) } as unknown as AdminSettingsService, + datasetService as unknown as DatasetService, + { create: vi.fn() } as unknown as NzModalService + ); + const emitted = emissionOf(); + + component.fileDropped([ + droppedFile("a.csv", new File(["x"], "a.csv")), + droppedFile("b.csv", new File(["y"], "b.csv")), + ]); + + expect(await emitted).toEqual([]); + expect(component.fileUploadBannerMessage).toBe("2 files failed to be selected."); + }); + + it("ignores a dropped directory", async () => { + const emitted = emissionOf(); + + component.fileDropped([{ relativePath: "folder", fileEntry: { isFile: false } } as unknown as NgxFileDropEntry]); + + expect(await emitted).toEqual([]); + }); + + it("drops a file whose entry cannot be read", async () => { + const failingEntry = { + relativePath: "unreadable.csv", + fileEntry: { + isFile: true, + file: (_success: (file: File) => void, failure: (error: unknown) => void): void => + failure(new Error("cannot read")), + }, + } as unknown as NgxFileDropEntry; + const emitted = emissionOf(); + + component.fileDropped([failingEntry]); + + expect(await emitted).toEqual([]); + }); + }); + + describe("lookups the drop depends on", () => { + const emissionOf = (): Promise<FileUploadItem[]> => + new Promise<FileUploadItem[]>(resolve => component.uploadedFiles.subscribe(resolve)); + + it("skips both lookups when the uploader has no dataset context", async () => { + // The standalone (dataset-creation) usage: no owner/name and no did yet. + component.ownerEmail = ""; + component.datasetName = ""; + component.did = undefined; + const emitted = emissionOf(); + + component.fileDropped([droppedFile("fresh.csv", new File(["new"], "fresh.csv"))]); + + expect((await emitted).map(item => item.name)).toEqual(["fresh.csv"]); + expect(datasetService.listMultipartUploads).not.toHaveBeenCalled(); + expect(datasetService.findExistingUploadFiles).not.toHaveBeenCalled(); + }); + + it("treats a failed lookup as nothing to reconcile", async () => { + datasetService.listMultipartUploads.mockReturnValue(throwError(() => new Error("offline"))); + datasetService.findExistingUploadFiles.mockReturnValue(throwError(() => new Error("offline"))); + const emitted = emissionOf(); + + component.fileDropped([droppedFile("failed.csv", new File(["half"], "failed.csv"))]); + + // No dialog can be raised without paths, so the file goes straight through. + expect((await emitted).map(item => item.name)).toEqual(["failed.csv"]); + expect(modals).toEqual([]); + }); + + it("treats a null lookup result as nothing to reconcile", async () => { + datasetService.listMultipartUploads.mockReturnValue(of(null)); + datasetService.findExistingUploadFiles.mockReturnValue(of(null)); + const emitted = emissionOf(); + + component.fileDropped([droppedFile("failed.csv", new File(["half"], "failed.csv"))]); + + expect((await emitted).map(item => item.name)).toEqual(["failed.csv"]); + expect(modals).toEqual([]); + }); + + it("reports an unexpected failure of the whole drop", async () => { + datasetService.listMultipartUploads.mockImplementation(() => { + throw new Error("lookup exploded"); + }); + + component.fileDropped([droppedFile("any.csv", new File(["x"], "any.csv"))]); + + await waitUntil(() => component.fileUploadingFinished); + expect(component.fileUploadBannerType).toBe("error"); + expect(component.fileUploadBannerMessage).toBe("Unexpected error: lookup exploded"); + }); + + it("reports an unexpected failure that carries no message", async () => { + datasetService.listMultipartUploads.mockImplementation(() => { + throw "lookup exploded"; + }); + + component.fileDropped([droppedFile("any.csv", new File(["x"], "any.csv"))]); + + await waitUntil(() => component.fileUploadingFinished); + expect(component.fileUploadBannerMessage).toBe("Unexpected error: lookup exploded"); + }); + }); + + it("stops tracking the size setting once destroyed", () => { + // @UntilDestroy() supplies the ngOnDestroy that ends the `untilDestroyed(this)` + // subscription; without it a late setting would still be applied to a dead component. + const setting = new Subject<string>(); + const uploader = new FilesUploaderComponent( + { error: vi.fn() } as unknown as NotificationService, + { getPublicSetting: vi.fn().mockReturnValue(setting) } as unknown as AdminSettingsService, + datasetService as unknown as DatasetService, + { create: vi.fn() } as unknown as NzModalService + ); + + setting.next("50"); + expect(uploader.singleFileUploadMaxSizeMiB).toBe(50); + + (uploader as unknown as OnDestroy).ngOnDestroy(); + setting.next("99"); + + expect(uploader.singleFileUploadMaxSizeMiB).toBe(50); + }); + + describe("dialog titles for paths without a file name", () => { + it("falls back to the whole path when the conflicting path ends in a separator", async () => { + datasetService.listMultipartUploads.mockReturnValue(of(["folder/"])); + datasetService.findExistingUploadFiles.mockReturnValue(of(["folder/"])); + const emitted = new Promise<FileUploadItem[]>(resolve => component.uploadedFiles.subscribe(resolve)); + + component.fileDropped([droppedFile("folder/", new File(["x"], "x"))]); + + await waitUntil(() => modals.length === 1); + expect(modals[0].nzData.path).toBe("folder/"); + modals[0].nzFooter.find(button => button.label === "Resume")?.onClick(); + + await waitUntil(() => modals.length === 2); + expect(modals[1].nzData.path).toBe("folder/"); + modals[1].nzFooter.find(button => button.label === "Upload")?.onClick(); + + expect((await emitted).map(item => item.name)).toEqual(["folder/"]); + }); + }); }); diff --git a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts index bd90137c96..11208fa940 100644 --- a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts @@ -17,7 +17,7 @@ * under the License. */ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, tick } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; import { UserComputingUnitComponent } from "./user-computing-unit.component"; import { ComputingUnitCreateModalComponent } from "../../../../common/component/computing-unit-create-modal/computing-unit-create-modal.component"; @@ -33,6 +33,8 @@ import { commonTestProviders } from "../../../../common/testing/test-utils"; import { WorkflowComputingUnitManagingService } from "../../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service"; import { ComputingUnitStatusService } from "../../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; import { MockComputingUnitStatusService } from "../../../../common/service/computing-unit/computing-unit-status/mock-computing-unit-status.service"; +import { ComputingUnitActionsService } from "../../../../common/service/computing-unit/computing-unit-actions/computing-unit-actions.service"; +import { NotificationService } from "../../../../common/service/notification/notification.service"; import { of } from "rxjs"; import type { Mocked } from "vitest"; describe("UserComputingUnitComponent", () => { @@ -94,4 +96,109 @@ describe("UserComputingUnitComponent", () => { modal.visibleChange.emit(false); expect(component.addComputeUnitModalVisible).toBe(false); }); + + describe("session, polling and termination", () => { + function makeUnit(cuid: number): DashboardWorkflowComputingUnit { + return { + computingUnit: { + cuid, + uid: 1, + name: `unit-${cuid}`, + creationTime: 0, + terminateTime: undefined, + type: "kubernetes", + uri: `uri-${cuid}`, + resource: { + cpuLimit: "1", + memoryLimit: "1Gi", + gpuLimit: "0", + jvmMemorySize: "1Gi", + shmSize: "64Mi", + nodeAddresses: [], + }, + }, + status: "Running", + metrics: { cpuUsage: "N/A", memoryUsage: "N/A" }, + isOwner: true, + accessPrivilege: "WRITE", + ownerGoogleAvatar: "", + ownerName: "owner", + } as DashboardWorkflowComputingUnit; + } + + afterEach(() => { + // ngOnInit starts a 1s interval; destroying the fixture unsubscribes it so it + // cannot tick into a later test. + fixture.destroy(); + }); + + it("follows the signed-in user when the session changes", () => { + fixture.detectChanges(); + const stubUserService = TestBed.inject(UserService) as unknown as StubUserService; + + stubUserService.user = undefined; + stubUserService.userChangeSubject.next(undefined); + + expect(component.isLogin).toBe(false); + expect(component.currentUid).toBeUndefined(); + }); + + it("maps the fetched computing units into dashboard entries", () => { + const statusService = TestBed.inject(ComputingUnitStatusService); + vi.spyOn(statusService, "getAllComputingUnits").mockReturnValue(of([makeUnit(7)])); + + fixture.detectChanges(); + + expect(component.allComputingUnits.map(u => u.computingUnit.cuid)).toEqual([7]); + expect(component.entries.map(e => e.id)).toEqual([7]); + }); + + it("refreshes the list on every poll tick, and stops once destroyed", fakeAsync(() => { + const statusService = TestBed.inject(ComputingUnitStatusService); + const refreshSpy = vi.spyOn(statusService, "refreshComputingUnitList").mockImplementation(() => {}); + + // ngOnInit directly rather than through detectChanges: the fixture's NgZone was + // created outside this fakeAsync zone, so a poll scheduled from there would land + // on the real timer queue and tick() could not drive it. + component.ngOnInit(); + expect(refreshSpy).not.toHaveBeenCalled(); + + tick(1000); + expect(refreshSpy).toHaveBeenCalledTimes(1); + tick(1000); + expect(refreshSpy).toHaveBeenCalledTimes(2); + + fixture.destroy(); + tick(2000); + expect(refreshSpy).toHaveBeenCalledTimes(2); + + // The interval is unbounded, so drop it before leaving the fakeAsync zone. + discardPeriodicTasks(); + })); + + it("hands a known computing unit to the actions service", () => { + const statusService = TestBed.inject(ComputingUnitStatusService); + vi.spyOn(statusService, "getAllComputingUnits").mockReturnValue(of([makeUnit(7)])); + const actions = TestBed.inject(ComputingUnitActionsService); + const terminateSpy = vi.spyOn(actions, "confirmAndTerminate").mockImplementation(() => {}); + fixture.detectChanges(); + + component.terminateComputingUnit(7); + + expect(terminateSpy).toHaveBeenCalledWith(7, component.allComputingUnits[0]); + }); + + it("reports an error instead of terminating an unknown computing unit", () => { + const actions = TestBed.inject(ComputingUnitActionsService); + const terminateSpy = vi.spyOn(actions, "confirmAndTerminate").mockImplementation(() => {}); + const notificationService = TestBed.inject(NotificationService); + const errorSpy = vi.spyOn(notificationService, "error").mockImplementation(() => {}); + fixture.detectChanges(); + + component.terminateComputingUnit(404); + + expect(errorSpy).toHaveBeenCalledWith("Invalid computing unit."); + expect(terminateSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts index 245f3be93c..0aadb792dd 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts @@ -26,6 +26,8 @@ import { DomSanitizer } from "@angular/platform-browser"; import { commonTestProviders } from "../../../../../../common/testing/test-utils"; import { of } from "rxjs"; import * as Papa from "papaparse"; +import JSZip from "jszip"; +import readXlsxFile from "read-excel-file"; import { SimpleChange, SimpleChanges } from "@angular/core"; import { MarkdownModule } from "ngx-markdown"; @@ -414,6 +416,184 @@ describe("UserDatasetFileRendererComponent", () => { loadWith("notes.txt", blob); expect(component.isFileTypePreviewUnsupported).toBe(true); }); + + it("does not fetch when the dataset ids are missing", () => { + const datasetService = TestBed.inject(DatasetService); + const spy = vi.spyOn(datasetService, "retrieveDatasetVersionSingleFile"); + // A supported, in-limit file, so the two pre-checks pass and the id guard is the + // only thing left to stop the request. + component.did = undefined; + component.dvid = 2; + component.filePath = "notes.txt"; + component.fileSize = 100; + + component.reloadFileContent(); + + expect(spy).not.toHaveBeenCalled(); + // Characterizing a defect, not an intent: `isLoading` is set true just above the id + // guard and nothing on this path clears it, so the spinner keeps running until some + // later change triggers another reload. Flip this to `false` when that is fixed. + expect(component.isLoading).toBe(true); + }); + + /** + * The spreadsheet branch runs the real `read-excel-file`, so these build an actual + * .xlsx with JSZip (already a dependency, and already used this way in + * user-workflow.component.spec.ts) rather than mocking the parser module. + */ + async function buildXlsxBlob(rowsXml: string): Promise<Blob> { + const zip = new JSZip(); + zip.file( + "[Content_Types].xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"> +<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/> +<Default Extension="xml" ContentType="application/xml"/> +<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/> +<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/> +</Types>` + ); + zip.file( + "_rels/.rels", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> +<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/> +</Relationships>` + ); + zip.file( + "xl/workbook.xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"> +<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets> +</workbook>` + ); + zip.file( + "xl/_rels/workbook.xml.rels", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> +<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/> +</Relationships>` + ); + zip.file( + "xl/worksheets/sheet1.xml", + `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"> +<sheetData>${rowsXml}</sheetData> +</worksheet>` + ); + const content = await zip.generateAsync({ type: "arraybuffer" }); + const blob = new Blob([content], { type: MIME_TYPES.MSEXCEL }); + // jsdom's Blob has no `arrayBuffer()`, which is how read-excel-file reads its input. + // Hand back the buffer we just built rather than patching Blob.prototype globally. + (blob as unknown as { arrayBuffer: () => Promise<ArrayBuffer> }).arrayBuffer = () => Promise.resolve(content); + return blob; + } + + // `getMimeType` looks an uppercased extension up as a key of MIME_TYPES, and the Excel key + // is MSEXCEL — so ".msexcel" is the only suffix that reaches this branch, and a real + // ".xlsx"/".xls" resolves to OCTET_STREAM and is rejected as unsupported. These tests use + // the suffix the code actually accepts; see the PR for the defect that implies. + const spreadsheetPath = "data.msexcel"; + + it("selects the spreadsheet viewer and parses the workbook into the table", async () => { + // Row 2 skips column B, so the parser yields a null cell and the empty-string arm of + // the cell mapping is exercised alongside the populated one. + const blob = await buildXlsxBlob(` + <row r="1"><c r="A1"><v>1</v></c><c r="B1"><v>2</v></c></row> + <row r="2"><c r="A2"><v>3</v></c><c r="C2"><v>4</v></c></row> + `); + + loadWith(spreadsheetPath, blob); + + await vi.waitFor(() => expect(component.displayXlsx).toBe(true)); + // Every row is padded to the widest one, so the header picks up a trailing empty cell + // from row 2's column C. + expect(component.tableDataHeader).toEqual(["1", "2", ""]); + expect(component.tableContent[0]).toEqual(["3", "", "4"]); + }); + + it("leaves the spreadsheet viewer off for a workbook with no rows", async () => { + const blob = await buildXlsxBlob(""); + + loadWith(spreadsheetPath, blob); + // Parsing the same workbook here is the barrier: it starts strictly after the + // component's read of an identical blob and runs the identical promise chain, so it + // cannot settle first. No timers and no counting of microtask turns. + await readXlsxFile(blob as unknown as File); + + expect(component.displayXlsx).toBe(false); + expect(component.tableDataHeader).toEqual([]); + }); + }); + + /** + * papaparse reads a File through `FileReader`, so replacing the global with a fake that + * settles on a microtask makes both the empty-result and the read-failure arms of the CSV + * branch deterministic — jsdom's real timing is never relied on. + */ + describe("CSV parsing outcomes", () => { + let realFileReader: typeof globalThis.FileReader; + let outcome: { text: string } | { fail: true }; + + class FakeFileReader { + public result: string | null = null; + public error: unknown = null; + public onload: ((event: unknown) => void) | null = null; + public onerror: ((event: unknown) => void) | null = null; + readAsText(): void { + queueMicrotask(() => { + if ("fail" in outcome) { + this.error = new Error("read failed"); + this.onerror?.({}); + } else { + this.result = outcome.text; + this.onload?.({ target: { result: outcome.text } }); + } + }); + } + abort(): void {} + } + + beforeEach(() => { + realFileReader = globalThis.FileReader; + (globalThis as unknown as { FileReader: unknown }).FileReader = FakeFileReader; + }); + + afterEach(() => { + (globalThis as unknown as { FileReader: unknown }).FileReader = realFileReader; + }); + + function loadCsv(): void { + const datasetService = TestBed.inject(DatasetService); + vi.spyOn(datasetService, "retrieveDatasetVersionSingleFile").mockReturnValue( + of(new Blob(["ignored"], { type: MIME_TYPES.CSV })) + ); + component.did = 1; + component.dvid = 2; + component.filePath = "data.csv"; + component.fileSize = undefined; + component.reloadFileContent(); + } + + it("leaves the table empty when the CSV has no rows", async () => { + outcome = { text: "" }; + + loadCsv(); + await vi.waitFor(() => expect(component.displayCSV).toBe(true)); + + expect(component.tableDataHeader).toEqual([]); + expect(component.tableContent).toEqual([]); + }); + + it("flags a loading error when the CSV cannot be read", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + outcome = { fail: true }; + + loadCsv(); + + await vi.waitFor(() => expect(component.isFileLoadingError).toBe(true)); + expect(consoleSpy).toHaveBeenCalled(); + }); }); });
