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();
+  });
+});

Reply via email to