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-8031-ea580941fc51896041c9d15a4ec536dd50962126 in repository https://gitbox.apache.org/repos/asf/texera.git
commit e87ec126afdbdb12a9fc90e4904e54dc9a59c7ca Author: Xinyuan Lin <[email protected]> AuthorDate: Fri Aug 28 02:43:29 2026 +0000 test(frontend): cover the computing-unit actions service and result panel (#8031) ### What changes were proposed in this PR? Two small frontend files. Measured with the identical spec-file filter on both sides (two repeated `--include` globs, never a test-name phrase), `rm -rf coverage` between runs, reading `frontend/coverage/gui/lcov.info`. | File | Codecov metric | Delta | |---|---|---| | `computing-unit-actions.service.ts` | 26/29 = 89.7% → **29/29 = 100%** | +3 lines | | `result-panel.component.html` | 50/53 = 94.3% → **53/53 = 100%** | +3 lines | **+6 fully-covered lines and +5 branch arms.** The before figure on the template reproduces Codecov's published 94.3% exactly. This is a small PR and I would rather say so than dress it up. What makes it worth having is the template's function counter. ### The template's bindings were never entered `result-panel.component.html` sat at **FNF 7 / FNH 4** — three of its seven functions uncovered — because the existing spec called `handleEndDrag(...)` and `handleStartDrag()` **directly on the component**. The bindings themselves stayed at `FNDA:0`: covered-looking, unexercised. Driving them through the rendered element is what closes them. That is the seventh time in this campaign a file has sat at a healthy line percentage with uncovered functions, and the second time the specific cause was a spec calling a handler directly instead of firing the binding. ### Verification 18 mutations, **all 18 killed.** The first draft claimed `survivors: []` alongside "NO DEFECTS FOUND" and "nothing in the advertised gap was unreachable". Its own mutants were re-run against the bundle as shipped first, and several survived it; all now die. Two further corrections to that draft: - **An arithmetic error.** It reported the service as `23/26 → 26/26`. Lines 101 and 109 carry `BRDA` records but **no `DA`** record — v8 attributes the `DA` for the whole `modalService.confirm({...})` statement to line 99 — so on the `union(DA, BRDA)` line set the correct figures are 26/29 → 29/29. Same delta, different denominator, and worth stating so the numbers reproduce. - **A miscredited kill.** One mutation's row named a single test with the second in a parenthetical "also:". Re-derived, it fails two tests outright. ### One vacuity worth naming, though it is not a survivor The pre-existing test "renders the resize handles when the panel is docked" still passes a swap of the line-120 `nzDirections` arm on its own, because it never distinguishes the two directions. It is not a file-level survivor — the new tests kill that mutant — but the older test does not constrain what its name suggests. ### Deliberately not included Nothing. Both files reach 100% on lines, branches and functions, and neither carries an unreachable partial — which is unusual at this point in the codebase and is why this bundle was taken at six lines when three sibling bundles at the same size were dropped. No production file is touched. The worktree used a real yarn install rather than a `node_modules` junction. ### Any related issues, documentation, discussions? Closes #8030 ### How was this PR tested? ``` npx ng test --watch=false --include="**/computing-unit-actions.service.spec.ts" --include="**/result-panel.component.spec.ts" ``` ``` Test Files 2 passed (2) ``` `yarn format:ci` passes. `frontend/junit.xml` and `frontend/coverage/` are regenerated by every run and are not committed. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --- .../computing-unit-actions.service.spec.ts | 34 +++++ .../result-panel/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 fe208457a4..d31ce59095 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 <strong>local1</strong>"); + // 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 <strong>local1</strong>"); + 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 c4236299b7..339c772b52 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; +}
