You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/07/12 02:07:00 UTC
(texera) branch main updated (00480e85b4 -> 477311780b)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a change to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
from 00480e85b4 test(frontend): add unit test coverage for parseLogicalOperatorPortID (#6345)
new 95b9935cce test(frontend): extend WorkflowActionService coverage (#6352)
new 5a7317c6b6 test(frontend): extend OperatorPropertyEditFrameComponent coverage (#6351)
new 20bc526be3 test(agent-service): add unit test coverage for assembleContext (#6354)
new 477311780b test(agent-service): add unit test coverage for extractOperatorInputPortSchemaMap (#6353)
The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
agent-service/src/agent/util/context-utils.spec.ts | 103 +++++++
.../src/agent/util/workflow-utils.spec.ts | 99 +++++++
.../operator-property-edit-frame.component.spec.ts | 324 ++++++++++++++++++++-
.../model/workflow-action.service.spec.ts | 315 +++++++++++++++++++-
4 files changed, 838 insertions(+), 3 deletions(-)
create mode 100644 agent-service/src/agent/util/context-utils.spec.ts
create mode 100644 agent-service/src/agent/util/workflow-utils.spec.ts
(texera) 04/04: test(agent-service): add unit test coverage for extractOperatorInputPortSchemaMap (#6353)
Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 477311780bb4bb383cb47382e364d48517b31d6b
Author: Meng Wang <me...@uci.edu>
AuthorDate: Sat Jul 11 18:56:38 2026 -0700
test(agent-service): add unit test coverage for extractOperatorInputPortSchemaMap (#6353)
### What changes were proposed in this PR?
Add unit test coverage for `extractOperatorInputPortSchemaMap` in
`agent-service/src/agent/util/workflow-utils.ts` — a pure function
(type-only imports) that maps each input port of an operator to its
resolved upstream schema. A `bun test` `.spec.ts` sits next to the
source. No production-code changes.
### Any related issues, documentation, discussions?
Closes #6334.
### How was this PR tested?
```
bun test src/agent/util/workflow-utils.spec.ts
```
6 pass. `bun run typecheck` and `bun run format:check` are clean. The
spec is pure — it builds `OperatorPredicate` / `OperatorLink` / schema
fixtures in-process and covers: no input links → `undefined`; a single
port → its keyed schema; an absent source operator; an unparseable
source `portID`; link-to-port routing by target port number; and
multiple input ports each keyed separately.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8 [1M context])
---
.../src/agent/util/workflow-utils.spec.ts | 99 ++++++++++++++++++++++
1 file changed, 99 insertions(+)
diff --git a/agent-service/src/agent/util/workflow-utils.spec.ts b/agent-service/src/agent/util/workflow-utils.spec.ts
new file mode 100644
index 0000000000..b8fd82dfd9
--- /dev/null
+++ b/agent-service/src/agent/util/workflow-utils.spec.ts
@@ -0,0 +1,99 @@
+/**
+ * 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 { describe, expect, test } from "bun:test";
+import { extractOperatorInputPortSchemaMap } from "./workflow-utils";
+import type { OperatorLink, OperatorPredicate, PortSchema } from "../../types/workflow";
+
+const TARGET = "target";
+
+function operatorWithInputPorts(count: number): OperatorPredicate {
+ return {
+ operatorID: TARGET,
+ operatorType: "TestOp",
+ operatorVersion: "1.0",
+ operatorProperties: {},
+ inputPorts: Array.from({ length: count }, (_, i) => ({ portID: `input-${i}` })),
+ outputPorts: [],
+ showAdvanced: false,
+ };
+}
+
+function link(linkID: string, sourceOp: string, sourcePort: string, targetPort: string): OperatorLink {
+ return {
+ linkID,
+ source: { operatorID: sourceOp, portID: sourcePort },
+ target: { operatorID: TARGET, portID: targetPort },
+ };
+}
+
+const schemaA: PortSchema = [{ attributeName: "a", attributeType: "string" }];
+const schemaB: PortSchema = [{ attributeName: "b", attributeType: "integer" }];
+
+describe("extractOperatorInputPortSchemaMap", () => {
+ test("returns undefined when the operator has no input links", () => {
+ expect(extractOperatorInputPortSchemaMap(TARGET, operatorWithInputPorts(1), {}, [])).toBeUndefined();
+ });
+
+ test("maps a single input port to its resolved upstream schema", () => {
+ const links = [link("l0", "src", "output-0", "input-0")];
+ const outputSchemas = { src: { "0_false": schemaA } };
+ expect(extractOperatorInputPortSchemaMap(TARGET, operatorWithInputPorts(1), outputSchemas, links)).toEqual({
+ "0_false": schemaA,
+ });
+ });
+
+ test("returns undefined when the only source operator is absent from outputSchemas", () => {
+ const links = [link("l0", "missing", "output-0", "input-0")];
+ expect(extractOperatorInputPortSchemaMap(TARGET, operatorWithInputPorts(1), {}, links)).toBeUndefined();
+ });
+
+ test("keys each input port separately and routes links by target port number", () => {
+ const links = [link("l0", "srcA", "output-0", "input-0"), link("l1", "srcB", "output-0", "input-1")];
+ const outputSchemas = {
+ srcA: { "0_false": schemaA },
+ srcB: { "0_false": schemaB },
+ };
+ expect(extractOperatorInputPortSchemaMap(TARGET, operatorWithInputPorts(2), outputSchemas, links)).toEqual({
+ "0_false": schemaA,
+ "1_false": schemaB,
+ });
+ });
+
+ test("leaves a port undefined when its source operator is absent from outputSchemas", () => {
+ const links = [link("l0", "srcA", "output-0", "input-0"), link("l1", "missing", "output-0", "input-1")];
+ const outputSchemas = { srcA: { "0_false": schemaA } };
+ expect(extractOperatorInputPortSchemaMap(TARGET, operatorWithInputPorts(2), outputSchemas, links)).toEqual({
+ "0_false": schemaA,
+ "1_false": undefined,
+ });
+ });
+
+ test("leaves a port undefined when the source portID is unparseable", () => {
+ const links = [link("l0", "srcA", "output-0", "input-0"), link("l1", "srcB", "not-a-port", "input-1")];
+ const outputSchemas = {
+ srcA: { "0_false": schemaA },
+ srcB: { "0_false": schemaB },
+ };
+ expect(extractOperatorInputPortSchemaMap(TARGET, operatorWithInputPorts(2), outputSchemas, links)).toEqual({
+ "0_false": schemaA,
+ "1_false": undefined,
+ });
+ });
+});
(texera) 02/04: test(frontend): extend OperatorPropertyEditFrameComponent coverage (#6351)
Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 5a7317c6b6d187e4d24b157cd2a9c2d97ae8ea4d
Author: Xinyuan Lin <xi...@uci.edu>
AuthorDate: Sat Jul 11 18:41:56 2026 -0700
test(frontend): extend OperatorPropertyEditFrameComponent coverage (#6351)
### What changes were proposed in this PR?
Extends the `OperatorPropertyEditFrameComponent` spec (was ~49% covered)
to exercise the schema-driven validators and field-type mapping:
`attributeTypeRules` enum/const-$data/allOf rules, `inEnum`, FileScanOp
`outputFileName` hide,
datasetversionselector/repeat-section-dnd/codearea mapping,
`checkOperatorProperty`, `typeInferenceOnLambdaFunction`, Python-UDF env
schema, `setInteractivity`, and the property-change loop guard.
### Any related issues, documentation, discussions?
Closes #6348.
### How was this PR tested?
Added 21 tests to the existing spec (now 154), reusing its
StubOperatorMetadataService + jointjs-graph harness and a
WorkflowCompilingService spy. Verified locally with Vitest (`ng test
--watch=false --include=...`) and CI's `format:ci` gate (prettier-eslint
+ eslint) clean. Test-only change — no production code is modified.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8 [1M context])
Co-authored-by: probe <pr...@x>
---
.../operator-property-edit-frame.component.spec.ts | 324 ++++++++++++++++++++-
1 file changed, 323 insertions(+), 1 deletion(-)
diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
index f474490083..267b686b10 100644
--- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
+++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
@@ -25,13 +25,15 @@ import {
OperatorPropertyEditFrameComponent,
} from "./operator-property-edit-frame.component";
import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service";
+import { WorkflowCompilingService } from "../../../service/compile-workflow/workflow-compiling.service";
+import { CustomJSONSchema7 } from "../../../types/custom-json-schema.interface";
import { OperatorMetadataService } from "../../../service/operator-metadata/operator-metadata.service";
import { StubOperatorMetadataService } from "../../../service/operator-metadata/stub-operator-metadata.service";
import { FORM_DEBOUNCE_TIME_MS } from "../../../service/execute-workflow/execute-workflow.service";
import { DatePipe } from "@angular/common";
import { By } from "@angular/platform-browser";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
-import { FormsModule, ReactiveFormsModule } from "@angular/forms";
+import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { FormlyFieldConfig, FormlyModule } from "@ngx-formly/core";
import { TEXERA_FORMLY_CONFIG } from "../../../../common/formly/formly-config";
import { HttpClientTestingModule } from "@angular/common/http/testing";
@@ -1539,4 +1541,324 @@ describe("OperatorPropertyEditFrameComponent", () => {
const hideFn = (field?.expressions as Record<string, Function>)?.["hide"];
expect(hideFn({ model: {} } as FormlyFieldConfig)).toBe(true);
});
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // attributeTypeRules validator (checkAttributeType) — the root-field validator
+ // added by setFormlyFormBinding when a schema declares attributeTypeRules.
+ // getOperatorInputAttributeType is stubbed on the (root-provided) compiling service.
+ // ──────────────────────────────────────────────────────────────────────────
+ describe("attributeTypeRules validator (checkAttributeType)", () => {
+ let compiling: WorkflowCompilingService;
+
+ beforeEach(() => {
+ compiling = TestBed.inject(WorkflowCompilingService);
+ component.currentOperatorId = "attr-rules-op";
+ });
+
+ // Binds a crafted schema and returns the root field's checkAttributeType validator.
+ function bindSchema(schema: CustomJSONSchema7): any {
+ component.setFormlyFormBinding(schema);
+ return (component.formlyFields?.[0] as any)?.validators?.checkAttributeType;
+ }
+
+ function rootField(): FormlyFieldConfig {
+ return component.formlyFields![0];
+ }
+
+ it("enum rule fails when the input attribute type is not in the enum", () => {
+ const validator = bindSchema({
+ type: "object",
+ properties: { attr: { type: "string", autofillAttributeOnPort: 0 } },
+ attributeTypeRules: { attr: { enum: ["integer"] } },
+ });
+ const spy = vi.spyOn(compiling, "getOperatorInputAttributeType").mockReturnValue("string");
+ const field = rootField();
+ expect(validator.expression({ value: { attr: "colA" } } as any, field)).toBe(false);
+ expect((field as any).validators.checkAttributeType.message).toContain(
+ "is string, but it's expected to be integer"
+ );
+ expect(spy).toHaveBeenCalledWith("attr-rules-op", 0, "colA");
+ });
+
+ it("enum rule passes when the input attribute type matches the enum", () => {
+ const validator = bindSchema({
+ type: "object",
+ properties: { attr: { type: "string", autofillAttributeOnPort: 0 } },
+ attributeTypeRules: { attr: { enum: ["integer"] } },
+ });
+ vi.spyOn(compiling, "getOperatorInputAttributeType").mockReturnValue("integer");
+ expect(validator.expression({ value: { attr: "colA" } } as any, rootField())).toBe(true);
+ });
+
+ it("enum rule is skipped when the attribute type is undefined (attribute not selected)", () => {
+ const validator = bindSchema({
+ type: "object",
+ properties: { attr: { type: "string", autofillAttributeOnPort: 0 } },
+ attributeTypeRules: { attr: { enum: ["integer"] } },
+ });
+ vi.spyOn(compiling, "getOperatorInputAttributeType").mockReturnValue(undefined);
+ expect(validator.expression({ value: { attr: "" } } as any, rootField())).toBe(true);
+ });
+
+ it("const $data rule fails when the sibling attribute resolves to a different type", () => {
+ const validator = bindSchema({
+ type: "object",
+ properties: {
+ attr: { type: "string", autofillAttributeOnPort: 0 },
+ other: { type: "string", autofillAttributeOnPort: 0 },
+ },
+ attributeTypeRules: { attr: { const: { $data: "other" } } },
+ });
+ vi.spyOn(compiling, "getOperatorInputAttributeType").mockImplementation((_id, _port, name) =>
+ name === "colA" ? "string" : "integer"
+ );
+ const field = rootField();
+ expect(validator.expression({ value: { attr: "colA", other: "colB" } } as any, field)).toBe(false);
+ expect((field as any).validators.checkAttributeType.message).toContain("expected to be the same type as 'colB'");
+ });
+
+ it("const $data rule is skipped when the sibling attribute type is not yet resolved", () => {
+ const validator = bindSchema({
+ type: "object",
+ properties: {
+ attr: { type: "string", autofillAttributeOnPort: 0 },
+ other: { type: "string", autofillAttributeOnPort: 0 },
+ },
+ attributeTypeRules: { attr: { const: { $data: "other" } } },
+ });
+ vi.spyOn(compiling, "getOperatorInputAttributeType").mockImplementation((_id, _port, name) =>
+ name === "colA" ? "string" : undefined
+ );
+ expect(validator.expression({ value: { attr: "colA", other: "" } } as any, rootField())).toBe(true);
+ });
+
+ it("allOf if/then rule fails when the if-condition holds but the then-enum is violated", () => {
+ const validator = bindSchema({
+ type: "object",
+ properties: {
+ attr: { type: "string", autofillAttributeOnPort: 0 },
+ mode: { type: "string" },
+ },
+ attributeTypeRules: {
+ attr: { allOf: [{ if: { mode: { valEnum: ["strict"] } }, then: { enum: ["integer"] } }] },
+ },
+ });
+ vi.spyOn(compiling, "getOperatorInputAttributeType").mockReturnValue("string");
+ const field = rootField();
+ expect(validator.expression({ value: { attr: "colA", mode: "strict" } } as any, field)).toBe(false);
+ expect((field as any).validators.checkAttributeType.message).toContain("given that 'mode' is strict");
+ });
+
+ it("allOf if/then rule passes when the if-condition is not satisfied", () => {
+ const validator = bindSchema({
+ type: "object",
+ properties: {
+ attr: { type: "string", autofillAttributeOnPort: 0 },
+ mode: { type: "string" },
+ },
+ attributeTypeRules: {
+ attr: { allOf: [{ if: { mode: { valEnum: ["strict"] } }, then: { enum: ["integer"] } }] },
+ },
+ });
+ vi.spyOn(compiling, "getOperatorInputAttributeType").mockReturnValue("string");
+ expect(validator.expression({ value: { attr: "colA", mode: "loose" } } as any, rootField())).toBe(true);
+ });
+ });
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Field-level validators / custom field-type mapping in setFormlyFormBinding
+ // ──────────────────────────────────────────────────────────────────────────
+ describe("field-level validators and type mapping", () => {
+ function getField(key: string): FormlyFieldConfig | undefined {
+ return component.formlyFields?.[0]?.fieldGroup?.find(f => f.key === key);
+ }
+
+ it("adds an inEnum validator that rejects values no longer present in the schema enum", () => {
+ component.setFormlyFormBinding({
+ type: "object",
+ properties: { color: { type: "string", enum: ["red", "green"] } },
+ });
+ const validator = getField("color")?.validators?.["inEnum"];
+ expect(validator).toBeDefined();
+ expect(validator!.expression({ value: "blue" } as any)).toBe(false);
+ expect(validator!.expression({ value: "red" } as any)).toBe(true);
+ expect(validator!.message(null, { formControl: { value: "blue" } } as any)).toBe(
+ '"blue" is no longer a valid option'
+ );
+ });
+
+ it("maps datasetVersionPath to the datasetversionselector field type", () => {
+ component.setFormlyFormBinding({
+ type: "object",
+ properties: { datasetVersionPath: { type: "string" } },
+ });
+ expect(getField("datasetVersionPath")?.type).toBe("datasetversionselector");
+ });
+
+ it("maps a field described as 'Input your code here' to the codearea field type", () => {
+ component.setFormlyFormBinding({
+ type: "object",
+ properties: { code: { type: "string", description: "Input your code here" } },
+ });
+ expect(getField("code")?.type).toBe("codearea");
+ });
+ });
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Operator-type-specific field behavior (FileScanOp hide, Projection reorder)
+ // ──────────────────────────────────────────────────────────────────────────
+ describe("operator-type-specific field behavior", () => {
+ function getField(key: string): FormlyFieldConfig | undefined {
+ return component.formlyFields?.[0]?.fieldGroup?.find(f => f.key === key);
+ }
+
+ it("hides FileScanOp outputFileName unless extract is on or the type is a string/binary type", () => {
+ component.currentOperatorSchema = { operatorType: "FileScanOp" } as any;
+ component.setFormlyFormBinding({
+ type: "object",
+ properties: { outputFileName: { type: "string" } },
+ });
+ const hide = (getField("outputFileName")?.expressions as Record<string, Function>)["hide"];
+ expect(hide({ model: {} } as FormlyFieldConfig)).toBe(true);
+ expect(hide({ model: { extract: true } } as FormlyFieldConfig)).toBe(false);
+ expect(hide({ model: { attributeType: "single string" } } as FormlyFieldConfig)).toBe(false);
+ expect(hide({ model: { attributeType: "binary" } } as FormlyFieldConfig)).toBe(false);
+ expect(hide({ model: { attributeType: "large binary" } } as FormlyFieldConfig)).toBe(false);
+ });
+
+ it("maps Projection attributes to repeat-section-dnd and proxies reorder() to onFormChanges", () => {
+ component.currentOperatorSchema = { operatorType: "Projection" } as any;
+ component.formData = { attributes: ["colA"] };
+ component.setFormlyFormBinding({
+ type: "object",
+ properties: { attributes: { type: "array", items: { type: "string" } } },
+ });
+ const field = getField("attributes");
+ expect(field?.type).toBe("repeat-section-dnd");
+ const spy = vi.spyOn(component, "onFormChanges").mockImplementation(() => {});
+ (field?.props as any).reorder();
+ expect(spy).toHaveBeenCalledWith({ attributes: ["colA"] });
+ });
+ });
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // checkOperatorProperty — the guard used by the debounced form-change stream
+ // ──────────────────────────────────────────────────────────────────────────
+ describe("checkOperatorProperty", () => {
+ it("returns false when no operator is being displayed", () => {
+ component.currentOperatorId = undefined;
+ expect(component.checkOperatorProperty({})).toBe(false);
+ });
+
+ it("returns true only when the form data differs from the stored operator properties", () => {
+ workflowActionService.addOperator(mockScanPredicate, mockPoint);
+ component.currentOperatorId = mockScanPredicate.operatorID;
+ // mockScanPredicate.operatorProperties is {}
+ expect(component.checkOperatorProperty({})).toBe(false);
+ expect(component.checkOperatorProperty({ tableName: "twitter" })).toBe(true);
+ });
+ });
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // typeInferenceOnLambdaFunction — writes inferred attribute types back into
+ // the form data for PythonLambdaFunction operators.
+ // ──────────────────────────────────────────────────────────────────────────
+ describe("typeInferenceOnLambdaFunction", () => {
+ it("does nothing for non-lambda operators", () => {
+ component.currentOperatorId = "ScanSource-op";
+ const formData = { lambdaAttributeUnits: [{ attributeName: "colA", attributeType: "keep" }] };
+ component.typeInferenceOnLambdaFunction(formData);
+ expect(formData.lambdaAttributeUnits[0].attributeType).toBe("keep");
+ });
+
+ it("infers attribute types from the input schema and clears empty 'Add New Column' units", () => {
+ const compiling = TestBed.inject(WorkflowCompilingService);
+ vi.spyOn(compiling, "getOperatorInputSchemaMap").mockReturnValue({
+ "0": [{ attributeName: "colA", attributeType: "integer" }],
+ } as any);
+ component.currentOperatorId = "PythonLambdaFunction-op";
+ const formData = {
+ lambdaAttributeUnits: [
+ { attributeName: "Add New Column", newAttributeName: "", attributeType: "stale" },
+ { attributeName: "colA", attributeType: "stale" },
+ ],
+ };
+ component.typeInferenceOnLambdaFunction(formData);
+ expect(formData.lambdaAttributeUnits[0].attributeType).toBe("");
+ expect(formData.lambdaAttributeUnits[1].attributeType).toBe("integer");
+ });
+ });
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // setInteractivity — enable/disable the whole formly form group
+ // ──────────────────────────────────────────────────────────────────────────
+ describe("setInteractivity", () => {
+ it("disables and re-enables every control in the form group", () => {
+ const group = new FormGroup({ a: new FormControl("x") });
+ component.formlyFormGroup = group;
+
+ component.setInteractivity(false);
+ expect(component.interactive).toBe(false);
+ expect(group.disabled).toBe(true);
+ expect(group.get("a")!.disabled).toBe(true);
+
+ component.setInteractivity(true);
+ expect(component.interactive).toBe(true);
+ expect(group.enabled).toBe(true);
+ expect(group.get("a")!.enabled).toBe(true);
+ });
+
+ it("only updates the interactive flag when there is no form group", () => {
+ component.formlyFormGroup = undefined;
+ expect(() => component.setInteractivity(true)).not.toThrow();
+ expect(component.interactive).toBe(true);
+ });
+ });
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Python UDF environment schema patching
+ // ──────────────────────────────────────────────────────────────────────────
+ describe("Python UDF environment schema patching", () => {
+ it("injects the environment list into the envName enum without mutating the original schema", () => {
+ const schema = { type: "object", properties: { envName: { type: "string" } } } as CustomJSONSchema7;
+ const patched = (component as any).patchPythonUdfEnvironmentSchema(schema, ["env-a", "env-b"]);
+ expect((patched.properties.envName as CustomJSONSchema7).enum).toEqual(["env-a", "env-b"]);
+ expect((schema.properties!.envName as CustomJSONSchema7).enum).toBeUndefined();
+ });
+
+ it("hides envName and makes it optional when the default environment is checked", () => {
+ component.setFormlyFormBinding({
+ type: "object",
+ properties: { envName: { type: "string" }, defaultEnv: { type: "boolean" } },
+ });
+ (component as any).hideEnvNameWhenDefaultEnvChecked();
+ const envField = component.formlyFields?.[0]?.fieldGroup?.find(f => f.key === "envName");
+ expect((envField?.expressions as any).hide).toBe("!!field.parent.model.defaultEnv");
+ expect((envField?.expressions as any)["props.required"]).toBe("!field.parent.model.defaultEnv");
+ });
+ });
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // registerOperatorPropertyChangeHandler — program-driven property changes
+ // refresh formData, guarded by listeningToChange to avoid an echo loop.
+ // ──────────────────────────────────────────────────────────────────────────
+ describe("registerOperatorPropertyChangeHandler loop guard", () => {
+ it("refreshes formData on graph property changes only while listeningToChange is true", () => {
+ workflowActionService.addOperator(mockScanPredicate, mockPoint);
+ component.ngOnChanges({
+ currentOperatorId: new SimpleChange(undefined, mockScanPredicate.operatorID, true),
+ });
+ fixture.detectChanges(); // runs ngOnInit, which registers the handler
+
+ // loop guard active: a change echoed while listeningToChange is false must not touch formData
+ component.listeningToChange = false;
+ workflowActionService.setOperatorProperty(mockScanPredicate.operatorID, { marker: "blocked" });
+ expect(component.formData?.marker).toBeUndefined();
+
+ // normal program-driven change: formData is refreshed from the graph
+ component.listeningToChange = true;
+ workflowActionService.setOperatorProperty(mockScanPredicate.operatorID, { marker: "allowed" });
+ expect(component.formData.marker).toBe("allowed");
+ });
+ });
});
(texera) 01/04: test(frontend): extend WorkflowActionService coverage (#6352)
Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 95b9935ccee88c2be06c5a6484c566fd6d3d93ac
Author: Xinyuan Lin <xi...@uci.edu>
AuthorDate: Sat Jul 11 18:41:53 2026 -0700
test(frontend): extend WorkflowActionService coverage (#6352)
### What changes were proposed in this PR?
Extends the `WorkflowActionService` spec (was ~52% covered):
`reloadWorkflow`/`clearWorkflow`/`resetAsNewWorkflow`,
metadata/name/settings/batch-size/execution-mode/published setters,
temp-workflow yjs ws-provider connect/disconnect, add/remove port,
disable/reuse/view-result sets, highlight ports/elements,
`setOperatorVersion`/`setPortProperty`, and auto-layout positioning.
### Any related issues, documentation, discussions?
Closes #6349.
### How was this PR tested?
Added tests to the existing spec (now 37), reusing its real
TexeraGraph/joint-graph harness with undo-redo and ws-provider spies.
Verified locally with Vitest (`ng test --watch=false --include=...`) and
CI's `format:ci` gate (prettier-eslint + eslint) clean. Test-only change
— no production code is modified.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8 [1M context])
---------
Co-authored-by: probe <pr...@x>
---
.../model/workflow-action.service.spec.ts | 315 ++++++++++++++++++++-
1 file changed, 313 insertions(+), 2 deletions(-)
diff --git a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts
index 4ddf3b9006..d69995dce7 100644
--- a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts
+++ b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts
@@ -26,6 +26,7 @@ import {
mockCommentBox,
mockFalseResultSentimentLink,
mockFalseSentimentScanLink,
+ mockMultiInputOutputPredicate,
mockPoint,
mockResultPredicate,
mockScanPredicate,
@@ -36,10 +37,11 @@ import {
} from "./mock-workflow-data";
import { inject, TestBed } from "@angular/core/testing";
-import { WorkflowActionService } from "./workflow-action.service";
-import { OperatorPredicate } from "../../../types/workflow-common.interface";
+import { DEFAULT_WORKFLOW, DEFAULT_WORKFLOW_NAME, WorkflowActionService } from "./workflow-action.service";
+import { LogicalPort, OperatorPredicate } from "../../../types/workflow-common.interface";
import { WorkflowUtilService } from "../util/workflow-util.service";
import { commonTestProviders } from "../../../../common/testing/test-utils";
+import { ExecutionMode, Workflow, WorkflowSettings } from "../../../../common/type/workflow";
describe("WorkflowActionService", () => {
let service: WorkflowActionService;
@@ -277,4 +279,313 @@ describe("WorkflowActionService", () => {
// expect(sentimentOpPos).toEqual(mockPoint);
// expect(resultOpPos).toEqual(mockPoint);
});
+
+ it("should reload a workflow, repopulating the graph and clearing the undo/redo stacks", () => {
+ const settings: WorkflowSettings = {
+ dataTransferBatchSize: 250,
+ executionMode: ExecutionMode.MATERIALIZED,
+ };
+ const workflow: Workflow = {
+ ...DEFAULT_WORKFLOW,
+ name: "Reloaded WF",
+ content: {
+ operators: [mockScanPredicate, mockResultPredicate],
+ operatorPositions: {
+ [mockScanPredicate.operatorID]: mockPoint,
+ [mockResultPredicate.operatorID]: mockPoint,
+ },
+ links: [mockScanResultLink],
+ commentBoxes: [{ ...mockCommentBox, commentBoxID: "commentBox-1" }],
+ settings,
+ },
+ };
+ const clearUndoSpy = vi.spyOn(undoRedo, "clearUndoStack");
+ const clearRedoSpy = vi.spyOn(undoRedo, "clearRedoStack");
+ const restoreSpy = vi.spyOn(service.getJointGraphWrapper(), "restoreDefaultZoomAndOffset");
+
+ service.reloadWorkflow(workflow);
+
+ expect(texeraGraph.hasOperator(mockScanPredicate.operatorID)).toBeTruthy();
+ expect(texeraGraph.hasOperator(mockResultPredicate.operatorID)).toBeTruthy();
+ expect(texeraGraph.hasLinkWithID(mockScanResultLink.linkID)).toBeTruthy();
+ expect(texeraGraph.hasCommentBox("commentBox-1")).toBeTruthy();
+ expect(service.getWorkflowMetadata().name).toEqual("Reloaded WF");
+ expect(service.getWorkflowSettings().dataTransferBatchSize).toEqual(250);
+ expect(clearUndoSpy).toHaveBeenCalled();
+ expect(clearRedoSpy).toHaveBeenCalled();
+ expect(restoreSpy).toHaveBeenCalled();
+ });
+
+ it("should throw when a reloaded operator is missing its position", () => {
+ const workflow: Workflow = {
+ ...DEFAULT_WORKFLOW,
+ content: {
+ operators: [mockScanPredicate],
+ operatorPositions: {},
+ links: [],
+ commentBoxes: [],
+ settings: { dataTransferBatchSize: 100, executionMode: ExecutionMode.PIPELINED },
+ },
+ };
+ expect(() => service.reloadWorkflow(workflow)).toThrowError(
+ new RegExp(`position error: ${mockScanPredicate.operatorID}`)
+ );
+ });
+
+ it("should empty the graph and skip viewport restore when reloading undefined", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ const restoreSpy = vi.spyOn(service.getJointGraphWrapper(), "restoreDefaultZoomAndOffset");
+
+ service.reloadWorkflow(undefined, false, false);
+
+ expect(texeraGraph.getAllOperators().length).toEqual(0);
+ expect(restoreSpy).not.toHaveBeenCalled();
+ });
+
+ it("should clear the workflow back to its defaults", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ service.setWorkflowName("Something");
+ service.setWorkflowDataTransferBatchSize(999);
+ service.setHighlightingEnabled(true);
+
+ service.clearWorkflow();
+
+ expect(service.getWorkflowMetadata()).toEqual(DEFAULT_WORKFLOW);
+ expect(service.getWorkflowSettings().dataTransferBatchSize).toEqual(100);
+ expect(service.getWorkflowSettings().executionMode).toEqual(ExecutionMode.PIPELINED);
+ expect(texeraGraph.getAllOperators().length).toEqual(0);
+ expect(service.getHighlightingEnabled()).toBeFalsy();
+ });
+
+ it("should emit on workflowChanged when resetting as a new workflow", () => {
+ let changed = false;
+ service.workflowChanged().subscribe(() => (changed = true));
+
+ service.resetAsNewWorkflow();
+
+ expect(changed).toBeTruthy();
+ });
+
+ it("should only emit metadata changes when the reference changes", () => {
+ const current = service.getWorkflowMetadata();
+ let emitCount = 0;
+ service.workflowMetaDataChanged().subscribe(() => (emitCount += 1));
+
+ // identical reference -> no emission
+ service.setWorkflowMetadata(current);
+ expect(emitCount).toEqual(0);
+
+ // new metadata -> emission
+ service.setWorkflowMetadata({ ...DEFAULT_WORKFLOW, name: "renamed" });
+ expect(emitCount).toEqual(1);
+ expect(service.getWorkflowMetadata().name).toEqual("renamed");
+ });
+
+ it("should set the workflow name, defaulting blank names", () => {
+ service.setWorkflowName(" ");
+ expect(service.getWorkflowMetadata().name).toEqual(DEFAULT_WORKFLOW_NAME);
+
+ service.setWorkflowName("My Workflow");
+ expect(service.getWorkflowMetadata().name).toEqual("My Workflow");
+ });
+
+ it("should manage workflow settings and publish state", () => {
+ expect(service.getWorkflowSettings().dataTransferBatchSize).toEqual(100);
+ expect(service.getWorkflowSettings().executionMode).toEqual(ExecutionMode.PIPELINED);
+
+ // non-positive batch sizes are ignored, positive ones are applied
+ service.setWorkflowDataTransferBatchSize(0);
+ expect(service.getWorkflowSettings().dataTransferBatchSize).toEqual(100);
+ service.setWorkflowDataTransferBatchSize(400);
+ expect(service.getWorkflowSettings().dataTransferBatchSize).toEqual(400);
+
+ service.updateExecutionMode(ExecutionMode.MATERIALIZED);
+ expect(service.getWorkflowSettings().executionMode).toEqual(ExecutionMode.MATERIALIZED);
+
+ service.setWorkflowIsPublished(1);
+ expect(service.getWorkflowMetadata().isPublished).toEqual(1);
+
+ const settings: WorkflowSettings = { dataTransferBatchSize: 42, executionMode: ExecutionMode.PIPELINED };
+ service.setWorkflowSettings(settings);
+ expect(service.getWorkflowSettings()).toEqual(settings);
+ });
+
+ it("should toggle the shared-editing connection through temp workflow", () => {
+ const wsProvider = texeraGraph.sharedModel.wsProvider;
+ // setTempWorkflow only disconnects when the provider is in the "should connect" state,
+ // so force it on to make the disconnect assertion deterministic.
+ (wsProvider as any).shouldConnect = true;
+ const disconnectSpy = vi.spyOn(wsProvider, "disconnect").mockImplementation(() => {});
+ const connectSpy = vi.spyOn(wsProvider, "connect").mockImplementation(() => {});
+ const tempWorkflow: Workflow = {
+ ...DEFAULT_WORKFLOW,
+ content: {
+ operators: [],
+ operatorPositions: {},
+ links: [],
+ commentBoxes: [],
+ settings: { dataTransferBatchSize: 100, executionMode: ExecutionMode.PIPELINED },
+ },
+ };
+
+ service.setTempWorkflow(tempWorkflow);
+ expect(disconnectSpy).toHaveBeenCalled();
+ expect(service.getTempWorkflow()).toBe(tempWorkflow);
+
+ service.resetTempWorkflow();
+ expect(connectSpy).toHaveBeenCalled();
+ expect(service.getTempWorkflow()).toBeUndefined();
+ });
+
+ it("should add a dynamic input port to both the texera and joint graphs", () => {
+ const dynamicOp: OperatorPredicate = {
+ ...mockMultiInputOutputPredicate,
+ dynamicInputPorts: true,
+ dynamicOutputPorts: true,
+ };
+ service.addOperator(dynamicOp, mockPoint);
+ const element = jointGraph.getCell(dynamicOp.operatorID) as joint.dia.Element;
+ const beforeInputs = texeraGraph.getOperator(dynamicOp.operatorID).inputPorts.length;
+ const beforePorts = element.getPorts().length;
+
+ service.addPort(dynamicOp.operatorID, true);
+
+ expect(texeraGraph.getOperator(dynamicOp.operatorID).inputPorts.length).toEqual(beforeInputs + 1);
+ expect(element.getPorts().length).toEqual(beforePorts + 1);
+ });
+
+ it("should honor disallowMultiInputs when adding an input port", () => {
+ const dynamicOp: OperatorPredicate = { ...mockMultiInputOutputPredicate, dynamicInputPorts: true };
+ service.addOperator(dynamicOp, mockPoint);
+
+ service.addPort(dynamicOp.operatorID, true, true);
+
+ const inputPorts = texeraGraph.getOperator(dynamicOp.operatorID).inputPorts;
+ expect(inputPorts[inputPorts.length - 1].disallowMultiInputs).toBe(true);
+ });
+
+ it("should remove the last dynamic port from both the texera and joint graphs", () => {
+ const dynamicOp: OperatorPredicate = { ...mockMultiInputOutputPredicate, dynamicInputPorts: true };
+ service.addOperator(dynamicOp, mockPoint);
+ const element = jointGraph.getCell(dynamicOp.operatorID) as joint.dia.Element;
+ // Add a dynamic input port first, then remove it — so this exercises the dynamic-port
+ // removal path (round-tripping back to the original counts) rather than just deleting a
+ // pre-existing port.
+ const baseInputs = texeraGraph.getOperator(dynamicOp.operatorID).inputPorts.length;
+ const basePorts = element.getPorts().length;
+ service.addPort(dynamicOp.operatorID, true);
+ expect(texeraGraph.getOperator(dynamicOp.operatorID).inputPorts.length).toEqual(baseInputs + 1);
+
+ service.removePort(dynamicOp.operatorID, true);
+
+ expect(texeraGraph.getOperator(dynamicOp.operatorID).inputPorts.length).toEqual(baseInputs);
+ expect(element.getPorts().length).toEqual(basePorts);
+ });
+
+ it("should throw when adding a port to an operator without dynamic ports", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ expect(() => service.addPort(mockScanPredicate.operatorID, true)).toThrowError(
+ new RegExp("does not have dynamic input ports")
+ );
+ });
+
+ it("should disable and enable operators, reflecting in the graph and change stream", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ let event: { newDisabled: readonly string[]; newEnabled: readonly string[] } | undefined;
+ texeraGraph.getDisabledOperatorsChangedStream().subscribe(e => (event = e));
+
+ service.disableOperators([mockScanPredicate.operatorID]);
+ expect(texeraGraph.getDisabledOperators().has(mockScanPredicate.operatorID)).toBeTruthy();
+ expect(event?.newDisabled).toContain(mockScanPredicate.operatorID);
+
+ service.enableOperators([mockScanPredicate.operatorID]);
+ expect(texeraGraph.getDisabledOperators().has(mockScanPredicate.operatorID)).toBeFalsy();
+ expect(event?.newEnabled).toContain(mockScanPredicate.operatorID);
+ });
+
+ it("should mark and unmark operators for result reuse", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ let event: { newReuseCacheOps: readonly string[]; newUnreuseCacheOps: readonly string[] } | undefined;
+ texeraGraph.getReuseCacheOperatorsChangedStream().subscribe(e => (event = e));
+
+ service.markReuseResults([mockScanPredicate.operatorID]);
+ expect(texeraGraph.getOperatorsMarkedForReuseResult().has(mockScanPredicate.operatorID)).toBeTruthy();
+ expect(event?.newReuseCacheOps).toContain(mockScanPredicate.operatorID);
+
+ service.removeMarkReuseResults([mockScanPredicate.operatorID]);
+ expect(texeraGraph.getOperatorsMarkedForReuseResult().has(mockScanPredicate.operatorID)).toBeFalsy();
+ expect(event?.newUnreuseCacheOps).toContain(mockScanPredicate.operatorID);
+ });
+
+ it("should set and unset operators for viewing results", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ let event: { newViewResultOps: readonly string[]; newUnviewResultOps: readonly string[] } | undefined;
+ texeraGraph.getViewResultOperatorsChangedStream().subscribe(e => (event = e));
+
+ service.setViewOperatorResults([mockScanPredicate.operatorID]);
+ expect(texeraGraph.getOperatorsToViewResult().has(mockScanPredicate.operatorID)).toBeTruthy();
+ expect(event?.newViewResultOps).toContain(mockScanPredicate.operatorID);
+
+ service.unsetViewOperatorResults([mockScanPredicate.operatorID]);
+ expect(texeraGraph.getOperatorsToViewResult().has(mockScanPredicate.operatorID)).toBeFalsy();
+ expect(event?.newUnviewResultOps).toContain(mockScanPredicate.operatorID);
+ });
+
+ it("should highlight and unhighlight ports honoring multiselect mode", () => {
+ const jointGraphWrapper = service.getJointGraphWrapper();
+ const portA: LogicalPort = { operatorID: mockScanPredicate.operatorID, portID: "output-0" };
+ const portB: LogicalPort = { operatorID: mockSentimentPredicate.operatorID, portID: "input-0" };
+
+ // multiselect on -> both ports stay highlighted
+ service.highlightPorts(true, portA);
+ service.highlightPorts(true, portB);
+ expect(jointGraphWrapper.getCurrentHighlightedPortIDs().length).toEqual(2);
+
+ service.unhighlightPorts(portA, portB);
+ expect(jointGraphWrapper.getCurrentHighlightedPortIDs().length).toEqual(0);
+
+ // multiselect off -> highlighting a new port replaces the previous one
+ service.highlightPorts(false, portA);
+ service.highlightPorts(false, portB);
+ expect(jointGraphWrapper.getCurrentHighlightedPortIDs()).toEqual([portB]);
+ });
+
+ it("should highlight mixed operator and comment-box elements with multiselect", () => {
+ const jointGraphWrapper = service.getJointGraphWrapper();
+ service.addOperator(mockScanPredicate, mockPoint);
+ const commentBox = { ...mockCommentBox, commentBoxID: "commentBox-hl" };
+ service.addCommentBox(commentBox);
+
+ service.highlightElements(true, mockScanPredicate.operatorID, commentBox.commentBoxID);
+
+ expect(jointGraphWrapper.getCurrentHighlightedOperatorIDs()).toContain(mockScanPredicate.operatorID);
+ expect(jointGraphWrapper.getCurrentHighlightedCommentBoxIDs()).toContain(commentBox.commentBoxID);
+ });
+
+ it("should update an operator's version in the graph", () => {
+ service.addOperator(mockScanPredicate, mockPoint);
+ service.setOperatorVersion(mockScanPredicate.operatorID, "scan-v2");
+ expect(texeraGraph.getOperator(mockScanPredicate.operatorID).operatorVersion).toEqual("scan-v2");
+ });
+
+ it("should set a port property on an existing port and throw on a missing one", () => {
+ service.addOperator(mockSentimentPredicate, mockPoint);
+ const port: LogicalPort = { operatorID: mockSentimentPredicate.operatorID, portID: "input-0" };
+ const portProperty = { partitionInfo: { type: "none" }, dependencies: [] };
+
+ service.setPortProperty(port, portProperty);
+ expect(texeraGraph.getPortDescription(port)?.partitionRequirement).toEqual({ type: "none" });
+
+ const missingPort: LogicalPort = { operatorID: mockSentimentPredicate.operatorID, portID: "input-99" };
+ expect(() => service.setPortProperty(missingPort, portProperty)).toThrowError(new RegExp("does not exist"));
+ });
+
+ it("should compute the top-left position across all operators", () => {
+ service.addOperator(mockScanPredicate, { x: 300, y: 400 });
+ service.addOperator(mockResultPredicate, { x: 100, y: 250 });
+
+ service.calculateTopLeftOperatorPosition();
+
+ expect(service.getCenterPoint()).toEqual({ x: 100, y: 250 });
+ });
});
(texera) 03/04: test(agent-service): add unit test coverage for assembleContext (#6354)
Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 20bc526be33acbbc7130d259758b5cf49a96c4ce
Author: Meng Wang <me...@uci.edu>
AuthorDate: Sat Jul 11 18:46:25 2026 -0700
test(agent-service): add unit test coverage for assembleContext (#6354)
### What changes were proposed in this PR?
Add unit test coverage for `assembleContext` in
`agent-service/src/agent/util/context-utils.ts` — a pure function
(type-only imports, no network) that groups `ReActStep`s by task,
serializes completed vs ongoing tasks into markdown sections, appends a
dataflow section, and returns `[{ role: "user", content }]`. A `bun
test` `.spec.ts` sits next to the source. No production-code changes.
### Any related issues, documentation, discussions?
Closes #6333.
### How was this PR tested?
```
bun test src/agent/util/context-utils.spec.ts
```
5 pass. `bun run typecheck` and `bun run format:check` are clean. The
spec is pure — it builds `ReActStep` / `WorkflowState` /
`OperatorPredicate` fixtures in-process and covers: empty steps → a
single user message with no task sections; a completed task under `#
Completed Tasks` (`## Task (completed)` with the request + turns); an
unfinished task under `# Ongoing Task`; a `# Current Dataflow` section
when the workflow has operators; and steps sharing a `messageId` grouped
into one task.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8 [1M context])
---------
Signed-off-by: Meng Wang <me...@uci.edu>
Co-authored-by: Copilot Autofix powered by AI <17...@users.noreply.github.com>
---
agent-service/src/agent/util/context-utils.spec.ts | 103 +++++++++++++++++++++
1 file changed, 103 insertions(+)
diff --git a/agent-service/src/agent/util/context-utils.spec.ts b/agent-service/src/agent/util/context-utils.spec.ts
new file mode 100644
index 0000000000..84af9df3bb
--- /dev/null
+++ b/agent-service/src/agent/util/context-utils.spec.ts
@@ -0,0 +1,103 @@
+/**
+ * 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 { describe, expect, test } from "bun:test";
+import type { ModelMessage } from "ai";
+import { assembleContext } from "./context-utils";
+import { WorkflowState } from "../workflow-state";
+import type { ReActStep } from "../../types/agent";
+import type { OperatorPredicate } from "../../types/workflow";
+
+function step(messageId: string, role: "user" | "agent", stepId: number, content: string, isEnd: boolean): ReActStep {
+ return {
+ id: `${messageId}-${stepId}`,
+ messageId,
+ stepId,
+ timestamp: 0,
+ role,
+ content,
+ isBegin: true,
+ isEnd,
+ };
+}
+
+function makeOperator(id: string): OperatorPredicate {
+ return {
+ operatorID: id,
+ operatorType: "TestOp",
+ operatorVersion: "1.0",
+ operatorProperties: {},
+ inputPorts: [],
+ outputPorts: [],
+ showAdvanced: false,
+ };
+}
+
+// assembleContext always returns a single user message whose content is a joined string.
+function contentOf(result: ModelMessage[]): string {
+ expect(result).toHaveLength(1);
+ expect(result[0].role).toBe("user");
+ expect(typeof result[0].content).toBe("string");
+ return result[0].content as string;
+}
+
+describe("assembleContext", () => {
+ test("returns a single user message with no task sections for empty steps", () => {
+ const result = assembleContext([], new WorkflowState(), new Map());
+ expect(result).toHaveLength(1);
+ expect(result[0].role).toBe("user");
+ expect(contentOf(result)).toBe("");
+ });
+
+ test("renders a completed task under a Completed Tasks heading", () => {
+ const steps = [step("m1", "user", 0, "do X", true), step("m1", "agent", 1, "thinking", true)];
+ const content = contentOf(assembleContext(steps, new WorkflowState(), new Map()));
+ expect(content).toContain("# Completed Tasks");
+ expect(content).toContain("## Task (completed)");
+ expect(content).toContain("do X"); // the user request
+ expect(content).toContain("### Turn 1"); // the agent step
+ });
+
+ test("renders an unfinished task under an Ongoing Task heading", () => {
+ const steps = [step("m1", "user", 0, "do Y", true), step("m1", "agent", 1, "working", false)];
+ const content = contentOf(assembleContext(steps, new WorkflowState(), new Map()));
+ expect(content).toContain("# Ongoing Task");
+ expect(content).toContain("## Task (ongoing)");
+ });
+
+ test("appends a Current Dataflow section when the workflow has operators", () => {
+ const workflowState = new WorkflowState();
+ workflowState.addOperator(makeOperator("op1"));
+ const content = contentOf(assembleContext([], workflowState, new Map()));
+ expect(content).toContain("# Current Dataflow");
+ expect(content).toContain("## Operators");
+ });
+
+ test("groups steps sharing a messageId into a single task", () => {
+ const steps = [
+ step("m1", "user", 0, "req", true),
+ step("m1", "agent", 1, "turn one", false),
+ step("m1", "agent", 2, "turn two", true),
+ ];
+ const content = contentOf(assembleContext(steps, new WorkflowState(), new Map()));
+ expect((content.match(/## Task/g) ?? []).length).toBe(1);
+ expect(content).toContain("### Turn 1");
+ expect(content).toContain("### Turn 2");
+ });
+});