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-7241-a03782846338f0883d0c680328b35d41d9a2f7de
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 1ad5163f83fe3d48b4451bf76f04ec578f252645
Author: Xinyuan Lin <[email protected]>
AuthorDate: Sat Aug 1 02:08:03 2026 -0700

    test(frontend): extend workspace mini-map, jupyter-panel and preset specs 
(#7241)
    
    ### What changes were proposed in this PR?
    
    Three existing specs under `frontend/src/app/workspace/`, extended to
    cover what their subjects had left untested:
    
    | File | Before | Tests now |
    |---|---|---|
    | `mini-map.component.ts` | 42.9% | 16 |
    | `preset.service.ts` | 81.0% | 55 |
    | `jupyter-panel.service.ts` | 82.2% | 41 |
    
    Two of these have traps that make a naive extension pass for the wrong
    reason, which is most of why they were left half-covered:
    
    **jsdom has no layout.** `src/jsdom-svg-polyfill.ts` stubs
    `getScreenCTM`/`getCTM` to an identity matrix and `getBBox` to a zero
    rect, and `offsetWidth`/`offsetHeight` are 0. So `ngAfterViewInit`
    computes `scale = 0`, and a geometry assertion reads `"0px"` — true
    because there is no layout, not because the formula is right. The
    mini-map specs use a stub paper with explicit, mutually distinguishable
    geometry plus `Object.defineProperty`'d offset dimensions, and every
    expected value is a non-zero literal (`200px`, `100px`, `37.5px`,
    `matrix(0.25,0,0,0.25,240,135)`), so a regression back to zero-layout
    fails.
    
    **`catchError` swallows the difference between two branches.**
    `fetchNotebookAndMapping` wraps its `switchMap` in `catchError(() =>
    of(0))`. The base mock has no `sendNotebookToJupyter`, so flushing
    `{exists:true}` without defining it throws a `TypeError` that becomes
    `0` — indistinguishable from the send-failed branch under test. Every
    spec on that path defines the mock explicitly *and* asserts it was
    called; the reject case additionally asserts `console.error` was not
    called, which is what separates it from the `catchError` path.
    
    Also handled: Angular's automatic fixture teardown fires `ngOnDestroy`
    after every test, which writes `localStorage["mini-map"]`. The key is
    cleared before `TestBed.createComponent` and again in `afterEach`, so
    ordering cannot decide a result.
    
    Two things deliberately left uncovered, with the reasoning in the files:
    
    - `updatePreset`'s splice/replace branches. The method has **no
    production caller** and carries a real bug — lodash `indexOf` against a
    list that was just `JSON.parse`'d always returns `-1`, so `splice(-1,
    1)` deletes the *last* preset instead of the intended one, and
    `presets[-1] = replacement` writes a non-index property that
    `JSON.stringify` silently drops. Its sibling `updateOrCreatePreset`
    already uses the correct `findIndex(p => isEqual(p, original))`. Testing
    it would cement the bug; it wants a separate fix-or-delete.
    - The two `no default save preset info/warning message` throws — pinning
    them would assert a limitation that adding a default message would fix.
    
    Assertion strength was checked by mutation and every mutation reverted;
    the production diff is empty.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7238
    
    ### How was this PR tested?
    
    ```
    npx ng test --watch=false --include="**/mini-map.component.spec.ts" 
--include="**/jupyter-panel.service.spec.ts" 
--include="**/preset.service.spec.ts"
    ```
    
    ```
     ✓ src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts 
(41 tests)
     ✓ src/app/workspace/service/preset/preset.service.spec.ts (55 tests)
     ✓ 
src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts 
(16 tests)
     Test Files  3 passed (3)
    ```
    
    `yarn format:ci` passes (prettier-eslint + eslint), which Vitest does
    not cover on its own.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 .../mini-map/mini-map.component.spec.ts            | 358 ++++++++++++++++++++-
 .../jupyter-panel/jupyter-panel.service.spec.ts    | 239 ++++++++++++++
 .../service/preset/preset.service.spec.ts          |  60 ++++
 3 files changed, 655 insertions(+), 2 deletions(-)

diff --git 
a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts
 
b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts
index 3686454ca6..2087b5e72b 100644
--- 
a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts
@@ -19,18 +19,100 @@
 
 import { ComponentFixture, TestBed } from "@angular/core/testing";
 import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { ReplaySubject } from "rxjs";
+import * as joint from "jointjs";
 import { MiniMapComponent } from "./mini-map.component";
+import { MAIN_CANVAS } from "../workflow-editor.component";
 import { WorkflowActionService } from 
"../../../service/workflow-graph/model/workflow-action.service";
+import { JointGraphWrapper } from 
"../../../service/workflow-graph/model/joint-graph-wrapper";
 import { OperatorMetadataService } from 
"../../../service/operator-metadata/operator-metadata.service";
 import { StubOperatorMetadataService } from 
"../../../service/operator-metadata/stub-operator-metadata.service";
 import { JointUIService } from "../../../service/joint-ui/joint-ui.service";
+import { PanelService } from "../../../service/panel/panel.service";
 import { UndoRedoService } from "../../../service/undo-redo/undo-redo.service";
 import { WorkflowUtilService } from 
"../../../service/workflow-graph/util/workflow-util.service";
 import { DragDropModule } from "@angular/cdk/drag-drop";
 import { commonTestProviders } from "../../../../common/testing/test-utils";
 
+/**
+ * Stand-in for the main workflow-editor paper the mini-map mirrors.
+ *
+ * jsdom has no layout engine: `src/jsdom-svg-polyfill.ts` stubs `getScreenCTM`
+ * / `getCTM` to the identity matrix and `getBBox` to a zero rect, so a real
+ * jointjs paper reports zero-sized geometry and every navigator assertion 
would
+ * read "0px" — passing because there is no layout rather than because the
+ * formula is right. This stub reports explicit, distinguishable geometry
+ * instead, and records the calls the component makes against it.
+ */
+class StubPaper {
+  public readonly handlers: Record<string, () => void> = {};
+  public readonly pageToLocalPointArgs: { x: number; y: number }[] = [];
+  public readonly translateArgs: [number, number][] = [];
+  /** Local-coordinate point the next `pageToLocalPoint` call resolves to. */
+  public localPoint = { x: 0, y: 0 };
+  /** Current paper offset reported by the no-arg `translate()` getter. */
+  public offset = { tx: 0, ty: 0 };
+
+  constructor(
+    private readonly sx: number = 1,
+    private readonly sy: number = 1
+  ) {}
+
+  on(event: string, handler: () => void): void {
+    this.handlers[event] = handler;
+  }
+
+  scale(): { sx: number; sy: number } {
+    return { sx: this.sx, sy: this.sy };
+  }
+
+  pageToLocalPoint(point: { x: number; y: number }): { x: number; y: number } {
+    this.pageToLocalPointArgs.push(point);
+    return this.localPoint;
+  }
+
+  translate(tx?: number, ty?: number): { tx: number; ty: number } | void {
+    if (tx === undefined) return this.offset;
+    this.translateArgs.push([tx, ty as number]);
+  }
+}
+
+/**
+ * Regression coverage for the mini-map: the persisted show/hide flag, the
+ * navigator overlay geometry (which mirrors the main paper's viewport onto the
+ * mini-map), drag-to-pan, and the zoom/center toolbar buttons.
+ *
+ * Breakage this catches: dropping/renaming the "mini-map" localStorage key so
+ * the collapsed state no longer survives a reload; unsubscribing the mini-map
+ * from the main paper's translate/scale/resize events so the navigator 
freezes;
+ * sign or scale errors in the navigator-position and drag-to-pan formulas;
+ * losing the zoom-limit guards so the toolbar can push the zoom ratio past
+ * ZOOM_MINIMUM / ZOOM_MAXIMUM; and the panel service's close/reset streams no
+ * longer hiding/showing the mini-map.
+ */
 describe("MiniMapComponent", () => {
   let fixture: ComponentFixture<MiniMapComponent>;
+  let component: MiniMapComponent;
+  let workflowActionService: WorkflowActionService;
+  let panelService: PanelService;
+  let editorStub: HTMLDivElement | undefined;
+  let mainPaper$: ReplaySubject<joint.dia.Paper>;
+
+  /**
+   * `localStorage` is a jsdom global shared by every test in this file, and
+   * Angular's automatic fixture teardown runs `ngOnDestroy` — which writes
+   * `mini-map` — after each one. Seed and clear it explicitly so the persisted
+   * flag under test is the one this test wrote, not the previous test's.
+   */
+  beforeEach(() => {
+    localStorage.removeItem("mini-map");
+  });
+
+  afterEach(() => {
+    localStorage.removeItem("mini-map");
+    editorStub?.remove();
+    editorStub = undefined;
+  });
 
   beforeEach(async () => {
     await TestBed.configureTestingModule({
@@ -49,13 +131,285 @@ describe("MiniMapComponent", () => {
     }).compileComponents();
   });
 
+  // The fixture is created but NOT change-detected here: ngAfterViewInit reads
+  // the mini-map container's size and the persisted flag, so each test sets 
its
+  // own environment up before triggering it.
   beforeEach(() => {
     fixture = TestBed.createComponent(MiniMapComponent);
-    TestBed.inject(WorkflowActionService);
-    fixture.detectChanges();
+    component = fixture.componentInstance;
+    workflowActionService = TestBed.inject(WorkflowActionService);
+    panelService = TestBed.inject(PanelService);
+
+    // Own the paper stream rather than casting the wrapper's getter back to a 
Subject.
+    // `getMainJointPaperAttachedStream()` is declared `Observable<Paper>`, so 
calling
+    // `.next()` on its result only works because it happens to be a 
ReplaySubject today;
+    // switching it to `.asObservable()` would break the spec silently. 
Stubbing the
+    // getter depends on the declared type only. ReplaySubject(1) so a paper 
attached
+    // before the component subscribes in ngAfterViewInit is still delivered.
+    mainPaper$ = new ReplaySubject<joint.dia.Paper>(1);
+    vi.spyOn(workflowActionService.getJointGraphWrapper(), 
"getMainJointPaperAttachedStream").mockReturnValue(
+      mainPaper$.asObservable()
+    );
   });
 
+  /** Gives the mini-map container a size, which jsdom otherwise reports as 0. 
*/
+  function sizeMiniMapContainer(width: number, height: number): HTMLElement {
+    const map = fixture.nativeElement.querySelector("#mini-map") as 
HTMLElement;
+    Object.defineProperty(map, "offsetWidth", { value: width, configurable: 
true });
+    Object.defineProperty(map, "offsetHeight", { value: height, configurable: 
true });
+    return map;
+  }
+
+  /**
+   * The mini-map reads the main editor's element out of the document by id, so
+   * mount a stand-in with an explicit size and viewport rect.
+   */
+  function mountWorkflowEditorStub(width: number, height: number, left: 
number, top: number): HTMLDivElement {
+    const editor = document.createElement("div");
+    editor.id = "workflow-editor";
+    Object.defineProperty(editor, "offsetWidth", { value: width, configurable: 
true });
+    Object.defineProperty(editor, "offsetHeight", { value: height, 
configurable: true });
+    editor.getBoundingClientRect = () => ({ left, top, right: left + width, 
bottom: top + height }) as DOMRect;
+    document.body.appendChild(editor);
+    editorStub = editor;
+    return editor;
+  }
+
+  /** Publishes `paper` on the stream the mini-map subscribes to in 
ngAfterViewInit. */
+  function attachMainPaper(paper: StubPaper): void {
+    mainPaper$.next(paper as unknown as joint.dia.Paper);
+  }
+
   it("should create", () => {
+    fixture.detectChanges();
     expect(fixture.componentInstance).toBeTruthy();
   });
+
+  describe("mini-map paper", () => {
+    it("fits the whole main canvas into the mini-map container", () => {
+      // 912 / (2688 - -960) == 0.25; the height (100) is deliberately 
different
+      // so a width/height mix-up in the scale formula cannot pass.
+      const map = sizeMiniMapContainer(912, 100);
+
+      fixture.detectChanges();
+
+      expect(MAIN_CANVAS.xMax - MAIN_CANVAS.xMin).toBe(3648);
+      expect(component.scale).toBe(0.25);
+      // The paper is sized to the container it renders into ...
+      expect(map.style.width).toBe("912px");
+      expect(map.style.height).toBe("100px");
+      // ... and shifted so the canvas' top-left corner (-960, -540) lands on 
the
+      // container's origin: 960 * 0.25 == 240, 540 * 0.25 == 135.
+      
expect(map.querySelector("g.joint-layers")?.getAttribute("transform")).toBe("matrix(0.25,0,0,0.25,240,135)");
+    });
+  });
+
+  describe("persisted hidden state", () => {
+    it("starts visible when nothing has been persisted", () => {
+      fixture.detectChanges();
+      expect(component.hidden).toBe(false);
+    });
+
+    it("restores the collapsed state persisted by the previous session", () => 
{
+      localStorage.setItem("mini-map", "true");
+
+      // `hidden` is assigned inside ngAfterViewInit, i.e. after the template
+      // that reads it has been checked, so the dev-mode verification pass 
would
+      // report NG0100 for a state the component legitimately restores. Skip 
it.
+      fixture.detectChanges(false);
+
+      expect(component.hidden).toBe(true);
+    });
+
+    it("persists the collapsed state on destroy and restores it into a fresh 
instance", () => {
+      fixture.detectChanges();
+      component.hidden = true;
+
+      fixture.destroy();
+
+      // Round-trip through the real storage key: a rename on either the write
+      // or the read side breaks this.
+      expect(localStorage.getItem("mini-map")).toBe("true");
+      const reopened = TestBed.createComponent(MiniMapComponent);
+      reopened.detectChanges(false);
+      expect(reopened.componentInstance.hidden).toBe(true);
+    });
+
+    it("persists the collapsed state when the window unloads", () => {
+      fixture.detectChanges();
+      component.hidden = true;
+
+      // @HostListener("window:beforeunload") — the tab can close without 
Angular
+      // ever destroying the component, so the flag has to be written here too.
+      window.dispatchEvent(new Event("beforeunload"));
+
+      expect(localStorage.getItem("mini-map")).toBe("true");
+    });
+  });
+
+  describe("panel service integration", () => {
+    it("hides on closePanels and shows again on resetPanels", () => {
+      fixture.detectChanges();
+      expect(component.hidden).toBe(false);
+
+      panelService.closePanels();
+      expect(component.hidden).toBe(true);
+
+      panelService.resetPanels();
+      expect(component.hidden).toBe(false);
+    });
+  });
+
+  describe("navigator overlay", () => {
+    const MINI_MAP_SCALE = 0.25;
+
+    /**
+     * Wires a stub main paper to a change-detected component and pins an
+     * explicit mini-map scale (ngAfterViewInit computes 0 under jsdom, which
+     * would make every "0px" assertion vacuously true).
+     */
+    function attachStubbedViewport(): { paper: StubPaper; navigator: 
HTMLElement } {
+      mountWorkflowEditorStub(800, 600, 30, 40);
+      fixture.detectChanges();
+      component.scale = MINI_MAP_SCALE;
+
+      const navigator = document.getElementById("mini-map-navigator") as 
HTMLElement;
+      // cdkDrag leaves a transform behind after a drag; the component must 
clear
+      // it before writing left/top, or the two offsets would stack.
+      navigator.style.transform = "translate3d(11px, 13px, 0)";
+
+      // sx and sy differ so a width/height mix-up cannot pass.
+      const paper = new StubPaper(2, 4);
+      paper.localPoint = { x: -160, y: -140 };
+      attachMainPaper(paper);
+
+      return { paper, navigator };
+    }
+
+    it("positions and sizes the navigator from the main paper's viewport", () 
=> {
+      const { paper, navigator } = attachStubbedViewport();
+
+      expect(component.paper).toBe(paper as unknown as joint.dia.Paper);
+      // The viewport origin is the editor's top-left corner in page 
coordinates.
+      expect(paper.pageToLocalPointArgs).toEqual([{ x: 30, y: 40 }]);
+      // (-160 - -960) * 0.25 and (-140 - -540) * 0.25
+      expect(navigator.style.left).toBe("200px");
+      expect(navigator.style.top).toBe("100px");
+      // (800 / 2) * 0.25 and (600 / 4) * 0.25
+      expect(navigator.style.width).toBe("100px");
+      expect(navigator.style.height).toBe("37.5px");
+      expect(navigator.style.transform).toBe("");
+    });
+
+    it("repositions the navigator when the main paper translates, scales or 
resizes", () => {
+      const { paper, navigator } = attachStubbedViewport();
+      expect(Object.keys(paper.handlers).sort()).toEqual(["resize", "scale", 
"translate"]);
+
+      const movements: [string, number, string][] = [
+        ["translate", -560, "100px"],
+        ["scale", -360, "150px"],
+        ["resize", -60, "225px"],
+      ];
+      for (const [event, localX, expectedLeft] of movements) {
+        paper.localPoint = { x: localX, y: -140 };
+        paper.handlers[event]();
+        expect(navigator.style.left).toBe(expectedLeft);
+      }
+    });
+
+    it("leaves the navigator alone while the user is dragging it", () => {
+      const { paper, navigator } = attachStubbedViewport();
+      expect(navigator.style.left).toBe("200px");
+
+      // The drag itself is already moving the navigator; echoing the paper's
+      // translate back onto it would fight the pointer.
+      component.dragging = true;
+      paper.localPoint = { x: -560, y: -140 };
+      paper.handlers["translate"]();
+
+      expect(navigator.style.left).toBe("200px");
+    });
+  });
+
+  describe("drag to pan", () => {
+    it("pans the main paper opposite the pointer, in main-canvas units", () => 
{
+      fixture.detectChanges();
+      const paper = new StubPaper();
+      paper.offset = { tx: 100, ty: 50 };
+      component.paper = paper as unknown as joint.dia.Paper;
+      component.scale = 0.25;
+
+      component.onDrag({ event: { movementX: 10, movementY: -20 } });
+
+      // A pointer delta on the mini-map is worth 1/scale as much on the main
+      // canvas, and the paper moves against the pointer.
+      expect(paper.translateArgs).toEqual([[100 - 40, 50 + 80]]);
+    });
+  });
+
+  describe("zoom buttons", () => {
+    let jointGraphWrapper: JointGraphWrapper;
+
+    beforeEach(() => {
+      fixture.detectChanges();
+      jointGraphWrapper = workflowActionService.getJointGraphWrapper();
+    });
+
+    it("zooms out by one click step", () => {
+      jointGraphWrapper.setZoomProperty(1);
+
+      component.onClickZoomOut();
+
+      expect(jointGraphWrapper.getZoomRatio()).toBeCloseTo(1 - 
JointGraphWrapper.ZOOM_CLICK_DIFF, 10);
+      expect(jointGraphWrapper.getZoomRatio()).toBeLessThan(1);
+    });
+
+    it("does not zoom out past the minimum ratio", () => {
+      jointGraphWrapper.setZoomProperty(JointGraphWrapper.ZOOM_MINIMUM);
+      const setZoomProperty = vi.spyOn(jointGraphWrapper, "setZoomProperty");
+
+      component.onClickZoomOut();
+
+      expect(setZoomProperty).not.toHaveBeenCalled();
+      
expect(jointGraphWrapper.getZoomRatio()).toBe(JointGraphWrapper.ZOOM_MINIMUM);
+    });
+
+    it("zooms in by one click step", () => {
+      jointGraphWrapper.setZoomProperty(1);
+
+      component.onClickZoomIn();
+
+      expect(jointGraphWrapper.getZoomRatio()).toBeCloseTo(1 + 
JointGraphWrapper.ZOOM_CLICK_DIFF, 10);
+      expect(jointGraphWrapper.getZoomRatio()).toBeGreaterThan(1);
+    });
+
+    it("does not zoom in past the maximum ratio", () => {
+      jointGraphWrapper.setZoomProperty(JointGraphWrapper.ZOOM_MAXIMUM);
+      const setZoomProperty = vi.spyOn(jointGraphWrapper, "setZoomProperty");
+
+      component.onClickZoomIn();
+
+      expect(setZoomProperty).not.toHaveBeenCalled();
+      
expect(jointGraphWrapper.getZoomRatio()).toBe(JointGraphWrapper.ZOOM_MAXIMUM);
+    });
+  });
+
+  describe("center button", () => {
+    it("broadcasts a center event and resets the navigator's drag offset", () 
=> {
+      fixture.detectChanges();
+      const centerEvents: void[] = [];
+      workflowActionService
+        .getTexeraGraph()
+        .getCenterEventStream()
+        .subscribe(event => centerEvents.push(event));
+      const reset = vi.spyOn(component.navigatorDrag, "reset");
+
+      component.triggerCenter();
+
+      expect(centerEvents).toHaveLength(1);
+      // Without the reset the navigator keeps the cdkDrag transform from the
+      // previous drag and lands off-centre.
+      expect(reset).toHaveBeenCalledTimes(1);
+    });
+  });
 });
diff --git 
a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts
 
b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts
index c60b06d368..43347dfdf1 100644
--- 
a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts
+++ 
b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts
@@ -26,6 +26,20 @@ import { NotebookMigrationService } from 
"../notebook-migration/notebook-migrati
 import { GuiConfigService } from "src/app/common/service/gui-config.service";
 import { firstValueFrom, of, throwError } from "rxjs";
 
+/**
+ * Regression coverage for the Jupyter notebook panel service: the per-workflow
+ * notebook fetch, the cell-to-operator highlight index, the iframe message
+ * bridge, and the feature-flag gate.
+ *
+ * Breakage this catches: reporting a notebook as present when Jupyter refused
+ * to load it (the toolbar would offer to expand a panel that has nothing in
+ * it); caching a failed Jupyter-origin lookup, which leaves the bridge dead 
for
+ * the rest of the session; dropping the guards that keep an unsaved workflow
+ * (no wid) or a workflow with no stored mapping from being looked up or posted
+ * to; leaving a previous cell's highlights on the canvas when an unmapped cell
+ * is clicked; and losing the feature-flag early-return in the window message
+ * listener, which is installed unconditionally in the constructor.
+ */
 describe("JupyterPanelService", () => {
   let service: JupyterPanelService;
   let httpMock: HttpTestingController;
@@ -95,6 +109,9 @@ describe("JupyterPanelService", () => {
 
   afterEach(() => {
     httpMock.verify();
+    // Several specs below silence console.error/warn to keep the failure paths
+    // quiet; console is a shared global, so put the originals back.
+    vi.restoreAllMocks();
   });
 
   // Panel visibility
@@ -227,6 +244,46 @@ describe("JupyterPanelService", () => {
     expect(await resultPromise).toBe(0);
   });
 
+  // The fetch pipeline has two distinct ways to yield 0 — Jupyter refusing the
+  // notebook, and the request itself failing — and the second one is a
+  // catchError that swallows *any* throw inside the switchMap. Pinning them
+  // apart (via sendNotebookToJupyter and the console.error the catchError
+  // emits) is what keeps a broken happy path from masquerading as "send
+  // failed": the base mock has no sendNotebookToJupyter, so a spec that 
forgets
+  // to define it gets a TypeError converted into the very same 0.
+  it("returns 0 when Jupyter rejects the notebook, without going through the 
error path", async () => {
+    mockNotebook.sendNotebookToJupyter = vi.fn().mockResolvedValue(0);
+    const consoleError = vi.spyOn(console, "error").mockImplementation(() => 
{});
+    const mapping = { cell_to_operator: { cell1: ["A"] }, operator_to_cell: {} 
};
+    const notebook = { cells: [] };
+
+    const resultPromise = firstValueFrom((service as 
any).fetchNotebookAndMapping(1, 1));
+    httpMock
+      .expectOne(r => 
r.url.includes("/notebook-migration/fetch-notebook-and-mapping"))
+      .flush({ exists: true, mapping, notebook });
+
+    expect(await resultPromise).toBe(0);
+    // The mapping is stored before the notebook is handed to Jupyter, ...
+    expect(mockNotebook.setMapping).toHaveBeenCalledWith("mapping_wid_1", 
mapping);
+    // ... and the 0 came from Jupyter's own answer, not from a thrown error.
+    expect(mockNotebook.sendNotebookToJupyter).toHaveBeenCalledWith(notebook);
+    expect(consoleError).not.toHaveBeenCalled();
+  });
+
+  it("returns 0 and logs when the fetch request fails", async () => {
+    mockNotebook.sendNotebookToJupyter = vi.fn().mockResolvedValue(1);
+    const consoleError = vi.spyOn(console, "error").mockImplementation(() => 
{});
+
+    const resultPromise = firstValueFrom((service as 
any).fetchNotebookAndMapping(1, 1));
+    httpMock
+      .expectOne(r => 
r.url.includes("/notebook-migration/fetch-notebook-and-mapping"))
+      .flush("migration service down", { status: 500, statusText: "Server 
Error" });
+
+    expect(await resultPromise).toBe(0);
+    expect(consoleError).toHaveBeenCalled();
+    expect(mockNotebook.sendNotebookToJupyter).not.toHaveBeenCalled();
+  });
+
   // jupyterNotebookExists$ starts false and flips true once init()'s fetch 
finds
   // a notebook for the workflow; the toolbar's expand button binds to this.
   it("sets jupyterNotebookExists$ true after a workflow's notebook is 
fetched", async () => {
@@ -244,6 +301,26 @@ describe("JupyterPanelService", () => {
     expect(states.at(-1)).toBe(true); // true once the notebook is found
   });
 
+  // The stored notebook exists but Jupyter refuses it: the toolbar must not
+  // advertise a notebook the panel cannot actually show.
+  it("leaves the panel closed when the stored notebook cannot be loaded into 
Jupyter", async () => {
+    mockNotebook.sendNotebookToJupyter = vi.fn().mockResolvedValue(0);
+    const exists: boolean[] = [];
+    const visible: boolean[] = [];
+    service.jupyterNotebookExists$.subscribe(v => exists.push(v));
+    service.jupyterNotebookPanelVisible$.subscribe(v => visible.push(v));
+
+    service.init();
+    httpMock
+      .expectOne(r => 
r.url.includes("/notebook-migration/fetch-notebook-and-mapping"))
+      .flush({ exists: true, mapping: { cell_to_operator: {}, 
operator_to_cell: {} }, notebook: {} });
+    await new Promise(resolve => setTimeout(resolve, 0));
+
+    expect(mockNotebook.sendNotebookToJupyter).toHaveBeenCalled();
+    expect(exists.at(-1)).toBe(false);
+    expect(visible.at(-1)).toBe(false);
+  });
+
   // init(): subscribes to workflow changes, drops the stale mapping for the
   // current workflow, and fetches the incoming workflow's notebook + mapping.
   it("init subscribes, drops the stale mapping, and fetches for the new 
workflow", () => {
@@ -311,6 +388,20 @@ describe("JupyterPanelService", () => {
     expect(mockWorkflow.highlightLinks).toHaveBeenCalledWith(true, "link1");
   });
 
+  // A cell that isn't in the index still has to clear the previous cell's
+  // highlights, otherwise clicking an unmapped cell leaves the old selection 
on
+  // the canvas.
+  it("clears existing highlights and highlights nothing for an unmapped cell", 
() => {
+    (service as any).cellToHighlightMapping = { cell1: { components: ["op1"], 
edges: ["link1"] } };
+
+    (service as any).highlightFromCell("unmappedCell");
+
+    expect(mockWorkflow.unhighlightOperators).toHaveBeenCalledWith("A", "B");
+    expect(mockWorkflow.unhighlightLinks).toHaveBeenCalledWith("L1");
+    expect(mockWorkflow.highlightOperators).not.toHaveBeenCalled();
+    expect(mockWorkflow.highlightLinks).not.toHaveBeenCalled();
+  });
+
   // handleNotebookMessage must only act on cellClicked messages that come from
   // our own iframe (event.source) AND carry the Jupyter origin.
   it("handleNotebookMessage highlights only for messages from the iframe at 
the Jupyter origin", async () => {
@@ -332,6 +423,27 @@ describe("JupyterPanelService", () => {
     expect(highlightSpy).toHaveBeenCalledWith("c1");
   });
 
+  // postMessage can deliver a payload-less event; destructuring it must not
+  // throw out of the (async, unawaited) listener.
+  it("handleNotebookMessage ignores a message that carries no payload", async 
() => {
+    const iframeWindow = {} as Window;
+    service.setIframeRef({ contentWindow: iframeWindow } as any);
+    const highlightSpy = vi.spyOn(service as any, 
"highlightFromCell").mockImplementation(() => {});
+
+    await (service as any).handleNotebookMessage({
+      source: iframeWindow,
+      origin: "http://jupyter";,
+      data: undefined,
+    });
+
+    // The awaited call above is what proves the payload destructuring 
survived the
+    // missing data: drop the `event.data ?? {}` fallback and the async 
handler rejects,
+    // failing this test before either assertion runs. These two then pin that 
the
+    // guards still passed (source + origin are ours) and that no highlight 
was issued.
+    expect(mockNotebook.getJupyterURL).toHaveBeenCalled();
+    expect(highlightSpy).not.toHaveBeenCalled();
+  });
+
   // A workflow with operators but no links is valid; precompute must still
   // record each cell's components (with empty edges) so cell clicks highlight.
   it("precomputes component mappings even when the graph has no links", () => {
@@ -376,6 +488,49 @@ describe("JupyterPanelService", () => {
     });
   });
 
+  // An unsaved workflow has no wid, so there is no mapping key to look up; the
+  // precompute must drop the stale index instead of querying 
"mapping_wid_undefined".
+  it("drops the highlight index without a lookup when the workflow has no 
wid", () => {
+    mockWorkflow.getWorkflow.mockReturnValue({ wid: undefined });
+    (service as any).cellToHighlightMapping = { stale: { components: ["X"], 
edges: [] } };
+    const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    (service as any).precomputeHighlightMapping();
+
+    expect(mockNotebook.getMapping).not.toHaveBeenCalled();
+    expect((service as any).cellToHighlightMapping).toEqual({});
+    expect(consoleWarn).toHaveBeenCalled();
+  });
+
+  // The notebook was found but its mapping was never stored locally: leave the
+  // index empty rather than dereferencing the missing mapping.
+  it("drops the highlight index when no mapping is stored for the workflow", 
() => {
+    mockNotebook.getMapping.mockReturnValue(undefined);
+    (service as any).cellToHighlightMapping = { stale: { components: ["X"], 
edges: [] } };
+    const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    (service as any).precomputeHighlightMapping();
+
+    expect(mockNotebook.getMapping).toHaveBeenCalledWith("mapping_wid_1");
+    expect((service as any).cellToHighlightMapping).toEqual({});
+    expect(consoleWarn).toHaveBeenCalled();
+  });
+
+  // A cell mapped to nothing must index as an empty component list; a raw
+  // undefined would blow up the later `.length` check in highlightFromCell.
+  it("indexes a cell with no mapped operators as an empty selection", () => {
+    mockNotebook.getMapping.mockReturnValue({
+      cell_to_operator: { emptyCell: null },
+      operator_to_cell: {},
+    });
+
+    (service as any).precomputeHighlightMapping();
+
+    expect((service as any).cellToHighlightMapping).toEqual({
+      emptyCell: { components: [], edges: [] },
+    });
+  });
+
   // onWorkflowComponentClick
   it("should postMessage when mapping exists", async () => {
     const mockIframe = {
@@ -437,6 +592,73 @@ describe("JupyterPanelService", () => {
     expect(mockNotebook.getJupyterURL).toHaveBeenCalledTimes(1);
   });
 
+  it("does not postMessage for an unsaved workflow (undefined wid)", async () 
=> {
+    const mockIframe = { contentWindow: { postMessage: vi.fn() } } as any;
+    service.setIframeRef(mockIframe);
+    mockWorkflow.getWorkflow.mockReturnValue({ wid: undefined });
+    const consoleError = vi.spyOn(console, "error").mockImplementation(() => 
{});
+
+    await service.onWorkflowComponentClick("op1");
+
+    // Bailing before the lookup keeps a "mapping_wid_undefined" key from 
being read.
+    expect(mockNotebook.getMapping).not.toHaveBeenCalled();
+    expect(mockIframe.contentWindow.postMessage).not.toHaveBeenCalled();
+    expect(consoleError).toHaveBeenCalled();
+  });
+
+  it("does not postMessage when the workflow has no stored mapping", async () 
=> {
+    const mockIframe = { contentWindow: { postMessage: vi.fn() } } as any;
+    service.setIframeRef(mockIframe);
+    mockNotebook.getMapping.mockReturnValue(undefined);
+    const consoleError = vi.spyOn(console, "error").mockImplementation(() => 
{});
+
+    await service.onWorkflowComponentClick("op1");
+
+    expect(mockNotebook.getMapping).toHaveBeenCalledWith("mapping_wid_1");
+    expect(mockIframe.contentWindow.postMessage).not.toHaveBeenCalled();
+    expect(consoleError).toHaveBeenCalled();
+  });
+
+  // The Jupyter pod may not be reachable yet when the first click lands. A
+  // failed lookup must NOT be cached, or the panel stays dead for the rest of
+  // the session.
+  it("retries the origin lookup after an unavailable Jupyter URL", async () => 
{
+    const mockIframe = { contentWindow: { postMessage: vi.fn() } } as any;
+    service.setIframeRef(mockIframe);
+    mockNotebook.getMapping.mockReturnValue({
+      cell_to_operator: {},
+      operator_to_cell: { op1: ["cell1"] },
+    });
+    mockNotebook.getJupyterURL.mockResolvedValueOnce(null);
+
+    await service.onWorkflowComponentClick("op1");
+    expect(mockIframe.contentWindow.postMessage).not.toHaveBeenCalled();
+
+    await service.onWorkflowComponentClick("op1");
+
+    expect(mockNotebook.getJupyterURL).toHaveBeenCalledTimes(2);
+    expect(mockIframe.contentWindow.postMessage).toHaveBeenCalledWith(
+      { action: "triggerCellClick", operators: ["cell1"] },
+      "http://jupyter";
+    );
+  });
+
+  it("treats a malformed Jupyter URL as unavailable and keeps retrying", async 
() => {
+    const mockIframe = { contentWindow: { postMessage: vi.fn() } } as any;
+    service.setIframeRef(mockIframe);
+    mockNotebook.getMapping.mockReturnValue({
+      cell_to_operator: {},
+      operator_to_cell: { op1: ["cell1"] },
+    });
+    mockNotebook.getJupyterURL.mockResolvedValue("://not-a-url");
+
+    await service.onWorkflowComponentClick("op1");
+    await service.onWorkflowComponentClick("op1");
+
+    expect(mockIframe.contentWindow.postMessage).not.toHaveBeenCalled();
+    expect(mockNotebook.getJupyterURL).toHaveBeenCalledTimes(2);
+  });
+
   // Feature flag gate (defence in depth). With the flag off, init must not
   // subscribe to workflow changes, and onWorkflowComponentClick must not
   // postMessage to the iframe. The window message listener is installed in
@@ -491,5 +713,22 @@ describe("JupyterPanelService", () => {
       await service.onWorkflowComponentClick("cell1");
       expect(mockIframe.contentWindow.postMessage).not.toHaveBeenCalled();
     });
+
+    it("handleNotebookMessage ignores a cellClicked message from our own 
iframe", async () => {
+      const iframeWindow = {} as Window;
+      service.setIframeRef({ contentWindow: iframeWindow } as any);
+      const highlightSpy = vi.spyOn(service as any, 
"highlightFromCell").mockImplementation(() => {});
+
+      await (service as any).handleNotebookMessage({
+        source: iframeWindow,
+        origin: "http://jupyter";,
+        data: { action: "cellClicked", cellUUID: "c1" },
+      });
+
+      expect(highlightSpy).not.toHaveBeenCalled();
+      // The message carried our own source and origin, so it was the flag 
check
+      // that stopped it — the origin was never even resolved.
+      expect(mockNotebook.getJupyterURL).not.toHaveBeenCalled();
+    });
   });
 });
diff --git a/frontend/src/app/workspace/service/preset/preset.service.spec.ts 
b/frontend/src/app/workspace/service/preset/preset.service.spec.ts
index b71c976515..51a24132a2 100644
--- a/frontend/src/app/workspace/service/preset/preset.service.spec.ts
+++ b/frontend/src/app/workspace/service/preset/preset.service.spec.ts
@@ -42,6 +42,17 @@ if (!ajvInstance.getKeyword("enable-presets")) {
   ajvInstance.addKeyword({ keyword: "enable-presets", schemaType: "boolean" });
 }
 
+/**
+ * Regression coverage for PresetService: the dictionary-backed read/write of
+ * operator presets, the toast the user sees for each write, preset validation
+ * against the operator's 'enable-presets' schema, and the static schema 
helpers.
+ *
+ * Breakage this catches: losing the empty-list default so the very first 
preset
+ * saved for an operator type parses `null` and throws; routing a save/delete
+ * toast to the wrong NzMessageService severity, or dropping the caller's
+ * message so the user sees the generic default; and accepting an operator
+ * schema that has no `properties` at all instead of rejecting it.
+ */
 describe("PresetService", () => {
   let userConfigStub: {
     fetchKey: ReturnType<typeof vi.fn>;
@@ -163,6 +174,37 @@ describe("PresetService", () => {
       expect(messageStub.error).not.toHaveBeenCalled();
     });
 
+    it("routes an explicitly passed message to the NzMessageService method for 
its severity", () => {
+      const severities = ["error", "info", "success", "warning"] as const;
+
+      for (const severity of severities) {
+        presetService.savePresets(
+          presetType,
+          presetTarget,
+          [{ presetProperty: "v1" }],
+          `saved as ${severity}`,
+          severity
+        );
+      }
+
+      for (const severity of severities) {
+        // exactly one toast per severity: a mis-wired case would show up here 
as
+        // a missing call on one method and a doubled call on another.
+        expect(messageStub[severity]).toHaveBeenCalledTimes(1);
+        expect(messageStub[severity]).toHaveBeenCalledWith(`saved as 
${severity}`);
+      }
+    });
+
+    it("deletePreset reports the caller's message as an error toast", () => {
+      userConfigStub.fetchKey.mockReturnValue(of(JSON.stringify([{ 
presetProperty: "v1" }, { presetProperty: "v2" }])));
+
+      // PresetWrapperComponent.deletePreset passes exactly this shape.
+      presetService.deletePreset(presetType, presetTarget, { presetProperty: 
"v1" }, "Deleted preset: v1", "error");
+
+      expect(messageStub.error).toHaveBeenCalledWith("Deleted preset: v1");
+      expect(messageStub.success).not.toHaveBeenCalled();
+    });
+
     it("createPreset appends to existing presets and writes back", () => {
       const existing: Preset[] = [{ presetProperty: "v1" }];
       userConfigStub.fetchKey.mockReturnValue(of(JSON.stringify(existing)));
@@ -175,6 +217,16 @@ describe("PresetService", () => {
       );
     });
 
+    it("createPreset stores the first preset when the dictionary has no entry 
yet", () => {
+      // The very first save for an operator type: fetchKey resolves to null, 
and
+      // the missing entry has to read as an empty list rather than being 
parsed.
+      userConfigStub.fetchKey.mockReturnValue(of(null));
+
+      presetService.createPreset(presetType, presetTarget, { presetProperty: 
"v1" });
+
+      expect(userConfigStub.set).toHaveBeenCalledWith(presetDictKey, 
JSON.stringify([{ presetProperty: "v1" }]));
+    });
+
     it("createPreset does not write the preset back when it already exists", 
async () => {
       userConfigStub.fetchKey.mockReturnValue(of(JSON.stringify([{ 
presetProperty: "v1" }])));
 
@@ -394,6 +446,14 @@ describe("PresetService", () => {
       ).toThrow();
     });
 
+    it("getOperatorPresetSchema throws when the operator schema omits 
properties entirely", () => {
+      // Distinct from the empty-`properties` case above, which fails the later
+      // "no preset properties" check instead.
+      expect(() => PresetService.getOperatorPresetSchema(<CustomJSONSchema7>{ 
type: "object" })).toThrow(
+        /has no properties$/
+      );
+    });
+
     it("getOperatorPresetSchema throws when no property is preset-enabled", () 
=> {
       expect(() =>
         PresetService.getOperatorPresetSchema(<CustomJSONSchema7>{

Reply via email to