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-7418-96ed553760a299f2031f4cd83c024e9327cb5346 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 35346629ce00493927556df92fc839cb79b78dfa Author: Meng Wang <[email protected]> AuthorDate: Sat Aug 8 04:52:20 2026 -0700 test(frontend): extend UserProjectListItemComponent template coverage (#7418) ### What changes were proposed in this PR? Rebased onto `main` after #7415 landed on the same file, and reduced to what that PR left uncovered. #7415 pins what each template branch *looks like* by putting the component into the state directly; this PR pins the wiring that produces those states, so a control losing its handler fails here. Together they take `user-project-list-item.component.html` to 100% (110/110 statements, no uncovered branches — it was 77/110 after #7415). 8 new tests: - **colour panel** — the `[(colorPicker)]` / `(colorPickerSelect)` outputs, and the `cpExtraTemplate` menu, whose markup exists only while the picker is open: its Save action, and its Delete action in both states (disabled while the project has no colour, enabled and wired once one is set). None of this was previously rendered — it is the bulk of the gap #7410 describes. - **name** — the edit button opens the input, `keyup.enter` saves, `focusout` closes. - **description** — the expand/collapse controls, the edit button, `focusout` saving, and the suffix save icon closing the editor. - **actions** — the share button and the delete popconfirm's `nzOnConfirm`. - three class-level gaps: the `entry` getter's guard, `ngOnInit` adopting a stored colour, and `updateProjectColor` skipping the service when the colour is unchanged (class 63/67 -> 66/67). This block queries with `By.css` + `triggerEventHandler` rather than `querySelector`: `nzOnConfirm`, `keyup.enter` and `colorPickerSelect` are directive outputs, not DOM events, so a native dispatch cannot reach them. #7415's `MarkdownModule.forRoot()` is kept as-is. No production code was changed. Tests that #7415 already covers (the creation date, the `editable` gating, the read-only delete branch) were dropped from this PR rather than duplicated. One statement stays uncovered: the `if (!this.entry) throw` guard inside `saveProjectName`'s subscribe. The `entry` getter already throws when no entry was provided, so that branch cannot be reached — it is dead code rather than a coverage gap, and removing it felt out of scope for a test-only PR. ### Any related issues, documentation, discussions? Closes #7410. Builds on #7415 (#7412), which covers the same template from the rendering side. ### How was this PR tested? `ng test --watch=false --include src/app/dashboard/component/user/user-project/user-project-list-item/user-project-list-item.component.spec.ts` — 28 passed (20 existing + 8 new), run 3x for determinism. Coverage (`--coverage`) confirms `user-project-list-item.component.html` at 110/110 statements with no uncovered branches, and the class at 66/67. The failure path was verified by breaking an assertion (red, non-zero exit); eslint and prettier are clean. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8 [1M context]) --- .../user-project-list-item.component.spec.ts | 167 +++++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/frontend/src/app/dashboard/component/user/user-project/user-project-list-item/user-project-list-item.component.spec.ts b/frontend/src/app/dashboard/component/user/user-project/user-project-list-item/user-project-list-item.component.spec.ts index f2198854b1..58c57b18af 100644 --- a/frontend/src/app/dashboard/component/user/user-project/user-project-list-item/user-project-list-item.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-project/user-project-list-item/user-project-list-item.component.spec.ts @@ -32,6 +32,8 @@ import { UserService } from "../../../../../common/service/user/user.service"; import { commonTestProviders } from "../../../../../common/testing/test-utils"; import { ShareAccessComponent } from "../../share-access/share-access.component"; import { DatePipe } from "@angular/common"; +import { By } from "@angular/platform-browser"; +import { ColorPickerDirective } from "ngx-color-picker"; import { of } from "rxjs"; import { MarkdownModule } from "ngx-markdown"; @@ -131,6 +133,16 @@ describe("UserProjectListItemComponent", () => { expect(spy).not.toHaveBeenCalled(); }); + it("does not call the service when the color is unchanged", () => { + const spy = vi.spyOn(userProjectService, "updateProjectColor"); + component.entry = { ...component.entry, color: "123456" }; + component.color = "#123456"; + + component.updateProjectColor(); + + expect(spy).not.toHaveBeenCalled(); + }); + it("is a no-op when the item is not editable", () => { const spy = vi.spyOn(userProjectService, "updateProjectColor"); component.editable = false; @@ -332,4 +344,159 @@ describe("UserProjectListItemComponent", () => { expect(hostFixture.nativeElement.querySelector(".ant-input-clear-icon")).not.toBeNull(); }); }); + + /** + * The block above pins what each branch looks like by putting the component into that state + * directly. What is left unpinned is the wiring in between: the controls that produce those + * states, the outputs the parent listens to, and the colour menu, whose markup only exists + * while the picker is open. These go through the DOM so a control losing its handler fails + * here. Events that ng-zorro / the colour picker raise as directive outputs rather than DOM + * events (`nzOnConfirm`, `keyup.enter`, `colorPickerSelect`) need DebugElement, so this block + * queries with `By.css` instead of `querySelector`. + */ + describe("rendered controls", () => { + // Name what was missing, so a markup change fails with the element it could not find rather + // than "Cannot read properties of undefined". + const required = <T>(value: T | null | undefined, what: string): T => { + if (value === null || value === undefined) { + throw new Error(`expected ${what} to be rendered`); + } + return value; + }; + const q = (selector: string) => hostFixture.debugElement.query(By.css(selector)); + const buttonLabelled = (label: string) => + required( + hostFixture.debugElement + .queryAll(By.css("button")) + .find(button => (button.nativeElement.textContent ?? "").trim() === label), + `a button labelled "${label}"` + ); + + // The colour menu lives in `cpExtraTemplate`, so it exists only once the picker is open, + // which `[(cpToggle)]="editingColor"` drives. + const openColorPanel = () => { + component.editingColor = true; + hostFixture.detectChanges(); + }; + + afterEach(() => { + // An open colour picker keeps document-level listeners; destroying the view tears them + // down so a panel cannot outlive its test. + hostFixture.destroy(); + }); + + it("wires the colour-picker outputs to the component", () => { + const updateSpy = vi.spyOn(component, "updateProjectColor").mockImplementation(() => {}); + const picker = hostFixture.debugElement.query(By.directive(ColorPickerDirective)); + + picker.triggerEventHandler("colorPickerChange", "#abcdef"); + picker.triggerEventHandler("colorPickerSelect", "#abcdef"); + + expect(component.color).toBe("#abcdef"); + expect(updateSpy).toHaveBeenCalled(); + }); + + it("wires the colour menu's Save action", () => { + const updateSpy = vi.spyOn(component, "updateProjectColor").mockImplementation(() => {}); + + openColorPanel(); + buttonLabelled("Save").triggerEventHandler("click", null); + + expect(updateSpy).toHaveBeenCalled(); + }); + + it("enables the colour menu's Delete action only once a colour is set", () => { + const removeSpy = vi.spyOn(component, "removeProjectColor").mockImplementation(() => {}); + + // testProject starts with color: null, so there is nothing to delete yet. + openColorPanel(); + expect(buttonLabelled("Delete").nativeElement.disabled).toBe(true); + + component.entry = { ...component.entry, color: "123456" }; + hostFixture.detectChanges(); + const deleteButton = buttonLabelled("Delete"); + expect(deleteButton.nativeElement.disabled).toBe(false); + + deleteButton.triggerEventHandler("click", null); + + expect(removeSpy).toHaveBeenCalled(); + }); + + it("opens name editing, saves on enter, and closes on focusout", () => { + const saveSpy = vi.spyOn(component, "saveProjectName").mockImplementation(() => {}); + + required(q(".edit-name-icon").parent, "the edit-name button").triggerEventHandler("click", null); + hostFixture.detectChanges(); + expect(component.editingName).toBe(true); + + const input = q("input"); + input.nativeElement.value = "renamed"; + input.triggerEventHandler("keyup.enter", {}); + expect(saveSpy).toHaveBeenCalledWith("renamed"); + + input.triggerEventHandler("focusout", {}); + expect(component.editingName).toBe(false); + }); + + it("expands and re-collapses the description through its controls", () => { + q('[nz-tooltip="Expand Description"]').triggerEventHandler("click", null); + hostFixture.detectChanges(); + expect(component.descriptionCollapsed).toBe(false); + + q('[nz-tooltip="Collapse Description"]').triggerEventHandler("click", null); + hostFixture.detectChanges(); + expect(component.descriptionCollapsed).toBe(true); + }); + + it("opens description editing, saves on focusout, and closes through the save icon", () => { + const saveSpy = vi.spyOn(component, "saveProjectDescription").mockImplementation(() => {}); + + required(q(".edit-description-icon").parent, "the edit-description button").triggerEventHandler("click", null); + hostFixture.detectChanges(); + expect(component.editingDescription).toBe(true); + + const textarea = q("textarea"); + textarea.nativeElement.value = "a new description"; + hostFixture.detectChanges(); + + textarea.triggerEventHandler("focusout", {}); + expect(saveSpy).toHaveBeenCalledWith("a new description"); + + q(".ant-input-clear-icon").triggerEventHandler("click", null); + hostFixture.detectChanges(); + expect(component.editingDescription).toBe(false); + }); + + it("wires the share and delete actions", () => { + const shareSpy = vi.spyOn(component, "onClickOpenShareAccess").mockImplementation(() => {}); + const deletedSpy = vi.spyOn(component.deleted, "emit"); + const [share, remove] = hostFixture.debugElement.queryAll(By.css("ul[nz-list-item-actions] button")); + + share.triggerEventHandler("click", null); + remove.triggerEventHandler("nzOnConfirm", null); + + expect(shareSpy).toHaveBeenCalled(); + expect(deletedSpy).toHaveBeenCalled(); + }); + }); + + describe("entry input", () => { + it("throws when read before an entry is provided", () => { + component.entry = undefined as unknown as DashboardProject; + + expect(() => component.entry).toThrowError("entry property must be provided"); + }); + }); + + describe("ngOnInit", () => { + it("adopts the project's stored colour", () => { + // A fresh fixture (rather than the shared one) so ngOnInit sees the colour. + const fixture = TestBed.createComponent(TestHostComponent); + fixture.componentInstance.entry = { ...testProject, color: "123456" }; + fixture.detectChanges(); + + expect(fixture.componentInstance.inner.color).toBe("123456"); + fixture.destroy(); + }); + }); });
