From 9afbef9aaf6b294e4cf696042ff47f855ecd2fdf Mon Sep 17 00:00:00 2001 From: Emily Sun Date: Mon, 8 Jun 2026 23:39:20 -0700 Subject: [PATCH] test(frontend): add spec for VisualizationFrameContentComponent Covers drawChart() guard clauses (no-op when operatorId is undefined, when the result service is missing, or when the snapshot is empty), the rendering path (renders the latest entry's html-content through DomSanitizer into the iframe srcdoc), and the auditTime-throttled result-update subscription (redraws after UPDATE_INTERVAL_MS for matching ids, ignores other ids and undefined operatorId). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ualization-frame-content.component.spec.ts | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 frontend/src/app/workspace/component/visualization-panel-content/visualization-frame-content.component.spec.ts diff --git a/frontend/src/app/workspace/component/visualization-panel-content/visualization-frame-content.component.spec.ts b/frontend/src/app/workspace/component/visualization-panel-content/visualization-frame-content.component.spec.ts new file mode 100644 index 00000000000..9027bb36da8 --- /dev/null +++ b/frontend/src/app/workspace/component/visualization-panel-content/visualization-frame-content.component.spec.ts @@ -0,0 +1,164 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { Subject } from "rxjs"; +import type { Mocked } from "vitest"; + +import { VisualizationFrameContentComponent } from "./visualization-frame-content.component"; +import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service"; + +describe("VisualizationFrameContentComponent", () => { + const UPDATE_INTERVAL_MS = VisualizationFrameContentComponent.UPDATE_INTERVAL_MS; + + let fixture: ComponentFixture; + let component: VisualizationFrameContentComponent; + let workflowResultService: Mocked; + let resultUpdateStream: Subject>; + + // Each test fills in a snapshot via `setSnapshot`. The default returns []. + let snapshotProvider: () => unknown[] | undefined; + + function makeResultSnapshot(html: string): unknown[] { + return [{ "html-content": html }]; + } + + beforeEach(() => { + resultUpdateStream = new Subject(); + snapshotProvider = () => undefined; + + const operatorResultServiceStub = { + getCurrentResultSnapshot: () => snapshotProvider(), + }; + + const workflowResultServiceSpy = { + getResultUpdateStream: vi.fn().mockReturnValue(resultUpdateStream.asObservable()), + getResultService: vi.fn().mockReturnValue(operatorResultServiceStub), + }; + + TestBed.configureTestingModule({ + imports: [VisualizationFrameContentComponent], + providers: [{ provide: WorkflowResultService, useValue: workflowResultServiceSpy }], + }); + workflowResultService = TestBed.inject(WorkflowResultService) as unknown as Mocked; + }); + + // NB: do not use a default value for `operatorId` — `buildComponent(undefined)` + // would then collapse to "op-1", masking the very behaviour we want to test. + function buildComponent(operatorId: string | undefined): void { + fixture = TestBed.createComponent(VisualizationFrameContentComponent); + component = fixture.componentInstance; + component.operatorId = operatorId; + fixture.detectChanges(); // triggers ngAfterContentInit -> drawChart() + } + + describe("drawChart() guard clauses", () => { + it("is a no-op when operatorId is undefined", () => { + buildComponent(undefined); + + expect(workflowResultService.getResultService).not.toHaveBeenCalled(); + expect(component.htmlData).toBe(""); + }); + + it("is a no-op when getResultService returns undefined", () => { + workflowResultService.getResultService.mockReturnValue(undefined); + + buildComponent("op-1"); + + expect(component.htmlData).toBe(""); + }); + + it("is a no-op when the snapshot is empty/undefined", () => { + snapshotProvider = () => undefined; + + buildComponent("op-1"); + + expect(component.htmlData).toBe(""); + }); + }); + + describe("drawChart() rendering", () => { + it("renders the last snapshot entry's html-content through the DomSanitizer", () => { + snapshotProvider = () => [ + { "html-content": "
stale
" }, + { "html-content": "
fresh
" }, + ]; + + buildComponent("op-1"); + + // The sanitizer wraps the string in a SafeHtml; we don't peek inside the + // wrapper, but the wrapped output must reflect the freshest entry. + expect(component.htmlData).not.toBe(""); + // SafeHtml serialises back to the original markup via [srcdoc], so + // assert on the iframe payload instead of the wrapper internals. + const iframe = fixture.nativeElement.querySelector("iframe") as HTMLIFrameElement; + expect(iframe.getAttribute("srcdoc")).toContain("fresh"); + expect(iframe.getAttribute("srcdoc")).not.toContain("stale"); + }); + }); + + describe("result-update stream subscription", () => { + it("redraws after UPDATE_INTERVAL_MS when a matching operatorId emits", fakeAsync(() => { + snapshotProvider = () => makeResultSnapshot("
v1
"); + buildComponent("op-1"); + // Initial drawChart() from ngAfterContentInit ran with v1. + const iframe = fixture.nativeElement.querySelector("iframe") as HTMLIFrameElement; + expect(iframe.getAttribute("srcdoc")).toContain("v1"); + + // Stream emits, but auditTime(UPDATE_INTERVAL_MS) holds the redraw. + snapshotProvider = () => makeResultSnapshot("
v2
"); + resultUpdateStream.next({ "op-1": { fake: true } }); + + // Before the audit window elapses, the iframe still shows v1. + tick(UPDATE_INTERVAL_MS - 1); + fixture.detectChanges(); + expect(iframe.getAttribute("srcdoc")).toContain("v1"); + + // After the window, the redraw fires and the iframe flips to v2. + tick(1); + fixture.detectChanges(); + expect(iframe.getAttribute("srcdoc")).toContain("v2"); + })); + + it("ignores stream emissions that don't include the current operatorId", fakeAsync(() => { + snapshotProvider = () => makeResultSnapshot("
only-v1
"); + buildComponent("op-1"); + const iframe = fixture.nativeElement.querySelector("iframe") as HTMLIFrameElement; + const initialSrcdoc = iframe.getAttribute("srcdoc"); + + snapshotProvider = () => makeResultSnapshot("
would-be-v2
"); + resultUpdateStream.next({ "some-other-op": { fake: true } }); + tick(UPDATE_INTERVAL_MS); + fixture.detectChanges(); + + // No redraw — the iframe srcdoc is unchanged. + expect(iframe.getAttribute("srcdoc")).toBe(initialSrcdoc); + })); + + it("ignores stream emissions when operatorId is undefined", fakeAsync(() => { + buildComponent(undefined); + const drawSpy = vi.spyOn(component, "drawChart"); + + resultUpdateStream.next({ "op-1": { fake: true } }); + tick(UPDATE_INTERVAL_MS); + + expect(drawSpy).not.toHaveBeenCalled(); + })); + }); +});