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-7967-c4f5389ebd9182f55674eb329f6e41f3b2a0dc1c in repository https://gitbox.apache.org/repos/asf/texera.git
commit f236751dd601159bb14567ef4fe2509803cda809 Author: Meng Wang <[email protected]> AuthorDate: Tue Aug 25 22:26:14 2026 +0000 test(frontend): render the login form's sign-up mode and provider flags (#7967) ### What changes were proposed in this PR? Renders the login card, which the existing 23 `it()` blocks almost never did — they called `detectChanges()` twice. 13 new tests. Measured locally with `--coverage --coverage-reporters=lcovonly`: | `texera-login.component.html` | Before | After | | --- | --- | --- | | lines | 44/57 (77.19 %) | **57/57 (100 %)** | | branches | 11/24 | **24/24** | | handlers | 0/4 | **4/4** | - **Provider flags** — all four combinations of `localLogin` × `googleLogin`, since `localLogin` is read twice (the tab strip and the form) and the "or continue with" divider is gated on both. The all-off case also asserts the brand and footer still render, so the card is never blank. Each test sets both flags explicitly rather than leaning on the mock's defaults. - **Sign-in / sign-up mode** — the tab strip drives `mode` in both directions via `(nzSelectedIndexChange)`, and `[nzSelectedIndex]` follows `mode` back; the two sign-up-only input groups (`email`, `confirm`), the password-policy hint and the submit label are asserted present in sign-up and absent in sign-in. - **Password visibility** — the suffix template is shared by both password groups, so a single toggle has to flip both inputs and both icons. Covered by click and by `keydown.enter`, plus toggling back. - **Form submission** — through the form's `(ngSubmit)` binding rather than by calling `submit()`. The icon assertion reads the icon name off `NzIconDirective`'s own field: the directive declares `nzType` as a setter with no getter, so reading the input back yields `undefined`. No production code was changed. ### Any related issues, documentation, discussions? Closes #7962. ### How was this PR tested? `ng test --watch=false --include src/app/hub/component/login/texera-login.component.spec.ts` — 36 passed (23 before, 13 new), repeated 3× for stability. `yarn format:ci` clean. Failure path verified by breaking one assertion in each of the 13 new tests: 13 failed / 23 passed, non-zero exit, then restored to green. Running the whole `hub/**` folder surfaces one failure in `hub-search-result.component.spec.ts` (`setViewMode … persists to localStorage`), which reproduces on an unmodified checkout of `main` and is unrelated to this change. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --- .../component/login/texera-login.component.spec.ts | 172 +++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/frontend/src/app/hub/component/login/texera-login.component.spec.ts b/frontend/src/app/hub/component/login/texera-login.component.spec.ts index c17b595c70..8e2c172708 100644 --- a/frontend/src/app/hub/component/login/texera-login.component.spec.ts +++ b/frontend/src/app/hub/component/login/texera-login.component.spec.ts @@ -31,6 +31,9 @@ import { GuiConfigService } from "../../../common/service/gui-config.service"; import { MockGuiConfigService } from "../../../common/service/gui-config.service.mock"; import { commonTestProviders } from "../../../common/testing/test-utils"; import { USER_WORKFLOW } from "../../../app-routing.constant"; +import { By } from "@angular/platform-browser"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzTabsComponent } from "ng-zorro-antd/tabs"; describe("TexeraLoginComponent", () => { let component: TexeraLoginComponent; @@ -306,4 +309,173 @@ describe("TexeraLoginComponent", () => { expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); }); }); + + // ────────────────────────────────────────────────────────────────────────── + // Template rendering + // + // The suite above drives the class; these render the card. Each test sets both + // provider flags explicitly so nothing is inherited from the mock's defaults. + // ────────────────────────────────────────────────────────────────────────── + describe("template", () => { + function render(flags: { localLogin: boolean; googleLogin: boolean }): void { + (TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig(flags); + fixture.detectChanges(); + } + + const host = (): HTMLElement => fixture.nativeElement as HTMLElement; + const input = (name: string): HTMLInputElement | null => + host().querySelector<HTMLInputElement>(`input[formcontrolname="${name}"]`); + const submitButton = (): HTMLElement | null => host().querySelector("form button[type='submit']"); + const passwordIcons = () => fixture.debugElement.queryAll(By.css("nz-icon.ant-input-password-icon")); + // NzIconDirective declares nzType as a setter with no getter, so read the icon name + // off the base directive's own field rather than the input. + const iconTypes = (): string[] => + passwordIcons().map(icon => (icon.injector.get(NzIconDirective) as unknown as { type: string }).type); + + describe("provider flags", () => { + it("renders the local form and the google button when both are enabled", () => { + render({ localLogin: true, googleLogin: true }); + + expect(host().querySelector("nz-tabs")).toBeTruthy(); + expect(host().querySelector("form")).toBeTruthy(); + expect(host().querySelector("asl-google-signin-button")).toBeTruthy(); + // The "or continue with" divider only makes sense when both are offered. + expect(host().querySelector("nz-divider")).toBeTruthy(); + }); + + it("drops the google button but keeps the form when only local login is enabled", () => { + render({ localLogin: true, googleLogin: false }); + + expect(host().querySelector("nz-tabs")).toBeTruthy(); + expect(host().querySelector("form")).toBeTruthy(); + expect(host().querySelector("asl-google-signin-button")).toBeNull(); + expect(host().querySelector("nz-divider")).toBeNull(); + }); + + it("drops the tabs and the form but keeps the google button when only google is enabled", () => { + render({ localLogin: false, googleLogin: true }); + + expect(host().querySelector("nz-tabs")).toBeNull(); + expect(host().querySelector("form")).toBeNull(); + expect(host().querySelector("asl-google-signin-button")).toBeTruthy(); + expect(host().querySelector("nz-divider")).toBeNull(); + }); + + it("renders neither sign-in path when both are disabled", () => { + render({ localLogin: false, googleLogin: false }); + + expect(host().querySelector("nz-tabs")).toBeNull(); + expect(host().querySelector("form")).toBeNull(); + expect(host().querySelector("asl-google-signin-button")).toBeNull(); + expect(host().querySelector("nz-divider")).toBeNull(); + // The brand and footer are outside every flag, so the card is never empty. + expect(host().querySelector(".brand")).toBeTruthy(); + expect(host().querySelector("p.foot")).toBeTruthy(); + }); + }); + + describe("sign-in / sign-up mode", () => { + beforeEach(() => render({ localLogin: true, googleLogin: true })); + + it("shows only the sign-in fields by default", () => { + expect(component.mode).toBe("signin"); + expect(input("username")).toBeTruthy(); + expect(input("password")).toBeTruthy(); + expect(input("email")).toBeNull(); + expect(input("confirm")).toBeNull(); + expect(host().querySelector("p.hint")).toBeNull(); + expect(submitButton()?.textContent?.trim()).toBe("Sign in"); + }); + + it("adds the email, confirm and password-policy hint in sign-up mode", () => { + component.setMode("signup"); + fixture.detectChanges(); + + expect(input("email")).toBeTruthy(); + expect(input("confirm")).toBeTruthy(); + expect(host().querySelector("p.hint")?.textContent?.replace(/\s+/g, " ").trim()).toBe( + "Password must be at least 6 characters. After registering, contact the Texera administrator to activate your account." + ); + expect(submitButton()?.textContent?.trim()).toBe("Sign up"); + }); + + it("switches mode from the tab strip in both directions", () => { + const tabs = fixture.debugElement.query(By.css("nz-tabs")); + + tabs.triggerEventHandler("nzSelectedIndexChange", 1); + fixture.detectChanges(); + expect(component.mode).toBe("signup"); + expect(input("confirm")).toBeTruthy(); + + tabs.triggerEventHandler("nzSelectedIndexChange", 0); + fixture.detectChanges(); + expect(component.mode).toBe("signin"); + expect(input("confirm")).toBeNull(); + }); + + it("binds the selected tab to the current mode", () => { + const selectedIndex = () => + fixture.debugElement.query(By.directive(NzTabsComponent)).componentInstance.nzSelectedIndex; + + expect(selectedIndex()).toBe(0); + + component.setMode("signup"); + fixture.detectChanges(); + expect(selectedIndex()).toBe(1); + }); + + it("submits the form through its ngSubmit binding", () => { + const submitSpy = vi.spyOn(component, "submit").mockImplementation(() => {}); + + fixture.debugElement.query(By.css("form")).triggerEventHandler("ngSubmit", new Event("submit")); + + expect(submitSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("password visibility", () => { + beforeEach(() => { + render({ localLogin: true, googleLogin: true }); + component.setMode("signup"); + fixture.detectChanges(); + }); + + it("starts hidden on both password fields", () => { + expect(input("password")?.type).toBe("password"); + expect(input("confirm")?.type).toBe("password"); + // The suffix template is reused by both groups, so both icons render. + expect(passwordIcons().length).toBe(2); + expect(iconTypes()).toEqual(["eye-invisible", "eye-invisible"]); + }); + + it("reveals both password fields when the toggle is clicked", () => { + passwordIcons()[0].triggerEventHandler("click", new MouseEvent("click")); + fixture.detectChanges(); + + expect(component.passwordVisible).toBe(true); + expect(input("password")?.type).toBe("text"); + expect(input("confirm")?.type).toBe("text"); + expect(iconTypes()).toEqual(["eye", "eye"]); + }); + + it("also toggles from the keyboard, so the control is reachable without a mouse", () => { + passwordIcons()[0].triggerEventHandler("keydown.enter", new KeyboardEvent("keydown", { key: "Enter" })); + fixture.detectChanges(); + + expect(component.passwordVisible).toBe(true); + expect(input("password")?.type).toBe("text"); + }); + + it("hides them again on a second click", () => { + passwordIcons()[0].triggerEventHandler("click", new MouseEvent("click")); + fixture.detectChanges(); + passwordIcons()[0].triggerEventHandler("click", new MouseEvent("click")); + fixture.detectChanges(); + + expect(component.passwordVisible).toBe(false); + expect(input("password")?.type).toBe("password"); + expect(input("confirm")?.type).toBe("password"); + }); + }); + }); });
