You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/08/24 06:23:14 UTC

(texera) branch gh-readonly-queue/main/pr-7901-6c063e1c7122ebb4d9e57fbfcfa01be09adeac8a created (now 7738b6c45f)

This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-7901-6c063e1c7122ebb4d9e57fbfcfa01be09adeac8a
in repository https://gitbox.apache.org/repos/asf/texera.git


      at 7738b6c45f test(frontend): cover the email-request modal, landing page and user-venv (#7901)

This branch includes the following new commits:

     new 7738b6c45f test(frontend): cover the email-request modal, landing page and user-venv (#7901)

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(texera) 01/01: test(frontend): cover the email-request modal, landing page and user-venv (#7901)

Posted by gi...@apache.org.
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-7901-6c063e1c7122ebb4d9e57fbfcfa01be09adeac8a
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 7738b6c45ff42b9b1bc910cd492efb7f733ef72c
Author: Xinyuan Lin <xi...@uci.edu>
AuthorDate: Mon Aug 24 06:22:55 2026 +0000

    test(frontend): cover the email-request modal, landing page and user-venv (#7901)
    
    ### What changes were proposed in this PR?
    
    Three small templates. 57 tests → 70.
    
    Measured from `frontend/` with `ng test --coverage
    --coverage-reporters=lcovonly`, the same spec-file filter on both sides,
    `rm -rf coverage` between runs. lcov parsed with Codecov's model.
    
    | Template | Before | After |
    |---|---|---|
    | `email-request-modal.component.html` | 6/11 = 54.5%, branches **0/2**,
    functions **0/1** | **11/11 = 100%**, branches 2/2, functions 1/1 |
    | `landing-page.component.html` | 16/21 = 76.2%, functions **0/2** |
    **21/21 = 100%**, functions 2/2 |
    | `user-venv.component.html` | 66/71 = 93.0%, branches **3/12**,
    functions 8/12 | **71/71 = 100%**, branches 12/12, functions 12/12 |
    
    **Measured +15 lines. I claim +14, and the difference matters.** One
    line and one arm come only from an off-type `{name: null} as unknown as
    UserPveRecord` fixture — a state the schema forbids
    (`virtual_environments.name VARCHAR(128) NOT NULL`). That test is kept,
    but relabeled in the source as *documentation, not coverage*, because it
    records a real defect rather than earning a number. So: **+14 defensible
    lines, +10 arms, +7 functions.**
    
    Two of the wins were embarrassingly cheap once found: four lines from a
    single `createEmbeddedView` of the modal-title template, and five lines
    from the one `fixture.detectChanges()` that landing-page's 13-test spec
    never called.
    
    ### Verification
    
    21 mutations. The first draft reported no survivors; **nine mutants
    survived it** — the nz-option label exchange, both discard-path rewires,
    the operator select's hardcoded index, and more. All are now dead.
    
    Its framing was also wrong in a way worth naming: "100% branches" did
    not mean the arms were *constrained*. Both discard paths could be
    rewired to `saveEnvironment()` and stay green at 100%.
    
    ### Two reviewer suggestions were refused, one because it would have
    been a false positive
    
    - A suggested selector **would fail on the clean template**, so it was a
    mutant "kill" that proves nothing. Probed: it matches three inputs,
    because nz-select renders its own.
    - Another suggested asserting on rendered `nz-option-item` nodes. Those
    nodes **do not exist at all** in jsdom — probed with the panel open,
    `document.querySelectorAll('nz-option-item')` returns zero, because
    nz-option-container is a CDK virtual-scroll viewport and every element
    measures 0px. Label↔value correspondence can only be pinned by walking
    the options with the keyboard, which is what the test now does.
    
    A third suggestion offered two options; the one taken keeps the test and
    documents the defect, since deleting it would lose that record.
    
    ### Six survivors, all one class
    
    Five are static presentational copy no test observes — a placeholder, a
    dialog title, a tooltip, a popconfirm title, an `<h1>`. The line drawn
    is deliberate: rendered-effect attributes carrying interaction meaning
    are pinned; pure copy strings are not. The popconfirm's *behaviour* —
    first click only opens the popover, the row survives until confirm — is
    pinned.
    
    ### Reported, not pinned
    
    `UserVenvComponent.saveEnvironment()` calls `draft.name.trim()` where
    the name can be absent. Fixing that is a production change, so it is
    documented in the spec rather than asserted.
    
    Line 90's second arm in `user-venv.component.html` is an unreachable
    partial — a null guard on a column the DDL declares `NOT NULL`.
    
    Also recorded for whoever works here next: `selectedOperatorLabel` is
    only valid *after* the select's panel has been opened once, because
    ng-zorro resolves a pre-set model value against its options when the
    option list first registers.
    
    No production file is touched.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7900
    
    ### How was this PR tested?
    
    ```
    npx ng test --watch=false --include="**/email-request-modal.component.spec.ts" --include="**/landing-page.component.spec.ts" --include="**/user-venv.component.spec.ts"
    ```
    
    ```
     Test Files  3 passed (3)
          Tests  70 passed (70)
    ```
    
    Test counts verified two ways — the runner's output and `grep -c
    '^\s*it('` per file (7 / 16 / 47). `npx tsc -p src/tsconfig.spec.json
    --noEmit` exits 0 and `yarn format:ci` passes. `frontend/junit.xml` and
    `frontend/coverage/` are regenerated every run and are not committed.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 .../email-request-modal.component.spec.ts          |  54 ++++
 .../user/user-venv/user-venv.component.spec.ts     | 273 ++++++++++++++++++++-
 .../landing-page/landing-page.component.spec.ts    |  70 ++++++
 3 files changed, 394 insertions(+), 3 deletions(-)

diff --git a/frontend/src/app/common/service/user/email-request-modal/email-request-modal.component.spec.ts b/frontend/src/app/common/service/user/email-request-modal/email-request-modal.component.spec.ts
index 296c9ed097..8d2416126b 100644
--- a/frontend/src/app/common/service/user/email-request-modal/email-request-modal.component.spec.ts
+++ b/frontend/src/app/common/service/user/email-request-modal/email-request-modal.component.spec.ts
@@ -17,6 +17,7 @@
  * under the License.
  */
 
+import { ViewContainerRef } from "@angular/core";
 import { ComponentFixture, TestBed } from "@angular/core/testing";
 import { NZ_MODAL_DATA } from "ng-zorro-antd/modal";
 import { EmailRequestModalComponent } from "./email-request-modal.component";
@@ -61,4 +62,57 @@ describe("EmailRequestModalComponent", () => {
     const component = (await createFixture({ name: "Sofia" })).componentInstance;
     expect(component.getValues()).toEqual({ email: "" });
   });
+
+  // `modalTitle` is an <ng-template> handed to nz-modal as its title, so nothing
+  // renders it during a plain component render -- instantiate it explicitly.
+  it("renders the modal-title template with the label and the logo", async () => {
+    const fixture = await createFixture({ name: "Sofia Garcia" });
+    fixture.detectChanges();
+
+    const view = fixture.debugElement.injector
+      .get(ViewContainerRef)
+      .createEmbeddedView(fixture.componentInstance.modalTitle);
+    view.detectChanges();
+
+    const root = view.rootNodes.find((n: HTMLElement) => n.classList?.contains("email-modal-title"));
+    expect(root).toBeTruthy();
+    expect(root.querySelector("span")?.textContent?.trim()).toBe("One more thing");
+    const logo = root.querySelector("img.email-modal-logo") as HTMLImageElement;
+    expect(logo.getAttribute("src")).toBe("assets/logos/full_logo_small.png");
+    expect(logo.getAttribute("alt")).toBe("Texera logo");
+  });
+
+  /**
+   * The specs above assign `email` on the instance, which is the direction this form
+   * is never driven in: a real address is typed into the box and read back out through
+   * getValues(). Nothing pinned that the box writes back at all, nor that the
+   * explanatory paragraph names the signed-in user rather than echoing the address.
+   */
+  it("names the signed-in user and writes the typed address back through ngModel", async () => {
+    const fixture = await createFixture({ name: "Sofia Garcia" });
+    fixture.detectChanges();
+    const host = fixture.nativeElement as HTMLElement;
+
+    expect(host.querySelector("p")?.textContent).toContain("Sofia Garcia");
+    expect(host.querySelector("label")?.textContent?.trim()).toBe("Email");
+
+    const input = host.querySelector("input.email-modal-input") as HTMLInputElement;
+    expect(input.getAttribute("type")).toBe("email");
+    expect(input.getAttribute("placeholder")).toBe("you@university.edu");
+    // `nz-input` is the only directive on the one control this dialog has; dropping it
+    // leaves a bare unstyled box inside an ng-zorro modal, which nothing else observes.
+    expect(input.classList.contains("ant-input")).toBe(true);
+    // The field starts blank: it must not be pre-filled with the name it sits under.
+    expect(input.value).toBe("");
+
+    // Typed rather than assigned -- the [(ngModel)] write-back is the untested direction.
+    input.value = "hub-user@example.com";
+    input.dispatchEvent(new Event("input"));
+    fixture.detectChanges();
+
+    expect(fixture.componentInstance.email).toBe("hub-user@example.com");
+    expect(fixture.componentInstance.getValues()).toEqual({ email: "hub-user@example.com" });
+    // The paragraph still shows the name, so the two bindings are not crossed.
+    expect(host.querySelector("p")?.textContent).toContain("Sofia Garcia");
+  });
 });
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 dde83e2726..f47223f77e 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
@@ -24,7 +24,7 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations";
 import { DeleteOutline, FileAddOutline, PlusOutline } from "@ant-design/icons-angular/icons";
 import { NzIconModule } from "ng-zorro-antd/icon";
 import { ModalOptions, NzModalRef, NzModalService } from "ng-zorro-antd/modal";
-import { of, throwError } from "rxjs";
+import { BehaviorSubject, of, throwError } from "rxjs";
 
 import { UserVenvComponent } from "./user-venv.component";
 import { NotificationService } from "../../../../common/service/notification/notification.service";
@@ -485,6 +485,42 @@ describe("UserVenvComponent", () => {
       pveServiceSpy.listUserPves.mockReturnValue(of(records));
       fixture.detectChanges();
     };
+    /** Types `text` into `element` the way a user would, so [(ngModel)] writes back. */
+    const type = (element: HTMLInputElement, text: string): void => {
+      element.value = text;
+      element.dispatchEvent(new Event("input"));
+    };
+    /**
+     * Sends one keystroke to a single operator select. The option list is a cdk
+     * virtual-scroll viewport whose elements all measure 0px in jsdom, so no
+     * `.ant-select-item-option` is ever laid out to click: the keyboard is the only
+     * route -- and ng-zorro reads the legacy `keyCode`, which the KeyboardEvent
+     * constructor does not populate.
+     */
+    const pressOn = (select: HTMLElement, keyCode: number): void => {
+      const event = new KeyboardEvent("keydown", { bubbles: true });
+      Object.defineProperty(event, "keyCode", { get: () => keyCode });
+      q<HTMLElement>(select, "nz-select-top-control").dispatchEvent(event);
+      flushOverlay();
+    };
+    /** Opens one operator select's option panel. */
+    const openSelect = (select: HTMLElement): void => {
+      select.click();
+      flushOverlay();
+    };
+    /** Steps an already-open select down one option and commits it with Enter. */
+    const commitNextOption = (select: HTMLElement): void => {
+      pressOn(select, 40); // ArrowDown
+      pressOn(select, 13); // Enter commits
+    };
+    /**
+     * The label ng-zorro shows for the value one select holds. Only valid once the
+     * panel has been opened: ng-zorro resolves a pre-set value against its options
+     * when the option list first registers, and until then the box shows its
+     * placeholder instead of any label.
+     */
+    const selectedOperatorLabel = (select: HTMLElement): string | undefined =>
+      q<HTMLElement>(select, "nz-select-item.ant-select-selection-item").textContent?.trim();
 
     it("shows the empty-state message and no list when there are no environments", () => {
       seedList([]);
@@ -509,6 +545,9 @@ describe("UserVenvComponent", () => {
       const rows = fixture.debugElement.queryAll(By.css("li.python-env-page-item"));
       expect(rows.length).toBe(2);
       expect((fixture.nativeElement as HTMLElement).textContent).toContain("(unnamed)");
+      // The empty-state banner is the complementary arm of the same *ngIf pair: it must
+      // be gone, or a widened predicate would print "No environments yet" above the list.
+      expect((fixture.nativeElement as HTMLElement).querySelector(".python-env-page-empty")).toBeNull();
 
       rows[0].triggerEventHandler("click", {});
       expect(component.pveModalVisible).toBe(true);
@@ -539,6 +578,27 @@ describe("UserVenvComponent", () => {
       expect(o.querySelectorAll(".package-row").length).toBe(3);
       expect(o.querySelector(".add-btn button")).not.toBeNull();
       expect(o.querySelectorAll(".footer-all button").length).toBe(2);
+      // Save is the primary affordance and Close the secondary one. The footer helper
+      // finds buttons by label, so exchanging the two nzTypes would leave the discard
+      // button looking like the one to press and nothing would notice.
+      expect(footerButton(o, "Save").classList.contains("ant-btn-primary")).toBe(true);
+      expect(footerButton(o, "Close").classList.contains("ant-btn-primary")).toBe(false);
+
+      // The column headings, in order. Every other test finds the boxes by placeholder,
+      // so nothing observed the headings themselves: swapping the two labels would leave
+      // the Version column captioned "Package".
+      expect(
+        Array.from(o.querySelectorAll(".user-package-header-row .package-column-label")).map(e => e.textContent?.trim())
+      ).toEqual(["Package", "Version"]);
+
+      // ...and the columns those headings caption, in the same order. `nz-select` renders
+      // an `<input>` of its own (.ant-select-selection-search-input), so the three columns
+      // are identified by their wrapper `.field` rather than by counting inputs.
+      const dataRow = q<HTMLElement>(o, ".package-row:not(.user-package-header-row)");
+      const columns = Array.from(dataRow.querySelectorAll(".user-package-inputs > .field")).map(field =>
+        field.querySelector("nz-select") ? "operator" : field.querySelector("input")?.getAttribute("placeholder")
+      );
+      expect(columns).toEqual(["Package Name", "operator", "Package Version"]);
     });
 
     it("drives the modal package controls and the Save footer button through the DOM", () => {
@@ -563,20 +623,227 @@ describe("UserVenvComponent", () => {
       expect(pveServiceSpy.savePve).toHaveBeenCalledWith("envDrive", {});
     });
 
-    it("closes the modal from the footer Close button", () => {
+    /**
+     * The discard paths need the negative assertion as much as the positive one: a
+     * successful saveEnvironment() also clears the draft and hides the dialog, so
+     * "the modal closed" alone does not distinguish Close from Save. Without
+     * `savePve`/`updateUserPve` asserted un-called, dismissing the dialog could
+     * silently persist the edits and this suite would stay green.
+     */
+    it("closes the modal from the footer Close button without persisting anything", () => {
       fixture.detectChanges();
       const o = openModalWith({ name: "envClose", newPackages: [] });
       footerButton(o, "Close").click();
+      flushOverlay();
       expect(component.pveModalVisible).toBe(false);
       expect(component.currentDraft).toBeNull();
+      // The dialog must actually leave the DOM, not merely flip the field.
+      expect(document.querySelector(".footer-all")).toBeNull();
+      expect(pveServiceSpy.savePve).not.toHaveBeenCalled();
+      expect(pveServiceSpy.updateUserPve).not.toHaveBeenCalled();
     });
 
-    it("closes the modal on the nz-modal cancel (X / mask) output", () => {
+    it("closes the modal on the nz-modal cancel (X / mask) output without persisting anything", () => {
       fixture.detectChanges();
       openModalWith({ name: "envCancel", newPackages: [] });
       fixture.debugElement.query(By.css("nz-modal")).triggerEventHandler("nzOnCancel", null);
+      flushOverlay();
       expect(component.pveModalVisible).toBe(false);
       expect(component.currentDraft).toBeNull();
+      expect(document.querySelector(".footer-all")).toBeNull();
+      expect(pveServiceSpy.savePve).not.toHaveBeenCalled();
+      expect(pveServiceSpy.updateUserPve).not.toHaveBeenCalled();
+    });
+
+    /**
+     * Every test above sets the draft on the instance, so none of the [(ngModel)]
+     * boxes in the modal body had ever been typed into: the write-back direction of
+     * the name, package-name, version and operator controls was entirely unpinned,
+     * and a binding pointed at the wrong field would have gone unnoticed.
+     */
+    it("writes the typed environment name into the draft, enabling Save", () => {
+      fixture.detectChanges();
+      const o = openModalWith({ name: "   ", newPackages: [] });
+
+      // A whitespace-only name is the state showPveModal() leaves behind, and
+      // saveEnvironment() rejects it, so Save must not be offered yet.
+      expect(footerButton(o, "Save").disabled).toBe(true);
+
+      // With no packages the column-header row must not render at all.
+      expect(o.querySelectorAll(".package-row").length).toBe(0);
+      expect(o.querySelector(".user-package-header-row")).toBeNull();
+
+      const nameInput = q<HTMLInputElement>(o, ".ve-form input.fieldInput");
+      expect(nameInput.getAttribute("placeholder")).toBe("Environment Name");
+      type(nameInput, "envTyped");
+      flushOverlay();
+
+      expect(component.currentDraft?.name).toBe("envTyped");
+      expect(footerButton(overlay(), "Save").disabled).toBe(false);
+    });
+
+    it("writes the typed package name and version into the row", () => {
+      fixture.detectChanges();
+      const o = openModalWith({ name: "envRow", newPackages: [{ name: "", versionOp: "==", version: "" }] });
+
+      // Distinct values that could not be confused, so a name/version swap fails.
+      type(q<HTMLInputElement>(o, 'input[placeholder="Package Name"]'), "pandas");
+      type(q<HTMLInputElement>(o, 'input[placeholder="Package Version"]'), "2.5");
+      flushOverlay();
+
+      expect(component.currentDraft?.newPackages).toEqual([{ name: "pandas", versionOp: "==", version: "2.5" }]);
+      // The environment name is a different binding and must be untouched.
+      expect(component.currentDraft?.name).toBe("envRow");
+    });
+
+    it("picks a version operator from the select with the keyboard, showing the label it stored", () => {
+      fixture.detectChanges();
+      const o = openModalWith({ name: "envOp", newPackages: [{ name: "pandas", versionOp: "==", version: "2.5" }] });
+      const select = q<HTMLElement>(o, "nz-select");
+
+      // Each step pairs the value written to the model with the label the user reads
+      // back, walking all three options. Asserting the model alone pins the nzValue
+      // order but says nothing about nzLabel: exchanging two options' labels (values
+      // untouched) would show "==" in the box while ">=" went into the requirement
+      // string, which is the entire user-visible meaning of this control.
+      openSelect(select);
+      expect(selectedOperatorLabel(select)).toBe("==");
+
+      commitNextOption(select); // "==" -> ">="
+      expect(component.currentDraft?.newPackages[0].versionOp).toBe(">=");
+      expect(selectedOperatorLabel(select)).toBe(">=");
+
+      openSelect(select);
+      commitNextOption(select); // ">=" -> "<="
+      expect(component.currentDraft?.newPackages[0].versionOp).toBe("<=");
+      expect(selectedOperatorLabel(select)).toBe("<=");
+
+      footerButton(overlay(), "Save").click();
+      expect(pveServiceSpy.savePve).toHaveBeenCalledWith("envOp", { pandas: "<=2.5" });
+    });
+
+    it("opens and deletes the row that was clicked, not the first one", () => {
+      seedList([
+        { veid: 1, name: "envFirst", packages: {} },
+        { veid: 2, name: "envSecond", packages: { numpy: "==1.0" } },
+      ] as UserPveRecord[]);
+      const rows = fixture.debugElement.queryAll(By.css("li.python-env-page-item"));
+      expect(rows.length).toBe(2);
+
+      // Clicking the second row must open the second environment, not index 0.
+      rows[1].triggerEventHandler("click", {});
+      expect(component.currentDraft?.veid).toBe(2);
+      expect(component.currentDraft?.name).toBe("envSecond");
+      component.closePveModal();
+      flushOverlay();
+
+      // The second row's delete icon must target the second environment.
+      const icons = fixture.debugElement.queryAll(By.css(".python-env-delete-icon"));
+      expect(icons.length).toBe(2);
+      icons[1].triggerEventHandler("click", { stopPropagation: vi.fn() });
+      expect(capturedConfirmConfig?.nzTitle).toBe('Delete environment "envSecond"?');
+      (capturedConfirmConfig?.nzOnOk as () => void)();
+      expect(pveServiceSpy.deleteUserPve).toHaveBeenCalledWith(2);
+    });
+
+    it("reuses the row element for an unchanged veid across a refresh (trackBy)", () => {
+      const feed = new BehaviorSubject<UserPveRecord[]>([
+        { veid: 1, name: "envKeep", packages: {} },
+        { veid: 2, name: "envAlso", packages: {} },
+      ] as UserPveRecord[]);
+      pveServiceSpy.listUserPves.mockReturnValue(feed);
+      fixture.detectChanges();
+      const before = fixture.debugElement.queryAll(By.css("li.python-env-page-item"))[0].nativeElement;
+
+      // Fresh record objects, same veids: trackByVeid must keep the existing DOM node.
+      feed.next([
+        { veid: 1, name: "envKeep", packages: {} },
+        { veid: 2, name: "envAlso", packages: {} },
+      ] as UserPveRecord[]);
+      flushOverlay();
+      const after = fixture.debugElement.queryAll(By.css("li.python-env-page-item"))[0].nativeElement;
+      expect(after).toBe(before);
+    });
+
+    it("writes each package row through its own index, and removes the row that was confirmed", () => {
+      fixture.detectChanges();
+      const o = openModalWith({
+        name: "envRows",
+        newPackages: [
+          { name: "first", versionOp: "==", version: "1" },
+          { name: "", versionOp: "==", version: "" },
+        ],
+      });
+
+      const nameBoxes = o.querySelectorAll<HTMLInputElement>('input[placeholder="Package Name"]');
+      const versionBoxes = o.querySelectorAll<HTMLInputElement>('input[placeholder="Package Version"]');
+      expect(nameBoxes.length).toBe(2);
+      // Typing in the SECOND row must not land in the first.
+      type(nameBoxes[1], "second");
+      type(versionBoxes[1], "2");
+      flushOverlay();
+      expect(component.currentDraft?.newPackages).toEqual([
+        { name: "first", versionOp: "==", version: "1" },
+        { name: "second", versionOp: "==", version: "2" },
+      ]);
+
+      // The operator select is the one control whose per-index binding a single-row
+      // draft cannot check, because there index 0 and a hardcoded [0] agree. Drive the
+      // SECOND row's select; the first row must not move, in the model or on screen.
+      const selects = o.querySelectorAll<HTMLElement>(".package-row:not(.user-package-header-row) nz-select");
+      expect(selects.length).toBe(2);
+      openSelect(selects[1]);
+      commitNextOption(selects[1]); // "==" -> ">=" on the second row only
+      expect(component.currentDraft?.newPackages[1].versionOp).toBe(">=");
+      expect(selectedOperatorLabel(selects[1])).toBe(">=");
+      // The row the user did not touch keeps its own operator: a select bound to a
+      // hardcoded row index instead of `i` fails here.
+      expect(component.currentDraft?.newPackages[0].versionOp).toBe("==");
+
+      // Confirming the SECOND row's delete must remove that row, not the first.
+      const removeButtons = o.querySelectorAll<HTMLButtonElement>(".package-row .user-package-inputs button");
+      expect(removeButtons.length).toBe(2);
+      removeButtons[1].click();
+      flushOverlay();
+      q<HTMLButtonElement>(overlay(), ".ant-popover-buttons button.ant-btn-primary").click();
+      flushOverlay();
+      expect(component.currentDraft?.newPackages).toEqual([{ name: "first", versionOp: "==", version: "1" }]);
+    });
+
+    it("shows the Save button's loading state while a save is in flight", () => {
+      fixture.detectChanges();
+      const o = openModalWith({ name: "envSaving", newPackages: [] });
+      expect(footerButton(o, "Save").classList.contains("ant-btn-loading")).toBe(false);
+      component.saving = true;
+      flushOverlay();
+      expect(footerButton(overlay(), "Save").classList.contains("ant-btn-loading")).toBe(true);
+    });
+
+    /**
+     * DOCUMENTATION, NOT COVERAGE. `saveEnvironment()` (user-venv.component.ts:173) calls
+     * `draft.name.trim()` with no guard, so the `?.` chain in the Save button's
+     * `[disabled]` is the only thing standing between a name-less draft and a TypeError
+     * -- the template defends against a state the class does not. This test pins the
+     * defence, which is the correct behaviour; it deliberately does NOT assert the
+     * TypeError, which would cement the defect.
+     *
+     * The state it needs is unreachable through the product: `virtual_environments.name`
+     * is `VARCHAR(128) NOT NULL` (sql/texera_ddl.sql:279) and `UserPveRecord.name` is typed
+     * `string`, hence the off-type cast below. So the branch arm this reaches is an
+     * unreachable partial -- a null guard on a non-nullable value -- and the line and
+     * arm it moves are excluded from this bundle's claimed coverage gain. Everything
+     * else about that line (the trim, disabled-vs-enabled) is already pinned by the
+     * whitespace-name test above.
+     */
+    it("keeps Save disabled when the stored environment name is null", () => {
+      seedList([{ veid: 1, name: null, packages: {} } as unknown as UserPveRecord]);
+      fixture.debugElement.query(By.css("li.python-env-page-item")).triggerEventHandler("click", {});
+      flushOverlay();
+
+      expect(component.currentDraft?.name).toBeNull();
+      expect(footerButton(overlay(), "Save").disabled).toBe(true);
+      // Close stays available, so the user is not trapped in the dialog.
+      expect(footerButton(overlay(), "Close").disabled).toBe(false);
     });
   });
 });
diff --git a/frontend/src/app/hub/component/landing-page/landing-page.component.spec.ts b/frontend/src/app/hub/component/landing-page/landing-page.component.spec.ts
index 3b968874b3..d6822ce2db 100644
--- a/frontend/src/app/hub/component/landing-page/landing-page.component.spec.ts
+++ b/frontend/src/app/hub/component/landing-page/landing-page.component.spec.ts
@@ -18,12 +18,15 @@
  */
 
 import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
 import { Router } from "@angular/router";
 import { RouterTestingModule } from "@angular/router/testing";
 import { of, throwError } from "rxjs";
 import { vi } from "vitest";
 
 import { LandingPageComponent } from "./landing-page.component";
+import { BrowseSectionComponent } from "../browse-section/browse-section.component";
+import { DashboardEntry } from "../../../dashboard/type/dashboard-entry";
 import { ActionType, EntityType, HubService } from "../../service/hub.service";
 import { SearchService } from "../../../dashboard/service/user/search.service";
 import { UserService } from "../../../common/service/user/user.service";
@@ -239,4 +242,71 @@ describe("LandingPageComponent", () => {
     expect(component.topClonedWorkflows).toEqual([]);
     expect(component.topLovedDatasets).toEqual([]);
   });
+
+  /**
+   * Everything above drives the class directly, and no test in this spec ever called
+   * fixture.detectChanges(), so the template only ever ran its create block: the two
+   * counts, the two link handlers and the three browse-section inputs were all
+   * unexercised. A swapped or deleted binding here is invisible to the tests above.
+   */
+  describe("rendered template", () => {
+    /** The two intro anchors, in template order: workflows then datasets. */
+    function links() {
+      const found = fixture.debugElement.queryAll(By.css("a"));
+      expect(found.length).toBe(2);
+      return found;
+    }
+
+    it("shows the workflow and dataset counts, each in its own link", () => {
+      build();
+      fixture.detectChanges(); // ngOnInit -> getWorkflowCount
+
+      // The two counts differ (42 vs 7), so a swapped interpolation cannot pass.
+      expect(links()[0].nativeElement.textContent.trim()).toBe("42 workflows");
+      expect(links()[1].nativeElement.textContent.trim()).toBe("7 datasets");
+    });
+
+    it("routes to the workflow hub from the first link and the dataset hub from the second", () => {
+      build();
+      fixture.detectChanges();
+
+      links()[0].triggerEventHandler("click", {});
+      expect(routerNavigateSpy).toHaveBeenLastCalledWith([HUB_WORKFLOW_RESULT]);
+
+      links()[1].triggerEventHandler("click", {});
+      expect(routerNavigateSpy).toHaveBeenLastCalledWith([HUB_DATASET_RESULT]);
+    });
+
+    it("hands each browse section its own entity list, title and viewer id", () => {
+      build();
+      // Three separate array instances, asserted by identity below, so a swapped
+      // [entities] binding cannot pass a deep-equality check on empty lists.
+      const loved: DashboardEntry[] = [];
+      const cloned: DashboardEntry[] = [];
+      const lovedDatasets: DashboardEntry[] = [];
+      component.topLovedWorkflows = loved;
+      component.topClonedWorkflows = cloned;
+      component.topLovedDatasets = lovedDatasets;
+
+      fixture.detectChanges();
+
+      const sections = fixture.debugElement
+        .queryAll(By.directive(BrowseSectionComponent))
+        .map(d => d.componentInstance as BrowseSectionComponent);
+      expect(sections.length).toBe(3);
+      expect(sections.map(s => s.sectionTitle)).toEqual([
+        "Top Loved Workflows",
+        "Top Cloned Workflows",
+        "Top Loved Datasets",
+      ]);
+      expect(sections[0].entities).toBe(loved);
+      expect(sections[1].entities).toBe(cloned);
+      expect(sections[2].entities).toBe(lovedDatasets);
+      expect(sections.map(s => s.currentUid)).toEqual([
+        component.currentUid,
+        component.currentUid,
+        component.currentUid,
+      ]);
+    });
+  });
 });