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;
+}