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-7727-7fd4f892766f0a01c2d0c7a99cebc6c8d2ab7037 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 8bcc8ebc92ec4abb8f1d74e7baeff9dd71242b74 Author: Xinyuan Lin <[email protected]> AuthorDate: Mon Aug 17 08:02:26 2026 +0000 test(frontend): cover the compiling service and the search-results template (#7727) ### What changes were proposed in this PR? Two frontend files, bundled because one needed a proven remedy and the other was a plain gap. | File | Before | After | |---|---|---| | `search-results.component.html` | **0/33 lines, 0/6 branches, 0/6 functions** | **33/33, 6/6, 6/6** | | `workflow-compiling.service.ts` | 140/157 lines, 90/105 branches, 32/36 functions | **157/157, 103/105, 36/36** | Tests **50 -> 73**. `search-results.component.ts` also rises to 42/42 lines as a side effect. Both files reach 100% of their lines, which necessarily covers all 19 of the lines Codecov reports missed (11 + 8). ### The template was not undertested — it was unattributed This is #7458, and this instance is worth recording because it presents as the opposite. The existing `describe` uses `TestBed.overrideComponent` and **does render and assert on the DOM**: it checks `texera-list-item` counts, `.card-entry` nodes, and load-more visibility. The template still measured **0 of 33 lines**. Rendering happened; nothing was attributed. Second fingerprint: under the override the whole template function was attributed to the `.ts` as one uncovered span `(55,35)-(79)`. The appended `describe` keeps its own `TestBed` with no override and the real `ListItemComponent`, so the existing tests and the `StubListItemComponent` are untouched. ### Verification 23 mutations, **23 killed, no survivors** — each applied one at a time with the production file byte-compared after every revert, and the failing test read by name. Two mutations had to be reformulated, which is the part worth flagging: - Exchanging the compile response's success/failure legs is a **TypeScript narrowing error**, not a behaviour change. A mutation that only fails to compile proves nothing, so it was discarded and replaced with three semantic mutants: the success leg additionally requiring zero operator errors, the state stream being notified only from the failed leg, and the failed leg reusing the previous errors. - Dropping the `&& cardTemplate` guard breaks `strictTemplates` narrowing, so that mutant carries a companion `[ngTemplateOutlet]="cardTemplate!"` purely to keep it compiling. The behaviour change — card view rendering with no template — is the mutation, and it died on a DOM assertion. One survivor was found and closed during the build rather than reported: `[currentUid]="this.currentUid"` replaced by `entry.ownerId` passed, because every fixture entry shared an owner. The new test gives two entries distinguishable owners (7 and 99), asserts on the rendered `.owner-badge`, then re-points `currentUid` to 99 and asserts the badge moves — so a constant replacement dies too. ### Deliberately not included Two branches in `workflow-compiling.service.ts` are structurally unreachable, so it cannot exceed 103/105: - `if (!dynamicSchema) return undefined` (line 241) is dead: `DynamicSchemaService.getDynamicSchema()` returns a non-nullable `OperatorSchema` and **throws** on a miss. This is also the file's only uncovered statement. - `if (schemas.length > 0)` (line 285) sits inside `if (linksToThisPort.length > 0)`, where `schemas` is precisely that array mapped, so the false leg cannot occur. A production bug is reported rather than pinned: `getAttrNames` (line 362) tests required-ness against the **root** schema via `operatorSchema.jsonSchema.required?.includes(attrName)`, while `DynamicSchemaService.mutateProperty` recurses into nested `properties`/`definitions`/`items`. So a nested property that is required in its own sub-schema still gets `""` appended to its enum, and a nested optional property whose name collides with a root-level required one loses that escape hatch. The new tests exercise only root-level properties, so neither behaviour is cemented. Also noted, not cemented: `getOperatorInputSchemaMap` is a getter that mutates `currentCompilationStateInfo`, flipping the whole compilation to `Failed` when two links disagree on a port schema. No production file is touched. ### Any related issues, documentation, discussions? Closes #7726 ### How was this PR tested? ``` npx ng test --watch=false --include="**/workflow-compiling.service.spec.ts" --include="**/search-results.component.spec.ts" ``` ``` Test Files 2 passed (2) Tests 73 passed (73) ``` Coverage measured with `--coverage` on the same run. `yarn format:ci` passes, and was checked non-vacuously with a positive control: a deliberately misformatted throwaway file made it exit 1 and name the file, then was removed. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --- .../search-results.component.spec.ts | 302 ++++++++++++++++- .../workflow-compiling.service.spec.ts | 364 ++++++++++++++++++++- 2 files changed, 663 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/search-results/search-results.component.spec.ts b/frontend/src/app/dashboard/component/user/search-results/search-results.component.spec.ts index 348ccfc268..d1445ccd7c 100644 --- a/frontend/src/app/dashboard/component/user/search-results/search-results.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/search-results/search-results.component.spec.ts @@ -21,7 +21,11 @@ import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from " import { NgTemplateOutlet } from "@angular/common"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { LoadMoreFunction, SearchResultsComponent } from "./search-results.component"; +import { By } from "@angular/platform-browser"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { RouterTestingModule } from "@angular/router/testing"; +import { NzModalService } from "ng-zorro-antd/modal"; +import { LoadMoreFunction, SearchResultsComponent, SearchResultsViewMode } from "./search-results.component"; import { ListItemComponent } from "../list-item/list-item.component"; import { DashboardEntry } from "../../../type/dashboard-entry"; import { UserService } from "../../../../common/service/user/user.service"; @@ -295,3 +299,299 @@ describe("SearchResultsComponent", () => { }); }); }); + +/** + * Host that drives SearchResultsComponent purely through its template contract: + * inputs go in through bindings and every output is recorded as it arrives. + */ +@Component({ + standalone: true, + imports: [SearchResultsComponent], + template: ` + <texera-search-results + [viewMode]="viewMode" + [isPrivateSearch]="isPrivateSearch" + [editable]="true" + [currentUid]="currentUid" + [cardTemplate]="cardTemplateInput" + (deleted)="deletedEntries.push($event)" + (duplicated)="duplicatedEntries.push($event)" + (refresh)="refreshCount = refreshCount + 1" + (notifyWorkflow)="notifyCount = notifyCount + 1"> + </texera-search-results> + <ng-template + #card + let-entry + ><span class="card-entry">card:{{ entry.name }}</span></ng-template + > + `, +}) +class SearchResultsTemplateHostComponent { + @ViewChild("card", { static: true }) cardTemplate!: TemplateRef<{ $implicit: DashboardEntry }>; + @ViewChild(SearchResultsComponent, { static: true }) results!: SearchResultsComponent; + viewMode: SearchResultsViewMode = "list"; + isPrivateSearch = true; + currentUid: number | undefined = 7; + cardTemplateInput?: TemplateRef<{ $implicit: DashboardEntry }>; + deletedEntries: DashboardEntry[] = []; + duplicatedEntries: DashboardEntry[] = []; + refreshCount = 0; + notifyCount = 0; +} + +/** + * Deliberately a separate suite from the one above, with its own TestBed and no + * TestBed.overrideComponent: an override makes Angular re-JIT SearchResultsComponent + * from its decorator metadata, and the recompiled template loses the source map that + * attributes executed bindings back to search-results.component.html (issue #7458). + * Without the override the real ListItemComponent renders, so the child's outputs can + * be fired from the rendered DOM and the template's own bindings are actually counted. + */ +describe("SearchResultsComponent rendered template", () => { + let fixture: ComponentFixture<SearchResultsTemplateHostComponent>; + let host: SearchResultsTemplateHostComponent; + + /** A workflow entry complete enough for the real ListItemComponent to render it. */ + const workflowEntry = (id: number, name: string, checked = false, ownerId?: number): DashboardEntry => + ({ + id, + name, + description: `description of ${name}`, + type: "workflow", + workflow: { isOwner: true }, + accessibleUserIds: [7], + likeCount: 0, + viewCount: 0, + isLiked: false, + size: 0, + checked, + ownerId, + }) as unknown as DashboardEntry; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchResultsTemplateHostComponent, NoopAnimationsModule, HttpClientTestingModule, RouterTestingModule], + providers: [{ provide: UserService, useClass: StubUserService }, NzModalService, ...commonTestProviders], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchResultsTemplateHostComponent); + host = fixture.componentInstance; + }); + + afterEach(() => { + fixture?.destroy(); + document.querySelectorAll(".cdk-overlay-container").forEach(c => (c.innerHTML = "")); + }); + + const el = (): HTMLElement => fixture.nativeElement as HTMLElement; + + /** The only load-more button in the template; the list items render buttons of their own. */ + const loadMoreButton = (): HTMLButtonElement | null => el().querySelector(".load-more button"); + + const renderedNames = (): string[] => + Array.from(el().querySelectorAll(".resource-name")).map(node => (node.textContent ?? "").trim()); + + const renderedCards = (): string[] => + Array.from(el().querySelectorAll(".card-entry")).map(node => (node.textContent ?? "").trim()); + + /** Loads the first page through the component's real API and renders it. */ + const loadFirstPage = async (loadMoreFunction: LoadMoreFunction): Promise<void> => { + host.results.reset(loadMoreFunction); + await host.results.loadMore(); + fixture.detectChanges(); + }; + + /** + * Lets the click handler's promise chain settle. fixture.whenStable() cannot be used here: + * the cdk-virtual-scroll-viewport in the list view keeps the zone permanently unstable. + */ + const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0)); + + const clickLoadMore = async (): Promise<void> => { + loadMoreButton()!.click(); + await settle(); + fixture.detectChanges(); + }; + + describe("list view", () => { + it("renders one list item per entry and appends the next page when Load more is clicked", async () => { + const loadMoreFunction = vi + .fn<LoadMoreFunction>() + .mockResolvedValueOnce({ entries: [workflowEntry(1, "alpha"), workflowEntry(2, "beta")], more: true }) + .mockResolvedValueOnce({ entries: [workflowEntry(3, "gamma")], more: false }); + + await loadFirstPage(loadMoreFunction); + + expect(el().querySelectorAll("texera-list-item").length).toBe(2); + expect(renderedNames()).toEqual(["alpha", "beta"]); + expect(loadMoreButton()?.textContent?.trim()).toBe("Load more"); + + await clickLoadMore(); + + expect(loadMoreFunction).toHaveBeenCalledTimes(2); + // the second page starts where the first one ended + expect(loadMoreFunction).toHaveBeenLastCalledWith(2, 20); + expect(renderedNames()).toEqual(["alpha", "beta", "gamma"]); + // the last page reported more: false, so the button is gone + expect(loadMoreButton()).toBeNull(); + }); + + it("hides Load more while a page is still in flight", async () => { + let releasePage!: (page: { entries: DashboardEntry[]; more: boolean }) => void; + const pending = new Promise<{ entries: DashboardEntry[]; more: boolean }>(resolve => (releasePage = resolve)); + + await loadFirstPage(vi.fn<LoadMoreFunction>().mockResolvedValue({ entries: [], more: true })); + expect(loadMoreButton()).not.toBeNull(); + + host.results.loadMoreFunction = () => pending; + const inFlight = host.results.loadMore(); + fixture.detectChanges(); + expect(loadMoreButton()).toBeNull(); + + releasePage({ entries: [workflowEntry(1, "alpha")], more: true }); + await inFlight; + fixture.detectChanges(); + expect(loadMoreButton()).not.toBeNull(); + }); + + it("forwards each list item's deleted and duplicated outputs with that item's own entry", async () => { + const alpha = workflowEntry(1, "alpha"); + const beta = workflowEntry(2, "beta"); + await loadFirstPage(vi.fn<LoadMoreFunction>().mockResolvedValue({ entries: [alpha, beta], more: false })); + + // the second item's Copy button, so an entry mix-up cannot pass unnoticed + const copyButtons = el().querySelectorAll<HTMLButtonElement>('button[title="Copy"]'); + expect(copyButtons.length).toBe(2); + copyButtons[1].click(); + fixture.detectChanges(); + + expect(host.duplicatedEntries).toEqual([beta]); + expect(host.duplicatedEntries[0]).toBe(beta); + + // delete is behind a popconfirm overlay, so fire it on the real child instead + const listItems = fixture.debugElement.queryAll(By.directive(ListItemComponent)); + expect(listItems.length).toBe(2); + (listItems[1].componentInstance as ListItemComponent).deleted.emit(); + fixture.detectChanges(); + + expect(host.deletedEntries).toEqual([beta]); + expect(host.deletedEntries[0]).toBe(beta); + }); + + it("forwards a list item's refresh output", async () => { + await loadFirstPage( + vi + .fn<LoadMoreFunction>() + .mockResolvedValue({ entries: [workflowEntry(1, "alpha"), workflowEntry(2, "beta")], more: false }) + ); + + const listItems = fixture.debugElement.queryAll(By.directive(ListItemComponent)); + expect(host.refreshCount).toBe(0); + + (listItems[0].componentInstance as ListItemComponent).refresh.emit(); + (listItems[1].componentInstance as ListItemComponent).refresh.emit(); + fixture.detectChanges(); + + expect(host.refreshCount).toBe(2); + }); + + it("notifies only once ticking a checkbox leaves every entry selected", async () => { + // alpha starts selected, beta does not + const alpha = workflowEntry(1, "alpha", true); + const beta = workflowEntry(2, "beta", false); + await loadFirstPage(vi.fn<LoadMoreFunction>().mockResolvedValue({ entries: [alpha, beta], more: false })); + + const checkboxes = el().querySelectorAll<HTMLInputElement>("input.large-checkbox"); + expect(checkboxes.length).toBe(2); + + // ticking beta completes the selection + checkboxes[1].click(); + fixture.detectChanges(); + expect(beta.checked).toBe(true); + expect(host.notifyCount).toBe(1); + + // un-ticking alpha breaks it again, so nothing further is notified + checkboxes[0].click(); + fixture.detectChanges(); + expect(alpha.checked).toBe(false); + expect(host.notifyCount).toBe(1); + }); + + it("passes currentUid down, so the owner badge follows the current user", async () => { + // alpha belongs to the host's current user (7), beta to someone else (99) + await loadFirstPage( + vi.fn<LoadMoreFunction>().mockResolvedValue({ + entries: [workflowEntry(1, "alpha", false, 7), workflowEntry(2, "beta", false, 99)], + more: false, + }) + ); + + /** Names of the entries whose list item shows the owner badge. */ + const ownerBadgedNames = (): string[] => + Array.from(el().querySelectorAll("texera-list-item")) + .filter(item => item.querySelector(".owner-badge") !== null) + .map(item => (item.querySelector(".resource-name")?.textContent ?? "").trim()); + + expect(ownerBadgedNames()).toEqual(["alpha"]); + + // re-pointing the current user moves the badge, so the binding is not a constant + host.currentUid = 99; + fixture.detectChanges(); + + expect(ownerBadgedNames()).toEqual(["beta"]); + }); + + it("passes isPrivateSearch down to each list item", async () => { + host.isPrivateSearch = false; + await loadFirstPage( + vi.fn<LoadMoreFunction>().mockResolvedValue({ entries: [workflowEntry(1, "alpha")], more: false }) + ); + + // the checkbox and the button group are private-search only + expect(el().querySelectorAll("input.large-checkbox").length).toBe(0); + expect(el().querySelectorAll('button[title="Copy"]').length).toBe(0); + expect(el().querySelectorAll("texera-list-item").length).toBe(1); + }); + }); + + describe("card view", () => { + beforeEach(() => { + host.viewMode = "card"; + host.cardTemplateInput = host.cardTemplate; + }); + + it("renders the supplied card template per entry and appends the next page on Load more", async () => { + const loadMoreFunction = vi + .fn<LoadMoreFunction>() + .mockResolvedValueOnce({ entries: [workflowEntry(1, "alpha"), workflowEntry(2, "beta")], more: true }) + .mockResolvedValueOnce({ entries: [workflowEntry(3, "gamma")], more: false }); + + await loadFirstPage(loadMoreFunction); + + expect(renderedCards()).toEqual(["card:alpha", "card:beta"]); + // the card view is used instead of, not alongside, the list view + expect(el().querySelectorAll("texera-list-item").length).toBe(0); + expect(loadMoreButton()?.textContent?.trim()).toBe("Load more"); + + await clickLoadMore(); + + expect(loadMoreFunction).toHaveBeenLastCalledWith(2, 20); + expect(renderedCards()).toEqual(["card:alpha", "card:beta", "card:gamma"]); + expect(loadMoreButton()).toBeNull(); + }); + + it("renders nothing when no card template is supplied", async () => { + host.cardTemplateInput = undefined; + + await loadFirstPage( + vi.fn<LoadMoreFunction>().mockResolvedValue({ entries: [workflowEntry(1, "alpha")], more: true }) + ); + + expect(renderedCards()).toEqual([]); + expect(el().querySelector(".card-grid")).toBeNull(); + // the list view is not used as a fallback either + expect(el().querySelectorAll("texera-list-item").length).toBe(0); + expect(loadMoreButton()).toBeNull(); + }); + }); +}); diff --git a/frontend/src/app/workspace/service/compile-workflow/workflow-compiling.service.spec.ts b/frontend/src/app/workspace/service/compile-workflow/workflow-compiling.service.spec.ts index 11190cb32c..28f975540e 100644 --- a/frontend/src/app/workspace/service/compile-workflow/workflow-compiling.service.spec.ts +++ b/frontend/src/app/workspace/service/compile-workflow/workflow-compiling.service.spec.ts @@ -18,8 +18,13 @@ */ import { JSONSchema7Definition } from "json-schema"; -import { TestBed } from "@angular/core/testing"; -import { WorkflowCompilingService } from "./workflow-compiling.service"; +import { fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { HttpTestingController, TestRequest } from "@angular/common/http/testing"; +import { + WORKFLOW_COMPILATION_DEBOUNCE_TIME_MS, + WORKFLOW_COMPILATION_ENDPOINT, + WorkflowCompilingService, +} from "./workflow-compiling.service"; import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service"; import { DynamicSchemaService } from "../dynamic-schema/dynamic-schema.service"; import { ValidationWorkflowService } from "../validation/validation-workflow.service"; @@ -31,9 +36,13 @@ import { UndoRedoService } from "../undo-redo/undo-redo.service"; import { mockPoint, mockScanPredicate, + mockSentimentPredicate, + mockMultiInputOutputPredicate, mockResultPredicate, mockScanResultLink, } from "../workflow-graph/model/mock-workflow-data"; +import { OperatorPredicate } from "../../types/workflow-common.interface"; +import { AppSettings } from "../../../common/app-setting"; import { serializePortIdentity } from "../../../common/util/port-identity-serde"; import { commonTestImports, commonTestProviders } from "../../../common/testing/test-utils"; import { firstValueFrom } from "rxjs"; @@ -591,3 +600,354 @@ describe("WorkflowCompilingService.setOperatorInputAttrs / restoreOperatorInputA }); }); }); + +/** The real service graph the compiling service is wired into; no operator metadata or compile backend is contacted. */ +const configureCompilingTestBed = (): void => { + TestBed.configureTestingModule({ + imports: [...commonTestImports], + providers: [ + { provide: OperatorMetadataService, useClass: StubOperatorMetadataService }, + JointUIService, + WorkflowActionService, + WorkflowUtilService, + UndoRedoService, + DynamicSchemaService, + ValidationWorkflowService, + WorkflowCompilingService, + ...commonTestProviders, + ], + }); +}; + +const port = (id: number): string => serializePortIdentity({ id, internal: false }); + +describe("WorkflowCompilingService compile pipeline", () => { + let service: WorkflowCompilingService; + let workflowActionService: WorkflowActionService; + let httpTestingController: HttpTestingController; + + const compileUrl = `${AppSettings.getApiEndpoint()}/${WORKFLOW_COMPILATION_ENDPOINT}`; + const scanOutputSchema = [{ attributeName: "col_a", attributeType: "string" }]; + + beforeEach(() => { + configureCompilingTestBed(); + // injecting the service is what subscribes its constructor pipeline to the graph streams + service = TestBed.inject(WorkflowCompilingService); + workflowActionService = TestBed.inject(WorkflowActionService); + httpTestingController = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + /** + * Builds a valid Scan -> ViewResults workflow and lets the debounce window elapse, + * which is what makes the constructor pipeline issue exactly one compile request. + */ + const buildWorkflowAndCompile = (): TestRequest => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + workflowActionService.addOperator(mockResultPredicate, mockPoint); + workflowActionService.addLink(mockScanResultLink); + // ScanSource requires `tableName`; without it the operator is filtered out of the valid graph + workflowActionService.setOperatorProperty(mockScanPredicate.operatorID, { tableName: "twitter" }); + tick(WORKFLOW_COMPILATION_DEBOUNCE_TIME_MS); + return httpTestingController.expectOne(compileUrl); + }; + + it("debounces graph edits into a single POST carrying the logical plan", fakeAsync(() => { + const request = buildWorkflowAndCompile(); + + expect(request.request.method).toBe("POST"); + expect(request.request.headers.get("Content-Type")).toBe("application/json"); + + const body = JSON.parse(request.request.body); + expect(body.operators.map((operator: any) => operator.operatorID).sort()).toEqual( + [mockScanPredicate.operatorID, mockResultPredicate.operatorID].sort() + ); + expect(body.links.length).toBe(1); + expect(body.opsToReuseResult).toEqual([]); + expect(body.opsToViewResult).toEqual([]); + + request.flush({ physicalPlan: { operators: [], links: [] }, operatorOutputSchemas: {}, operatorErrors: {} }); + })); + + it("records the physical plan and output schemas when the response carries a physical plan", fakeAsync(() => { + const request = buildWorkflowAndCompile(); + const physicalPlan = { operators: [{ id: "physical-1" }], links: [] }; + + request.flush({ + physicalPlan, + operatorOutputSchemas: { [mockScanPredicate.operatorID]: { [port(0)]: scanOutputSchema } }, + operatorErrors: { [mockScanPredicate.operatorID]: { message: "ignored while succeeded" } }, + }); + + expect(service.getWorkflowCompilationState()).toBe(CompilationState.Succeeded); + expect((service as any).currentCompilationStateInfo.physicalPlan).toEqual(physicalPlan); + expect(service.getOperatorOutputSchemaMap(mockScanPredicate.operatorID)).toEqual({ [port(0)]: scanOutputSchema }); + // a succeeded compilation never surfaces operator errors, even when the response carries some + expect(service.getWorkflowCompilationErrors()).toEqual({}); + })); + + it("records the operator errors when the response carries no physical plan", fakeAsync(() => { + const request = buildWorkflowAndCompile(); + const operatorErrors = { [mockResultPredicate.operatorID]: { message: "compilation blew up" } }; + + request.flush({ + operatorOutputSchemas: { [mockScanPredicate.operatorID]: { [port(0)]: scanOutputSchema } }, + operatorErrors, + }); + + expect(service.getWorkflowCompilationState()).toBe(CompilationState.Failed); + expect(service.getWorkflowCompilationErrors()).toEqual(operatorErrors); + // the output schemas of the partially-compiled workflow are still kept + expect(service.getOperatorOutputSchemaMap(mockScanPredicate.operatorID)).toEqual({ [port(0)]: scanOutputSchema }); + })); + + it("pushes each compile outcome onto the compilation-state stream in order", fakeAsync(() => { + const states: CompilationState[] = []; + const subscription = service.getCompilationStateInfoChangedStream().subscribe(state => states.push(state)); + + buildWorkflowAndCompile().flush({ + physicalPlan: { operators: [], links: [] }, + operatorOutputSchemas: {}, + operatorErrors: {}, + }); + + // a second edit compiles again, this time without a physical plan + workflowActionService.setOperatorProperty(mockScanPredicate.operatorID, { tableName: "reddit" }); + tick(WORKFLOW_COMPILATION_DEBOUNCE_TIME_MS); + httpTestingController.expectOne(compileUrl).flush({ operatorOutputSchemas: {}, operatorErrors: {} }); + + expect(states).toEqual([CompilationState.Succeeded, CompilationState.Failed]); + subscription.unsubscribe(); + })); + + it("swallows a failing compile request and keeps compiling afterwards", fakeAsync(() => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + buildWorkflowAndCompile().flush("boom", { status: 500, statusText: "Server Error" }); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toBe("compile workflow API returns error"); + // the error is turned into EMPTY, so the state is left untouched... + expect(service.getWorkflowCompilationState()).toBe(CompilationState.Uninitialized); + + // ...and the outer subscription survives, so the next edit still compiles + workflowActionService.setOperatorProperty(mockScanPredicate.operatorID, { tableName: "reddit" }); + tick(WORKFLOW_COMPILATION_DEBOUNCE_TIME_MS); + httpTestingController + .expectOne(compileUrl) + .flush({ physicalPlan: { operators: [], links: [] }, operatorOutputSchemas: {}, operatorErrors: {} }); + + expect(service.getWorkflowCompilationState()).toBe(CompilationState.Succeeded); + warn.mockRestore(); + })); +}); + +describe("WorkflowCompilingService.applySchemaPropagationResult without a propagated input schema", () => { + let service: WorkflowCompilingService; + let workflowActionService: WorkflowActionService; + let dynamicSchemaService: DynamicSchemaService; + + beforeEach(() => { + configureCompilingTestBed(); + service = TestBed.inject(WorkflowCompilingService); + workflowActionService = TestBed.inject(WorkflowActionService); + dynamicSchemaService = TestBed.inject(DynamicSchemaService); + }); + + /** Replaces the operator's dynamic schema with one that already carries a propagated `enum` of attribute names. */ + const givePropagatedAttributeEnum = (operatorID: string): void => { + const base = dynamicSchemaService.getDynamicSchema(operatorID); + dynamicSchemaService.setDynamicSchema(operatorID, { + ...base, + jsonSchema: { + type: "object", + properties: { + attribute: { + type: "string", + autofill: "attributeName", + autofillAttributeOnPort: 0, + enum: ["col_a", ""], + uniqueItems: true, + }, + }, + } as any, + }); + }; + + it("restores the original input attributes of a non-source operator", () => { + // NlpSentiment declares one input port, so it is not a source operator + workflowActionService.addOperator(mockSentimentPredicate, mockPoint); + const operatorID = mockSentimentPredicate.operatorID; + givePropagatedAttributeEnum(operatorID); + + vi.spyOn(service, "getOperatorInputSchemaMap").mockReturnValue(undefined); + (service as any).applySchemaPropagationResult(); + + const attribute = (dynamicSchemaService.getDynamicSchema(operatorID).jsonSchema.properties as any).attribute; + expect(attribute.enum).toBeUndefined(); + expect(attribute.uniqueItems).toBeUndefined(); + }); + + it("keeps a source operator's attributes, which come from its own table rather than an input port", () => { + // ScanSource declares no input ports, so its attributes must survive untouched + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const operatorID = mockScanPredicate.operatorID; + givePropagatedAttributeEnum(operatorID); + const propagated = dynamicSchemaService.getDynamicSchema(operatorID); + + vi.spyOn(service, "getOperatorInputSchemaMap").mockReturnValue(undefined); + const setDynamicSchema = vi.spyOn(dynamicSchemaService, "setDynamicSchema"); + (service as any).applySchemaPropagationResult(); + + const attribute = (dynamicSchemaService.getDynamicSchema(operatorID).jsonSchema.properties as any).attribute; + expect(attribute.enum).toEqual(["col_a", ""]); + expect(attribute.uniqueItems).toBe(true); + // the schema is unchanged, so it is never written back + expect(setDynamicSchema).not.toHaveBeenCalled(); + expect(dynamicSchemaService.getDynamicSchema(operatorID)).toBe(propagated); + }); +}); + +describe("WorkflowCompilingService input port schema resolution", () => { + let service: WorkflowCompilingService; + let workflowActionService: WorkflowActionService; + let dynamicSchemaService: DynamicSchemaService; + + const schemaA = [{ attributeName: "col_a", attributeType: "string" }]; + const schemaB = [{ attributeName: "col_b", attributeType: "integer" }]; + + beforeEach(() => { + configureCompilingTestBed(); + service = TestBed.inject(WorkflowCompilingService); + workflowActionService = TestBed.inject(WorkflowActionService); + dynamicSchemaService = TestBed.inject(DynamicSchemaService); + }); + + /** Overwrites the private compilation-state snapshot the resolution logic reads the output schemas from. */ + const setOutputSchemas = (operatorOutputPortSchemaMap: unknown): void => { + (service as any).currentCompilationStateInfo = { + state: CompilationState.Succeeded, + physicalPlan: { operators: [], links: [] }, + operatorOutputPortSchemaMap, + }; + }; + + const addOperators = (...predicates: OperatorPredicate[]): void => + predicates.forEach(predicate => workflowActionService.addOperator(predicate, mockPoint)); + + const link = (sourceID: string, sourcePort: string, targetID: string, targetPort: string, linkID: string) => + workflowActionService.addLink({ + linkID, + source: { operatorID: sourceID, portID: sourcePort }, + target: { operatorID: targetID, portID: targetPort }, + }); + + it("ignores an input link whose target port ID is not in the input-<n> form", () => { + const target: OperatorPredicate = { ...mockSentimentPredicate, inputPorts: [{ portID: "the-input" }] }; + addOperators(mockScanPredicate, target); + link(mockScanPredicate.operatorID, "output-0", target.operatorID, "the-input", "link-bad-target"); + setOutputSchemas({ [mockScanPredicate.operatorID]: { [port(0)]: schemaA } }); + + const inputSchemaMap = service.getOperatorInputSchemaMap(target.operatorID); + + expect(Object.keys(inputSchemaMap!)).toEqual([port(0)]); + expect(inputSchemaMap![port(0)]).toBeUndefined(); + }); + + it("ignores an input link whose source port ID is not in the output-<n> form", () => { + const source: OperatorPredicate = { ...mockScanPredicate, outputPorts: [{ portID: "the-output" }] }; + addOperators(source, mockSentimentPredicate); + link(source.operatorID, "the-output", mockSentimentPredicate.operatorID, "input-0", "link-bad-source"); + setOutputSchemas({ [source.operatorID]: { [port(0)]: schemaA } }); + + const inputSchemaMap = service.getOperatorInputSchemaMap(mockSentimentPredicate.operatorID); + + expect(Object.keys(inputSchemaMap!)).toEqual([port(0)]); + expect(inputSchemaMap![port(0)]).toBeUndefined(); + }); + + it("resolves no schema when the upstream operator is missing from the output schema map", () => { + addOperators(mockScanPredicate, mockSentimentPredicate); + link(mockScanPredicate.operatorID, "output-0", mockSentimentPredicate.operatorID, "input-0", "link-scan-sentiment"); + // the map holds a schema, but for a different operator + setOutputSchemas({ "some-other-operator": { [port(0)]: schemaA } }); + + const inputSchemaMap = service.getOperatorInputSchemaMap(mockSentimentPredicate.operatorID); + + expect(Object.keys(inputSchemaMap!)).toEqual([port(0)]); + expect(inputSchemaMap![port(0)]).toBeUndefined(); + }); + + it("leaves unlinked input ports unresolved and puts the schema on the linked port only", () => { + // MultiInputOutput declares three input ports; only input-1 is wired up + addOperators(mockScanPredicate, mockMultiInputOutputPredicate); + link( + mockScanPredicate.operatorID, + "output-0", + mockMultiInputOutputPredicate.operatorID, + "input-1", + "link-scan-multi" + ); + setOutputSchemas({ [mockScanPredicate.operatorID]: { [port(0)]: schemaA } }); + + const inputSchemaMap = service.getOperatorInputSchemaMap(mockMultiInputOutputPredicate.operatorID); + + expect(Object.keys(inputSchemaMap!)).toEqual([port(0), port(1), port(2)]); + expect(inputSchemaMap![port(0)]).toBeUndefined(); + expect(inputSchemaMap![port(1)]).toEqual(schemaA); + expect(inputSchemaMap![port(2)]).toBeUndefined(); + }); + + it("accepts two links into the same input port when they agree on the schema", () => { + const secondScan: OperatorPredicate = { ...mockScanPredicate, operatorID: "scan-2" }; + addOperators(mockScanPredicate, secondScan, mockSentimentPredicate); + link(mockScanPredicate.operatorID, "output-0", mockSentimentPredicate.operatorID, "input-0", "link-scan-1"); + link(secondScan.operatorID, "output-0", mockSentimentPredicate.operatorID, "input-0", "link-scan-2"); + setOutputSchemas({ + [mockScanPredicate.operatorID]: { [port(0)]: schemaA }, + [secondScan.operatorID]: { [port(0)]: [...schemaA] }, + }); + + const inputSchemaMap = service.getOperatorInputSchemaMap(mockSentimentPredicate.operatorID); + + expect(inputSchemaMap![port(0)]).toEqual(schemaA); + expect(service.getWorkflowCompilationState()).toBe(CompilationState.Succeeded); + }); + + it("fails compilation when two links into the same input port disagree on the schema", () => { + const secondScan: OperatorPredicate = { ...mockScanPredicate, operatorID: "scan-2" }; + addOperators(mockScanPredicate, secondScan, mockSentimentPredicate); + link(mockScanPredicate.operatorID, "output-0", mockSentimentPredicate.operatorID, "input-0", "link-scan-1"); + link(secondScan.operatorID, "output-0", mockSentimentPredicate.operatorID, "input-0", "link-scan-2"); + setOutputSchemas({ + [mockScanPredicate.operatorID]: { [port(0)]: schemaA }, + [secondScan.operatorID]: { [port(0)]: schemaB }, + }); + + const inputSchemaMap = service.getOperatorInputSchemaMap(mockSentimentPredicate.operatorID); + + // the conflicting port is left unresolved and the compilation is marked failed + expect(inputSchemaMap![port(0)]).toBeUndefined(); + expect(service.getWorkflowCompilationState()).toBe(CompilationState.Failed); + const error = service.getWorkflowCompilationErrors()[mockSentimentPredicate.operatorID]; + expect(error.message).toBe("Multiple links with different schemas connected to the same input port 0"); + expect(error.details).toBe("Port 0 received 2 different schemas (some may be undefined)"); + expect(error.operatorId).toBe(mockSentimentPredicate.operatorID); + }); + + it("resolves nothing for an operator whose dynamic schema declares no input ports", () => { + addOperators(mockScanPredicate, mockSentimentPredicate); + link(mockScanPredicate.operatorID, "output-0", mockSentimentPredicate.operatorID, "input-0", "link-scan-sentiment"); + const base = dynamicSchemaService.getDynamicSchema(mockSentimentPredicate.operatorID); + dynamicSchemaService.setDynamicSchema(mockSentimentPredicate.operatorID, { + ...base, + additionalMetadata: { ...base.additionalMetadata, inputPorts: [] }, + }); + setOutputSchemas({ [mockScanPredicate.operatorID]: { [port(0)]: schemaA } }); + + expect(service.getOperatorInputSchemaMap(mockSentimentPredicate.operatorID)).toBeUndefined(); + }); +});
