From d61056a0abfcf08ba12ddbd56af99a5094ac0da2 Mon Sep 17 00:00:00 2001 From: Xinyuan Lin Date: Fri, 7 Aug 2026 18:46:24 -0700 Subject: [PATCH 1/3] test(frontend): render the project list item's permission and description rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list item decides in its template what a viewer may touch and how much of a description to show, and none of it was rendered: the existing specs call the save and colour methods directly. Adds 8 tests. The one that matters most is the editable gating — a project the viewer only has READ on must not be shown the rename, description, share or delete controls, and that decision lives entirely in two *ngIf="editable" guards. Also covers the name/edit-input swap, the description starting collapsed and expanding on request, the trim() guard that keeps a whitespace-only description from rendering an empty expander, the character counter, the save icon appearing only once the text actually changed, and the creation-date format. MarkdownModule.forRoot() joins the TestBed: an expanded description renders a element, which no existing test reached. No production file is touched. --- .../user-project-list-item.component.spec.ts | 98 ++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) 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 2263f8553db..9f97d37fcd2 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,7 @@ import { UserService } from "../../../../../common/service/user/user.service"; import { commonTestProviders } from "../../../../../common/testing/test-utils"; import { ShareAccessComponent } from "../../share-access/share-access.component"; import { of } from "rxjs"; +import { MarkdownModule } from "ngx-markdown"; // UserProjectListItemComponent is rooted at ; instantiating it // outside an host throws "No provider found for NzListComponent". @@ -71,7 +72,8 @@ describe("UserProjectListItemComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [TestHostComponent, HttpClientTestingModule], + // MarkdownModule.forRoot() backs the element in an expanded description. + imports: [TestHostComponent, HttpClientTestingModule, MarkdownModule.forRoot()], providers: [ NotificationService, UserProjectService, @@ -218,4 +220,98 @@ describe("UserProjectListItemComponent", () => { expect(refreshSpy).toHaveBeenCalled(); }); }); + /** + * The list item decides in its template what a viewer is allowed to touch and how much of a long + * description to show. The specs above call the save/colour methods directly, so none of the + * rendered gating had been pinned. + */ + describe("rendered item", () => { + /** Re-renders the host with the given entry/editable combination. */ + function render(over: Partial = {}, editable = true): HTMLElement { + hostFixture.componentInstance.entry = { ...testProject, ...over }; + hostFixture.componentInstance.editable = editable; + hostFixture.detectChanges(); + return hostFixture.nativeElement as HTMLElement; + } + + it("shows the project name and its creation date", () => { + const el = render({ name: "quarterly", creationTime: januaryFirst1970 }); + + expect(el.textContent).toContain("quarterly"); + expect(el.textContent).toContain("1970-01-01"); + }); + + it("hides every editing control from a read-only viewer", () => { + // accessLevel READ reaches this component as editable=false; if the template ignored it the + // viewer would be shown share and delete buttons for a project they cannot change. + const el = render({}, false); + + expect(el.querySelector(".edit-name-icon")).toBeNull(); + expect(el.querySelector(".edit-description-icon")).toBeNull(); + expect(el.querySelector("ul[nz-list-item-actions]")).toBeNull(); + }); + + it("offers the editing controls to a viewer with write access", () => { + const el = render({}, true); + + expect(el.querySelector(".edit-name-icon")).not.toBeNull(); + expect(el.querySelector("ul[nz-list-item-actions]")).not.toBeNull(); + }); + + it("swaps the name for an input once the name is being edited", () => { + render(); + expect(hostFixture.nativeElement.querySelector("nz-list-item-meta-title input")).toBeNull(); + + component.editingName = true; + hostFixture.detectChanges(); + + expect(hostFixture.nativeElement.querySelector("nz-list-item-meta-title input")).not.toBeNull(); + }); + + it("starts with the description collapsed and expands it on request", () => { + // descriptionCollapsed defaults to true, so a list of projects stays compact until the user + // opens one. + const el = render({ description: "a long description" }); + expect(el.querySelector(".description-container")).toBeNull(); + + component.descriptionCollapsed = false; + hostFixture.detectChanges(); + + expect(hostFixture.nativeElement.querySelector(".description-container")).not.toBeNull(); + }); + + it("shows no description block when the description is only whitespace", () => { + // Expanded, so the trim() guard is the only thing left to hide it: a whitespace-only + // description would otherwise render an empty expander with nothing in it. + render({ description: " " }); + component.descriptionCollapsed = false; + hostFixture.detectChanges(); + + expect(hostFixture.nativeElement.querySelector(".description-container")).toBeNull(); + }); + + it("counts the characters typed into the description editor", () => { + render({ description: "abc" }); + component.editingDescription = true; + hostFixture.detectChanges(); + + const count = hostFixture.nativeElement.querySelector(".character-count")!; + expect(count.textContent?.trim()).toBe(`3/${component.MAX_PROJECT_DESCRIPTION_CHAR_COUNT}`); + }); + + it("offers the save button only once the description has actually changed", () => { + render({ description: "abc" }); + component.editingDescription = true; + hostFixture.detectChanges(); + const textarea = hostFixture.nativeElement.querySelector("textarea")!; + + expect(hostFixture.nativeElement.querySelector(".ant-input-clear-icon")).toBeNull(); + + textarea.value = "abcd"; + textarea.dispatchEvent(new Event("input")); + hostFixture.detectChanges(); + + expect(hostFixture.nativeElement.querySelector(".ant-input-clear-icon")).not.toBeNull(); + }); + }); }); From e44b4919529bb6b2dd2e7194e5dead45a6b7977f Mon Sep 17 00:00:00 2001 From: Xinyuan Lin Date: Fri, 7 Aug 2026 22:10:02 -0700 Subject: [PATCH 2/3] test(frontend): tighten the project list item's date, gating and counter checks The creation-date assertion looked for the 1970-01-01 substring, which neither pinned the yyyy-MM-dd HH:mm format nor held up outside the runner's timezone; it now matches the rendered line as a shape. The editable case asserted only the rename control, so it now also covers the add-description control and counts the two action buttons. The character counter was checked once at its initial value; it now types into the textarea and confirms the count follows the box rather than the saved description. --- .../user-project-list-item.component.spec.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 9f97d37fcd2..d08706e47f6 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 @@ -238,7 +238,11 @@ describe("UserProjectListItemComponent", () => { const el = render({ name: "quarterly", creationTime: januaryFirst1970 }); expect(el.textContent).toContain("quarterly"); - expect(el.textContent).toContain("1970-01-01"); + // Matched as a shape rather than a literal: the pipe renders in the runner's local zone, so + // a fixed string would pin the timezone instead of the yyyy-MM-dd HH:mm format. + expect(el.querySelector("nz-list-item-meta-description p")?.textContent?.trim()).toMatch( + /^Created: \d{4}-\d{2}-\d{2} \d{2}:\d{2}$/ + ); }); it("hides every editing control from a read-only viewer", () => { @@ -255,7 +259,10 @@ describe("UserProjectListItemComponent", () => { const el = render({}, true); expect(el.querySelector(".edit-name-icon")).not.toBeNull(); - expect(el.querySelector("ul[nz-list-item-actions]")).not.toBeNull(); + expect(el.querySelector(".edit-description-icon")).not.toBeNull(); + // Share and delete both live in that list; count the buttons rather than just the container + // (nz-list-item-action renders as an
  • , so the element selector finds nothing). + expect(el.querySelectorAll("ul[nz-list-item-actions] button").length).toBe(2); }); it("swaps the name for an input once the name is being edited", () => { @@ -297,6 +304,16 @@ describe("UserProjectListItemComponent", () => { const count = hostFixture.nativeElement.querySelector(".character-count")!; expect(count.textContent?.trim()).toBe(`3/${component.MAX_PROJECT_DESCRIPTION_CHAR_COUNT}`); + + // It must follow what is in the box, not the value the description started at. + const textarea = hostFixture.nativeElement.querySelector("textarea")!; + textarea.value = "abcdef"; + textarea.dispatchEvent(new Event("input")); + hostFixture.detectChanges(); + + expect(hostFixture.nativeElement.querySelector(".character-count")!.textContent?.trim()).toBe( + `6/${component.MAX_PROJECT_DESCRIPTION_CHAR_COUNT}` + ); }); it("offers the save button only once the description has actually changed", () => { From 75e714021f265d6ca9ab1f165b00391260e00b9e Mon Sep 17 00:00:00 2001 From: Xinyuan Lin Date: Fri, 7 Aug 2026 23:59:17 -0700 Subject: [PATCH 3/3] test(frontend): pin the creation date's value as well as its format The regex introduced in e44b491952 fixed the format and timezone concerns but matched only the shape, so the assertion would have stayed green if the template rendered a different timestamp entirely. Formats the expected string with the same pipe and format literal instead, which pins the value too and still holds in any timezone. Rendering a different timestamp now fails; it did not before. --- .../user-project-list-item.component.spec.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 d08706e47f6..f2198854b1e 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 @@ -31,6 +31,7 @@ import { StubUserService } from "../../../../../common/service/user/stub-user.se 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 { of } from "rxjs"; import { MarkdownModule } from "ngx-markdown"; @@ -238,11 +239,11 @@ describe("UserProjectListItemComponent", () => { const el = render({ name: "quarterly", creationTime: januaryFirst1970 }); expect(el.textContent).toContain("quarterly"); - // Matched as a shape rather than a literal: the pipe renders in the runner's local zone, so - // a fixed string would pin the timezone instead of the yyyy-MM-dd HH:mm format. - expect(el.querySelector("nz-list-item-meta-description p")?.textContent?.trim()).toMatch( - /^Created: \d{4}-\d{2}-\d{2} \d{2}:\d{2}$/ - ); + // Expected value is formatted here with the same pipe and format string, so the assertion + // pins both the yyyy-MM-dd HH:mm format and the timestamp it was given, without hard-coding + // a literal that would only hold in one timezone. + const expected = new DatePipe("en-US").transform(januaryFirst1970, "yyyy-MM-dd HH:mm"); + expect(el.querySelector("nz-list-item-meta-description p")?.textContent?.trim()).toBe(`Created: ${expected}`); }); it("hides every editing control from a read-only viewer", () => {