From fc81f9d9cd84e410b5c4b786a5c656c213723139 Mon Sep 17 00:00:00 2001 From: mengw15 <125719918+mengw15@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:21:29 -0700 Subject: [PATCH] test(frontend): extend UserProjectListItemComponent template coverage --- .../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 f2198854b1e..58c57b18afa 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 = (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(); + }); + }); });