From b4040e910526e7fa59815cab167342a2c48ac1e6 Mon Sep 17 00:00:00 2001 From: mengw15 <125719918+mengw15@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:54:49 -0700 Subject: [PATCH] test(frontend): cover the remaining lines and branches in four files Extends the specs for CoeditorUserIconComponent, UserVenvComponent, FilesUploaderComponent and PresetWrapperComponent so each file reaches full line and branch coverage. No production code was changed. --- .../preset-wrapper.component.spec.ts | 149 +++++++++++++++--- .../files-uploader.component.spec.ts | 105 +++++++++++- .../user-venv/user-venv.component.spec.ts | 51 ++++++ .../coeditor-user-icon.component.spec.ts | 68 ++++++++ 4 files changed, 354 insertions(+), 19 deletions(-) diff --git a/frontend/src/app/common/formly/preset-wrapper/preset-wrapper.component.spec.ts b/frontend/src/app/common/formly/preset-wrapper/preset-wrapper.component.spec.ts index c074ce1a902..29d887f282f 100644 --- a/frontend/src/app/common/formly/preset-wrapper/preset-wrapper.component.spec.ts +++ b/frontend/src/app/common/formly/preset-wrapper/preset-wrapper.component.spec.ts @@ -18,6 +18,8 @@ */ import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { NzDropdownDirective, NzDropdownMenuComponent } from "ng-zorro-antd/dropdown"; import { FormControl } from "@angular/forms"; import { FormlyFieldConfig } from "@ngx-formly/core"; import { NzMessageService } from "ng-zorro-antd/message"; @@ -135,6 +137,43 @@ describe("PresetWrapperComponent", () => { expect(presetServiceStub.getPresets).toHaveBeenCalledWith(presetKey.presetType, presetKey.saveTarget); expect(component.searchResults).toEqual([testPreset, otherPreset]); }); + + it("seeds the search term from the form control's value", () => { + formControl.setValue("seeded"); + component.field = buildField(); + + component.ngOnInit(); + + expect(component["searchTerm"]).toBe("seeded"); + }); + + it("seeds an empty search term when the form control holds null", () => { + formControl.setValue(null); + component.field = buildField(); + + component.ngOnInit(); + + expect(component["searchTerm"]).toBe(""); + }); + }); + + describe("setupFieldConfig", () => { + it("merges the preset wrappers and the preset key into the given config", () => { + const config: FormlyFieldConfig = { key: fieldKey, templateOptions: { label: "kept" } }; + + PresetWrapperComponent.setupFieldConfig(config, "operator", "MySQLSource", "MySQLSource-op-1"); + + expect(config.wrappers).toEqual(["form-field", "preset-wrapper"]); + expect(config.templateOptions?.presetKey).toEqual({ + presetType: "operator", + saveTarget: "MySQLSource", + applyTarget: "MySQLSource-op-1", + }); + // the browser's own autocomplete is turned off so it cannot cover the preset menu + expect(config.templateOptions?.attributes).toEqual({ autocomplete: "off" }); + // pre-existing options survive the merge + expect(config.templateOptions?.label).toBe("kept"); + }); }); describe("functional api", () => { @@ -268,13 +307,17 @@ describe("PresetWrapperComponent", () => { expect(component.searchResults).toEqual([]); }); - it("does not refresh searchResults from form value changes while the dropdown is closed", () => { + it("does not refresh searchResults from form value changes while the dropdown is closed", async () => { const baselineCalls = presetServiceStub.getPresets.mock.calls.length; component.presetMenuVisible = false; formControl.setValue("typing"); + // the handler is debounced(0); without this tick it would not have run at all and + // the assertion below would hold no matter what the menu state is + await new Promise(resolve => setTimeout(resolve, 0)); - // No additional getPresets call because the menu is closed. + // the term is still tracked, but the menu being closed suppresses the refetch + expect(component["searchTerm"]).toBe("typing"); expect(presetServiceStub.getPresets.mock.calls.length).toBe(baselineCalls); }); @@ -290,6 +333,44 @@ describe("PresetWrapperComponent", () => { expect(presetServiceStub.getPresets.mock.calls.length).toBe(baselineCalls + 1); }); + it("adopts the preset carried by a matching applyPresetStream event", () => { + presetServiceStub.applyPresetStream.next({ + type: presetKey.presetType, + target: presetKey.applyTarget, + preset: testPreset, + }); + + expect(component["basePreset"]).toBe(testPreset); + }); + + it("ignores applyPresetStream events for a different presetType or applyTarget", () => { + const before = component["basePreset"]; + + presetServiceStub.applyPresetStream.next({ + type: "someOtherType", + target: presetKey.applyTarget, + preset: testPreset, + }); + presetServiceStub.applyPresetStream.next({ + type: presetKey.presetType, + target: "someOtherTarget", + preset: otherPreset, + }); + + expect(component["basePreset"]).toBe(before); + }); + + it("clears the search term when the form value becomes null while the dropdown is open", async () => { + component.presetMenuVisible = true; + component["searchTerm"] = "typed"; + + formControl.setValue(null); + // the valueChanges handler is debounced(0) — wait one tick + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(component["searchTerm"]).toBe(""); + }); + it("stops responding to stream events after ngOnDestroy", () => { component.searchResults = []; component.ngOnDestroy(); @@ -360,6 +441,20 @@ describe("PresetWrapperComponent", () => { fixture.detectChanges(); }; + it("routes the dropdown's own visibility event into onDropdownVisibilityEvent", () => { + // the tests above call the handler directly; this drives it through the template + // binding, which is the wiring that would break if the output were renamed + initWith([testPreset]); + const handler = vi.spyOn(component, "onDropdownVisibilityEvent"); + + fixture.debugElement + .query(By.directive(NzDropdownDirective)) + .injector.get(NzDropdownDirective) + .nzVisibleChange.emit(true); + + expect(handler).toHaveBeenCalledWith(true); + }); + it("renders the save button and saves the preset when it is clicked", () => { initWith([]); @@ -372,39 +467,57 @@ describe("PresetWrapperComponent", () => { expect(savePreset).toHaveBeenCalled(); }); - it("feeds the dropdown *ngFor with one entry per preset, titled and described", () => { - // The rows live in an nz-dropdown-menu that only mounts into a CDK overlay on a - // real user open, which jsdom does not drive; assert the list the *ngFor is bound - // to and the interpolations it renders for each row instead. + /** + * The rows live in an nz-dropdown-menu, whose content is an ng-template that only + * mounts into a CDK overlay when the dropdown opens — jsdom never drives that. + * Instantiating the template directly puts the rows in the fixture's DOM so the + * *ngFor, the interpolations and the row click handlers all really run. + */ + const renderDropdownRows = (): HTMLElement[] => { + const menu = fixture.debugElement.query(By.directive(NzDropdownMenuComponent)) + .componentInstance as NzDropdownMenuComponent; + menu.viewContainerRef.createEmbeddedView(menu.templateRef); + fixture.detectChanges(); + return Array.from(fixture.nativeElement.querySelectorAll(".preset-dropdown-item")); + }; + + it("renders one dropdown row per preset, titled and described", () => { initWith([testPreset, otherPreset]); - expect(component.searchResults).toEqual([testPreset, otherPreset]); - // the title cell renders the preset's value under the field's own key, and the - // description cell joins the remaining values - expect(component.getEntryTitle(testPreset)).toBe(testPreset[fieldKey]); - expect(component.getEntryDescription(testPreset)).toBe("otherPresetValue"); + const rows = renderDropdownRows(); + + expect(rows).toHaveLength(2); + expect(rows[0].querySelector(".title")?.textContent).toBe(testPreset[fieldKey]); + expect(rows[0].querySelector(".description")?.textContent).toBe("otherPresetValue"); + expect(rows[1].querySelector(".title")?.textContent).toBe(otherPreset[fieldKey]); }); - it("binds an empty dropdown list when there are no presets", () => { + it("renders no dropdown rows when there are no presets", () => { initWith([]); - expect(component.searchResults).toEqual([]); + expect(renderDropdownRows()).toHaveLength(0); }); - it("applies the preset the row's (click) binding targets", () => { + it("applies the preset when its row is clicked", () => { initWith([testPreset]); - component.applyPreset(testPreset); + renderDropdownRows()[0].querySelector(".dropdown-entry")!.click(); - expect(presetServiceStub.applyPreset).toHaveBeenCalledWith(expect.anything(), expect.anything(), testPreset); + expect(presetServiceStub.applyPreset).toHaveBeenCalledWith( + presetKey.presetType, + presetKey.applyTarget, + testPreset + ); }); - it("deletes the preset the delete button's (click) binding targets", () => { + it("deletes the preset from its row's delete button without applying it", () => { initWith([testPreset]); - component.deletePreset(testPreset); + renderDropdownRows()[0].querySelector(".delete-button")!.click(); expect(presetServiceStub.deletePreset).toHaveBeenCalled(); + // the button stops propagation so the surrounding row does not also apply it + expect(presetServiceStub.applyPreset).not.toHaveBeenCalled(); }); }); }); 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 e23668e9973..f0f9eeed191 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 @@ -19,8 +19,13 @@ import { of, Subject, throwError } from "rxjs"; import { OnDestroy } from "@angular/core"; -import { NgxFileDropEntry } from "ngx-file-drop"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { NgxFileDropComponent, NgxFileDropEntry } from "ngx-file-drop"; +import { NzAlertComponent } from "ng-zorro-antd/alert"; import { NzModalService } from "ng-zorro-antd/modal"; +import { commonTestProviders } from "../../../../common/testing/test-utils"; import { AdminSettingsService } from "../../../service/admin/settings/admin-settings.service"; import { DatasetService } from "../../../service/user/dataset/dataset.service"; import { NotificationService } from "../../../../common/service/notification/notification.service"; @@ -502,3 +507,101 @@ describe("FilesUploaderComponent", () => { }); }); }); + +/** + * The suite above constructs the component directly, so its template has never been + * rendered — the banner's `*ngIf`, the banner bindings and the drop-zone button live + * only in the template. These mount it for real. + */ +describe("FilesUploaderComponent rendered", () => { + let fixture: ComponentFixture; + let component: FilesUploaderComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FilesUploaderComponent, NoopAnimationsModule], + providers: [ + { provide: NotificationService, useValue: { error: vi.fn() } }, + { provide: AdminSettingsService, useValue: { getPublicSetting: vi.fn().mockReturnValue(of("20")) } }, + { + provide: DatasetService, + useValue: { + listMultipartUploads: vi.fn().mockReturnValue(of([])), + findExistingUploadFiles: vi.fn().mockReturnValue(of([])), + }, + }, + { provide: NzModalService, useValue: { create: vi.fn() } }, + ...commonTestProviders, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(FilesUploaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + const alert = (): HTMLElement | null => (fixture.nativeElement as HTMLElement).querySelector("nz-alert"); + + it("hides the banner until the alert is enabled and the upload has finished", () => { + expect(alert()).toBeNull(); + + component.showUploadAlert = true; + fixture.detectChanges(); + expect(alert()).toBeNull(); + + component.showUploadAlert = false; + component.fileUploadingFinished = true; + fixture.detectChanges(); + expect(alert()).toBeNull(); + }); + + it("renders the banner message once both flags are set", () => { + component.showUploadAlert = true; + component.fileUploadingFinished = true; + component.fileUploadBannerType = "error"; + component.fileUploadBannerMessage = "Upload failed. Please retry."; + fixture.detectChanges(); + + const banner = alert(); + expect(banner).not.toBeNull(); + expect(banner!.textContent).toContain("Upload failed. Please retry."); + }); + + it("clears the banner when its close control fires", () => { + component.showUploadAlert = true; + component.fileUploadingFinished = true; + component.fileUploadBannerMessage = "done"; + fixture.detectChanges(); + + fixture.debugElement.query(By.directive(NzAlertComponent)).componentInstance.nzOnClose.emit(); + fixture.detectChanges(); + + expect(component.fileUploadingFinished).toBe(false); + expect(alert()).toBeNull(); + }); + + it("opens the file selector from the drop-zone button", () => { + // ngx-file-drop hands its `openFileSelector` to the content template by reference, + // so spying on the component's property after render would not be seen. Assert its + // effect instead: it clicks the hidden file input. + const host = fixture.nativeElement as HTMLElement; + const fileInput: HTMLInputElement = host.querySelector("input.ngx-file-drop__file-input")!; + expect(fileInput).not.toBeNull(); + const openDialog = vi.spyOn(fileInput, "click").mockImplementation(() => {}); + + const button: HTMLButtonElement = host.querySelector(".upload-file-button")!; + expect(button).not.toBeNull(); + button.click(); + + expect(openDialog).toHaveBeenCalled(); + }); + + it("routes a drop on the zone into fileDropped", () => { + const dropped = vi.spyOn(component, "fileDropped").mockImplementation(() => {}); + const entries = [droppedFile("a.csv", new File(["a"], "a.csv"))]; + + fixture.debugElement.query(By.directive(NgxFileDropComponent)).componentInstance.onFileDrop.emit(entries); + + expect(dropped).toHaveBeenCalledWith(entries); + }); +}); diff --git a/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.spec.ts b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.spec.ts index cdc5ed66050..dde83e2726b 100644 --- a/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.spec.ts @@ -147,6 +147,26 @@ describe("UserVenvComponent", () => { expect(notificationSpy.error).toHaveBeenCalledWith("Failed to fetch Python environments."); expect(component.pves).toEqual([]); }); + + it("treats a record with no packages as an empty package list", () => { + pveServiceSpy.listUserPves.mockReturnValue(of([{ veid: 1, name: "envA" } as UserPveRecord])); + + fixture.detectChanges(); + + expect(component.pves[0].newPackages).toEqual([]); + }); + + it("falls back to an empty version when the stored value is nullish", () => { + // distinct from the `empty: ""` case above: "" is not nullish, so only a null + // value reaches the `?? ""` arm + pveServiceSpy.listUserPves.mockReturnValue( + of([{ veid: 1, name: "envA", packages: { ghost: null } } as unknown as UserPveRecord]) + ); + + fixture.detectChanges(); + + expect(component.pves[0].newPackages).toEqual([{ name: "ghost", versionOp: "==", version: "" }]); + }); }); describe("modal open/close and package editing", () => { @@ -284,6 +304,19 @@ describe("UserVenvComponent", () => { expect(pveServiceSpy.listUserPves).toHaveBeenCalledTimes(1); // refresh after save }); + it("treats a row whose version is nullish as an empty version", () => { + component.currentDraft = { + name: "envNull", + newPackages: [{ name: "a", versionOp: ">=", version: null as unknown as string }], + }; + pveServiceSpy.savePve.mockReturnValue(of({ veid: 6 })); + pveServiceSpy.listUserPves.mockReturnValue(of([])); + + component.saveEnvironment(); + + expect(pveServiceSpy.savePve).toHaveBeenCalledWith("envNull", { a: "" }); + }); + it("updates an existing environment when the draft carries a veid", () => { component.pves = [{ veid: 7, name: "envU", newPackages: [] }]; component.currentDraft = { @@ -351,6 +384,14 @@ describe("UserVenvComponent", () => { component.confirmDeletePve(5); expect(confirmSpy).not.toHaveBeenCalled(); }); + + it("names an environment with a blank name as (unnamed) in the confirm title", () => { + component.pves = [{ veid: 3, name: "", newPackages: [] }]; + + component.confirmDeletePve(0); + + expect(capturedConfirmConfig?.nzTitle).toBe('Delete environment "(unnamed)"?'); + }); }); describe("deletePve", () => { @@ -387,6 +428,16 @@ describe("UserVenvComponent", () => { expect(consoleErrorSpy).toHaveBeenCalled(); expect(notificationSpy.error).toHaveBeenCalledWith("Failed to delete Python environment."); }); + + it("reports a blank-named environment as (unnamed) on success", () => { + component.pves = [{ veid: 9, name: "", newPackages: [] }]; + pveServiceSpy.deleteUserPve.mockReturnValue(of(undefined)); + pveServiceSpy.listUserPves.mockReturnValue(of([])); + + component.deletePve(0); + + expect(notificationSpy.success).toHaveBeenCalledWith('Deleted environment "(unnamed)".'); + }); }); describe("trackByVeid", () => { diff --git a/frontend/src/app/workspace/component/menu/coeditor-user-icon/coeditor-user-icon.component.spec.ts b/frontend/src/app/workspace/component/menu/coeditor-user-icon/coeditor-user-icon.component.spec.ts index 0f3f07170dd..b52d6b96707 100644 --- a/frontend/src/app/workspace/component/menu/coeditor-user-icon/coeditor-user-icon.component.spec.ts +++ b/frontend/src/app/workspace/component/menu/coeditor-user-icon/coeditor-user-icon.component.spec.ts @@ -18,6 +18,7 @@ */ import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { CoeditorUserIconComponent } from "./coeditor-user-icon.component"; import { CoeditorPresenceService } from "../../../service/workflow-graph/model/coeditor-presence.service"; @@ -53,7 +54,74 @@ describe("CoeditorUserIconComponent", () => { fixture.detectChanges(); }); + /** + * The menu items live inside ``, whose content is an ng-template + * that only mounts into a CDK overlay when the dropdown opens — jsdom never drives + * that. Instantiating the template directly puts the items in the fixture's DOM, so + * both variants can be asserted and clicked without an overlay. + */ + function renderDropdownMenu(): HTMLElement[] { + const menu = fixture.debugElement.query(By.directive(NzDropdownMenuComponent)) + .componentInstance as NzDropdownMenuComponent; + menu.viewContainerRef.createEmbeddedView(menu.templateRef); + fixture.detectChanges(); + return Array.from(fixture.nativeElement.querySelectorAll("li[nz-menu-item]")); + } + it("should create", () => { expect(component).toBeTruthy(); }); + + it("offers to start shadowing while shadowing mode is off", () => { + component.coeditor = { ...component.coeditor, name: "alice", clientId: "c1" }; + const items = renderDropdownMenu(); + + expect(items).toHaveLength(1); + expect(items[0].textContent).toContain('Start "shadowing":'); + expect(items[0].textContent).toContain("alice"); + expect(items[0].textContent).toContain("c1"); + }); + + it("still offers to start shadowing while another co-editor is being shadowed", () => { + // second half of the guard false: shadowing is on, but for a different client + component.coeditor = { ...component.coeditor, clientId: "c1" }; + coeditorPresenceService.shadowingModeEnabled = true; + coeditorPresenceService.shadowingCoeditor = { ...component.coeditor, clientId: "c2" }; + + const items = renderDropdownMenu(); + + expect(items).toHaveLength(1); + expect(items[0].textContent).toContain('Start "shadowing":'); + }); + + it("offers to stop shadowing while this co-editor is the one being shadowed", () => { + component.coeditor = { ...component.coeditor, clientId: "c1" }; + coeditorPresenceService.shadowingModeEnabled = true; + coeditorPresenceService.shadowingCoeditor = component.coeditor; + + const items = renderDropdownMenu(); + + expect(items).toHaveLength(1); + expect(items[0].textContent).toContain("Stop Shadowing"); + }); + + it("shadows the co-editor when the start item is clicked", () => { + component.coeditor = { ...component.coeditor, clientId: "c1" }; + const shadow = vi.spyOn(coeditorPresenceService, "shadowCoeditor"); + + renderDropdownMenu()[0].click(); + + expect(shadow).toHaveBeenCalledWith(component.coeditor); + }); + + it("stops shadowing when the stop item is clicked", () => { + component.coeditor = { ...component.coeditor, clientId: "c1" }; + coeditorPresenceService.shadowingModeEnabled = true; + coeditorPresenceService.shadowingCoeditor = component.coeditor; + const stop = vi.spyOn(coeditorPresenceService, "stopShadowing"); + + renderDropdownMenu()[0].click(); + + expect(stop).toHaveBeenCalled(); + }); });