From 91bc950c3bba83a74e375405ff8e387991f52da8 Mon Sep 17 00:00:00 2001 From: Xinyuan Lin Date: Wed, 26 Aug 2026 18:07:08 -0700 Subject: [PATCH] test(frontend): cover the computing-unit actions service and result panel Both files reach 100% on lines, branches and functions: +6 fully-covered lines and +5 branch arms. The before figure on the template reproduces Codecov's published 94.3% exactly. Small, and worth it for the function counter: result-panel.component.html sat at FNF 7 / FNH 4 because its spec called handleEndDrag and handleStartDrag directly on the component, leaving the template's own bindings at FNDA:0 -- covered-looking and unexercised. Seventh time this campaign a file has had uncovered functions behind a healthy line figure. 18 mutations, all killed. The first pass claimed no survivors alongside "NO DEFECTS FOUND"; several of its own mutants survived it. Its arithmetic was also wrong -- two lines carry BRDA records but no DA record, because v8 attributes the DA for the whole modalService.confirm statement to the line above -- and one kill was credited to one test when it fails two. Neither file carries an unreachable partial, which is why this was taken at six lines when three sibling bundles the same size were dropped. --- .../computing-unit-actions.service.spec.ts | 34 ++++ .../result-panel.component.spec.ts | 167 +++++++++++++++++- 2 files changed, 192 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/common/service/computing-unit/computing-unit-actions/computing-unit-actions.service.spec.ts b/frontend/src/app/common/service/computing-unit/computing-unit-actions/computing-unit-actions.service.spec.ts index fe208457a4f..d31ce590950 100644 --- a/frontend/src/app/common/service/computing-unit/computing-unit-actions/computing-unit-actions.service.spec.ts +++ b/frontend/src/app/common/service/computing-unit/computing-unit-actions/computing-unit-actions.service.spec.ts @@ -149,6 +149,40 @@ describe("ComputingUnitActionsService", () => { expect(notificationService.error).toHaveBeenCalledWith("Failed to terminate computing unit"); }); + it("uses the disconnect wording and shows no destructive warning for a local unit", () => { + service.confirmAndTerminate(3, unit({ type: "local", name: "local1" })); + + const config = modalService.confirm.mock.calls[0][0]; + expect(config.nzTitle).toBe("Disconnect from Local Computing Unit"); + expect(config.nzOkText).toBe("Disconnect"); + expect(config.nzContent).toContain("disconnect from local1"); + // A local unit holds no execution results, so the data-loss warning must not appear. + // Assert the copy itself, not only its colour, so restyling the shared warning + // (a CSS class, a theme token) cannot silently void this guard. + expect(config.nzContent).not.toContain("Warning:"); + expect(config.nzContent).not.toContain("will be lost"); + expect(config.nzContent).not.toContain("#ff4d4f"); + // the cancel label is a fixed word; it must not co-vary with the unit type + expect(config.nzCancelText).toBe("Cancel"); + expect(config.nzOkType).toBe("primary"); + }); + + it("falls back to the kubernetes copy when the unit carries no type", () => { + // `type` is declared non-optional, so this pins the explicit `|| "kubernetes"` fallback. + // The name deliberately reuses the local case's "local1" so that `type` and `name` are not + // correlated across the suite: the copy cannot be keyed off the unit's name instead. + service.confirmAndTerminate(4, unit({ type: undefined, name: "local1" })); + + const config = modalService.confirm.mock.calls[0][0]; + expect(config.nzTitle).toBe("Terminate Computing Unit"); + expect(config.nzOkText).toBe("Terminate"); + expect(config.nzContent).toContain("terminate local1"); + expect(config.nzContent).toContain("Warning:"); + expect(config.nzContent).toContain("#ff4d4f"); + expect(config.nzCancelText).toBe("Cancel"); + expect(config.nzOkType).toBe("primary"); + }); + it("notifies an error with the extracted message when the termination observable errors", () => { statusService.terminateComputingUnit.mockReturnValue(throwError(() => new Error("kaboom"))); service.confirmAndTerminate(7, unit()); diff --git a/frontend/src/app/workspace/component/result-panel/result-panel.component.spec.ts b/frontend/src/app/workspace/component/result-panel/result-panel.component.spec.ts index c4236299b7b..339c772b521 100644 --- a/frontend/src/app/workspace/component/result-panel/result-panel.component.spec.ts +++ b/frontend/src/app/workspace/component/result-panel/result-panel.component.spec.ts @@ -18,9 +18,10 @@ */ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { Component, ElementRef } from "@angular/core"; -import { CdkDragEnd } from "@angular/cdk/drag-drop"; +import { Component, ElementRef, Input } from "@angular/core"; +import { CdkDrag, CdkDragEnd } from "@angular/cdk/drag-drop"; import { NzResizeEvent } from "ng-zorro-antd/resizable"; +import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { DEFAULT_HEIGHT, DEFAULT_WIDTH, ResultPanelComponent } from "./result-panel.component"; import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-workflow.service"; @@ -173,8 +174,12 @@ describe("ResultPanelComponent", () => { component.dragPosition = { x: 5, y: 7 }; expect(component.isPanelDocked()).toBe(true); + // vary one axis at a time so each half of the conjunction is individually load-bearing component.dragPosition = { x: 5, y: 8 }; expect(component.isPanelDocked()).toBe(false); + + component.dragPosition = { x: 6, y: 7 }; + expect(component.isPanelDocked()).toBe(false); }); it("clearResultPanel empties the frame configs", () => { @@ -681,12 +686,20 @@ describe("ResultPanelComponent", () => { const resetSpy = vi.spyOn(component, "resetPanelPosition").mockImplementation(() => {}); expect(fixture.debugElement.query(By.css("#content"))).toBeTruthy(); + // Assert after each fire so the three controls are individually attributable: a + // single spy call cannot stand in for all of them, and swapping the two in-panel + // handlers changes which counter moves. fire("#result-buttons li[nz-menu-item]", "click", new MouseEvent("click")); // header close + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(resetSpy).not.toHaveBeenCalled(); + fire("#panel-button button", "click", new MouseEvent("click")); // reset-position - fire("#panel-button li[nz-menu-item]", "click", new MouseEvent("click")); // in-panel close + expect(resetSpy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); - expect(closeSpy).toHaveBeenCalled(); - expect(resetSpy).toHaveBeenCalled(); + fire("#panel-button li[nz-menu-item]", "click", new MouseEvent("click")); // in-panel close + expect(closeSpy).toHaveBeenCalledTimes(2); + expect(resetSpy).toHaveBeenCalledTimes(1); }); it("renders only the collapsed open button and wires openPanel when width is 0", () => { @@ -729,14 +742,29 @@ describe("ResultPanelComponent", () => { it("renders a tab per frame when frames are present", () => { component.width = DEFAULT_WIDTH; - component.frameComponentConfigs.set("Result", { component: StubFrame, componentInputs: {} }); - component.frameComponentConfigs.set("Console", { component: StubFrame, componentInputs: {} }); + // Seed real inputs: the outlet binds `inputs: config.value.componentInputs`, and that is + // the only thing that delivers operatorId into the real result / console / error frames. + component.frameComponentConfigs.set("Result", { + component: StubFrame, + componentInputs: { operatorId: "op-result" }, + }); + component.frameComponentConfigs.set("Console", { + component: StubFrame, + componentInputs: { operatorId: "op-console" }, + }); fixture.detectChanges(); const text = (fixture.nativeElement as HTMLElement).textContent ?? ""; expect(text).toContain("Result"); expect(text).toContain("Console"); expect(text).not.toContain("No results available to display."); + + const renderedInputs = fixture.debugElement + .queryAll(By.directive(StubFrame)) + .map(frame => (frame.componentInstance as StubFrame).operatorId); + expect(renderedInputs.length).toBeGreaterThan(0); + expect(renderedInputs).not.toContain(undefined); + renderedInputs.forEach(operatorId => expect(["op-result", "op-console"]).toContain(operatorId)); }); it("renders the resize handles when the panel is docked", () => { @@ -746,10 +774,131 @@ describe("ResultPanelComponent", () => { expect(fixture.debugElement.query(By.css("nz-resize-handles"))).toBeTruthy(); }); + + // The two tests below leave isPanelDocked unmocked on purpose: the real predicate has to run + // for the template's `isPanelDocked() ? ... : ...` direction list to be exercised both ways. + // The spy is installed without an implementation, so behaviour is untouched; asserting that + // it was called is what stops the call being replaced by an inline template expression. + it("offers only the right handle while the panel sits docked in its return position", () => { + component.width = DEFAULT_WIDTH; + component.returnPosition = { x: 0, y: 0 }; + component.dragPosition = { x: 0, y: 0 }; + const dockedSpy = vi.spyOn(component, "isPanelDocked"); + fixture.detectChanges(); + + const handles = fixture.debugElement.queryAll(By.css("nz-resize-handle")); + expect(dockedSpy).toHaveBeenCalled(); + expect(handles.length).toBe(1); + expect((handles[0].nativeElement as HTMLElement).className).toContain("nz-resizable-handle-right"); + }); + + it("offers all three resize directions once the panel has been dragged away from its return position", () => { + component.width = DEFAULT_WIDTH; + component.returnPosition = { x: 0, y: 0 }; + // Only the y axis moves, so a direction list keyed off the x axis alone would still + // report the panel as docked and render a single handle. + component.dragPosition = { x: 0, y: 60 }; + const dockedSpy = vi.spyOn(component, "isPanelDocked"); + fixture.detectChanges(); + + const classNames = fixture.debugElement + .queryAll(By.css("nz-resize-handle")) + .map(handle => (handle.nativeElement as HTMLElement).className); + expect(dockedSpy).toHaveBeenCalled(); + expect(classNames.length).toBe(3); + expect(classNames.some(c => c.includes("nz-resizable-handle-right"))).toBe(true); + expect(classNames.some(c => c.includes("nz-resizable-handle-bottom"))).toBe(true); + expect(classNames.some(c => c.includes("nz-resizable-handle-bottomRight"))).toBe(true); + }); + + it("names the selected operator in the collapsed open button's tooltip", () => { + component.width = 0; + component.operatorTitle = "My Operator"; + fixture.detectChanges(); + + const openButton = fixture.debugElement.query(By.css("#result-buttons li[nz-menu-item]")); + expect(openButton.injector.get(NzTooltipDirective).directiveTitle).toBe("Open Result Panel: My Operator"); + }); + + it("omits the separator from the collapsed open button's tooltip when no operator is selected", () => { + component.width = 0; + component.operatorTitle = ""; + fixture.detectChanges(); + + const openButton = fixture.debugElement.query(By.css("#result-buttons li[nz-menu-item]")); + expect(openButton.injector.get(NzTooltipDirective).directiveTitle).toBe("Open Result Panel"); + }); + + // The expanded panel swaps the same menu slot for a Close item carrying its own, + // near-identical tooltip; both arms of its ternary need their own assertions or the + // interpolation is only executed, never read. + it("names the selected operator in the expanded close button's tooltip", () => { + component.width = DEFAULT_WIDTH; + component.operatorTitle = "My Operator"; + fixture.detectChanges(); + + const closeButton = fixture.debugElement.query(By.css("#result-buttons li[nz-menu-item]")); + expect(closeButton.injector.get(NzTooltipDirective).directiveTitle).toBe("Close Result Panel: My Operator"); + }); + + it("omits the separator from the expanded close button's tooltip when no operator is selected", () => { + component.width = DEFAULT_WIDTH; + component.operatorTitle = ""; + fixture.detectChanges(); + + const closeButton = fixture.debugElement.query(By.css("#result-buttons li[nz-menu-item]")); + expect(closeButton.injector.get(NzTooltipDirective).directiveTitle).toBe("Close Result Panel"); + }); + + it("routes the container's drag and resize outputs to the component handlers", () => { + // onResize defers its assignment through requestAnimationFrame; run the callback + // synchronously so the width/height assertions are deterministic. + const cancelSpy = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {}); + const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(cb => { + cb(0); + return 1; + }); + // handleStartDrag/handleEndDrag dereference the #dynamicComponent ViewChild, which only + // resolves once the panel is open and has at least one frame to render. + component.width = DEFAULT_WIDTH; + component.returnPosition = { x: 0, y: 0 }; + component.frameComponentConfigs.set("Result", { component: StubFrame, componentInputs: {} }); + fixture.detectChanges(); + const startDragSpy = vi.spyOn(component, "handleStartDrag"); + + try { + fire("#result-container", "cdkDragStarted", {}); + fire("#result-container", "cdkDragEnded", { + source: { getFreeDragPosition: () => ({ x: 11, y: 22 }) }, + }); + fire("#result-container", "nzResize", { width: 700, height: 400 }); + + expect(startDragSpy).toHaveBeenCalledTimes(1); + expect(component.dragPosition).toEqual({ x: 11, y: 22 }); + expect(component.width).toBe(700); + expect(component.height).toBe(400); + + // Recording the fields is only half the wiring: push a change-detection pass and + // assert the recorded values actually reach the DOM, i.e. that the container is + // sized from width/height and keeps the dropped position rather than snapping back. + fixture.detectChanges(); + const container = fixture.debugElement.query(By.css("#result-container")); + expect(container.injector.get(CdkDrag).freeDragPosition).toEqual({ x: 11, y: 22 }); + const host = container.nativeElement as HTMLElement; + expect(host.style.width).toBe("700px"); + expect(host.style.height).toBe("400px"); + } finally { + rafSpy.mockRestore(); + cancelSpy.mockRestore(); + } + }); }); }); // A trivial standalone frame so *ngComponentOutlet can instantiate a tab's content -// without pulling in a real result-frame component's dependency graph. +// without pulling in a real result-frame component's dependency graph. It declares the +// one input every real frame takes, so the outlet's `inputs:` half is observable. @Component({ standalone: true, template: "" }) -class StubFrame {} +class StubFrame { + @Input() operatorId?: string; +}