This is an automated email from the ASF dual-hosted git repository.

aglinxinyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new 7f7bba8601 test: add real test cases for PresetService spec  (#4991)
7f7bba8601 is described below

commit 7f7bba8601c5d6240fbba7654cb2e0773332ec81
Author: Matthew B. <[email protected]>
AuthorDate: Sat May 9 13:58:11 2026 -0700

    test: add real test cases for PresetService spec  (#4991)
    
    ### What changes were proposed in this PR?
    
    Replaces the old all-commented-out preset.service.spec.ts with a real,
    runnable Vitest test suite for PresetService, and re-enables the file in
    angular.json and tsconfig.spec.json (it was previously excluded from the
    build).
    
    The new suite uses fake UserConfigService and NzMessageService providers
    and covers:
    - Preset I/O: applyPresetStream / savePresetsStream event emission;
    savePresets routing non-empty lists to userConfig.set and empty lists to
    userConfig.delete; default vs. suppressed save toast; createPreset /
    updatePreset /
    deletePreset / getPresets happy paths and error cases.
    - Operator preset application: applyPreset is a no-op for non-operator
    types and unknown operator IDs; valid presets merge into operator
    properties; invalid presets leave properties unchanged.
    - Validation: isValidOperatorPreset and isValidNewOperatorPreset
    accept/reject the expected shapes.
    - Static helpers: getOperatorPresetSchema, getOperatorPreset, and
    filterOperatorPresetProperties.
    Also registers the project's custom "enable-presets" Ajv keyword as a
    no-op in the test file so Ajv 8 strict mode doesn't reject the operator
    schemas at compile time.
    
    ### Any related issues, documentation, or discussions?
    Closes: #4968
    
    ### Was this PR authored or co-authored using generative AI tooling?
    Co-Authored with Claude Opus 4.7 in compliance with ASF
---
 frontend/angular.json                              |   3 +-
 .../service/preset/preset.service.spec.ts          | 870 +++++++++++----------
 frontend/src/tsconfig.spec.json                    |   1 -
 3 files changed, 442 insertions(+), 432 deletions(-)

diff --git a/frontend/angular.json b/frontend/angular.json
index e521d7cee9..b5e15a2bb7 100644
--- a/frontend/angular.json
+++ b/frontend/angular.json
@@ -99,8 +99,7 @@
               
"**/app/workspace/component/left-panel/settings/settings.component.spec.ts",
               "**/app/workspace/component/menu/menu.component.spec.ts",
               
"**/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts",
-              "**/app/workspace/component/workspace.component.spec.ts",
-              "**/app/workspace/service/preset/preset.service.spec.ts"
+              "**/app/workspace/component/workspace.component.spec.ts"
             ]
           }
         }
diff --git a/frontend/src/app/workspace/service/preset/preset.service.spec.ts 
b/frontend/src/app/workspace/service/preset/preset.service.spec.ts
index 458aefb0f3..0ef72dbd4d 100644
--- a/frontend/src/app/workspace/service/preset/preset.service.spec.ts
+++ b/frontend/src/app/workspace/service/preset/preset.service.spec.ts
@@ -16,432 +16,444 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-// TODO: rewrite skipped tests away from Jasmine done/fail callbacks (#4861).
-// These stubs make the it.skip bodies type-check without running.
-declare function done(): void;
-declare function fail(message?: string): never;
-
-// TODO(vitest): done callbacks need rewrite to async/Promise pattern; these 
specs are skipped pending follow-up — tracked in #4861.
-
-// import { HttpClientTestingModule, HttpTestingController } from 
"@angular/common/http/testing";
-// import { TestBed, inject, fakeAsync, tick, flush, discardPeriodicTasks } 
from "@angular/core/testing";
-// import { BrowserAnimationsModule } from 
"@angular/platform-browser/animations";
-// import { NzMessageModule, NzMessageService } from "ng-zorro-antd/message";
-// import { AppSettings } from "src/app/common/app-setting";
-// import { DictionaryService } from 
"src/app/common/service/user/user-dictionary/dictionary.service";
-// import { JointUIService } from "../joint-ui/joint-ui.service";
-// import { OperatorMetadataService } from 
"../operator-metadata/operator-metadata.service";
-// import { StubOperatorMetadataService } from 
"../operator-metadata/stub-operator-metadata.service";
-// import { UndoRedoService } from "../undo-redo/undo-redo.service";
-// import { WorkflowActionService } from 
"../workflow-graph/model/workflow-action.service";
-// import { WorkflowUtilService } from 
"../workflow-graph/util/workflow-util.service";
-// import { PresetService } from "./preset.service";
-// import { mockPresetEnabledPredicate, mockPoint } from 
"../workflow-graph/model/mock-workflow-data";
-// import { CustomJSONSchema7 } from 
"../../types/custom-json-schema.interface";
-// import { mockPresetEnabledSchema } from 
"../operator-metadata/mock-operator-metadata.data";
-
-// describe("PresetService", () => {
-//   let presetService: PresetService;
-//   let httpMock: HttpTestingController;
-
-//   beforeEach(fakeAsync(() => {
-//     TestBed.configureTestingModule({
-//       providers: [
-//         PresetService,
-//         DictionaryService,
-//         WorkflowActionService,
-//         WorkflowUtilService,
-//         JointUIService,
-//         UndoRedoService,
-//         { provide: OperatorMetadataService, useClass: 
StubOperatorMetadataService },
-//       ],
-//       imports: [NzMessageModule, HttpClientTestingModule, 
BrowserAnimationsModule],
-//     });
-
-//     presetService = TestBed.inject(PresetService);
-//     httpMock = TestBed.inject(HttpTestingController);
-
-//     // handle dict initialization
-//     const testDict = { a: "a", b: "b", c: "c" };
-//     const dictApiEndpoint = 
`${AppSettings.getApiEndpoint()}/${DictionaryService.USER_DICTIONARY_ENDPOINT}`;
-//     
httpMock.expectOne(`${AppSettings.getApiEndpoint()}/users/auth/status`).flush({ 
name: "testUser", uid: 1 }); // allow autologin by userService
-//     httpMock.expectOne(`${dictApiEndpoint}/get`).flush({ code: 1, result: 
testDict });
-//     httpMock.verify();
-//     tick();
-//   }));
-
-//   it("should be created", inject([WorkflowActionService], (injectedService: 
WorkflowActionService) => {
-//     expect(injectedService).toBeTruthy();
-//   }));
-
-//   describe("preset I/O", () => {
-//     it.skip("should emit an event when presets are applied", () => {
-//       presetService.applyPresetStream.subscribe(value => {
-//         expect(value).toEqual({ type: "testType", target: "testTarget", 
preset: { testPresetKey: "testPresetValue" } });
-//         done();
-//       });
-//       presetService.applyPreset("testType", "testTarget", { testPresetKey: 
"testPresetValue" });
-//     });
-
-//     it.skip("should emit an event when presets are saved", () => {
-//       presetService.savePresetsStream.subscribe(value => {
-//         expect(value).toEqual({
-//           type: "testType",
-//           target: "testTarget",
-//           presets: [{ testPresetKey: "testPresetValue" }],
-//         });
-//         done();
-//       });
-//       presetService.savePresets("testType", "testTarget", [{ testPresetKey: 
"testPresetValue" }]);
-//     });
-
-//     it("should save to user dictionary when presets are saved", 
fakeAsync(() => {
-//       const userDictionaryService = TestBed.inject(DictionaryService);
-//       const dictApiEndpoint = 
`${AppSettings.getApiEndpoint()}/${DictionaryService.USER_DICTIONARY_ENDPOINT}`;
-
-//       presetService.savePresets("testType", "testTarget", [{ testPresetKey: 
"testPresetValue" }]);
-//       let savePresetReq = httpMock.expectOne(`${dictApiEndpoint}/set`);
-//       expect(savePresetReq.cancelled).toBeFalsy();
-//       expect(savePresetReq.request.method).toEqual("POST");
-//       expect(savePresetReq.request.responseType).toEqual("json");
-//       savePresetReq.flush({ code: 2, result: "arbitrary confirmation 
message" });
-//       httpMock.verify();
-//       tick();
-//       flush();
-//       expect(
-//         userDictionaryService.getUserDictionary()[`${(PresetService as 
any).DICT_PREFIX}-testType-testTarget`]
-//       ).toEqual(JSON.stringify([{ testPresetKey: "testPresetValue" }]));
-//     }));
-
-//     it("should save amended entry to user dictionary when a preset is 
deleted", fakeAsync(() => {
-//       const userDictionaryService = TestBed.inject(DictionaryService);
-//       const dictApiEndpoint = 
`${AppSettings.getApiEndpoint()}/${DictionaryService.USER_DICTIONARY_ENDPOINT}`;
-//       const presetDictKey = `${(PresetService as 
any).DICT_PREFIX}-testType-testTarget`;
-//       const initialPresets = [{ testPresetKey: "testPresetValue" }, { 
testPresetKey: "testPresetValue2" }];
-//       const endPresets = initialPresets.slice(0, 1);
-
-//       presetService.savePresets("testType", "testTarget", initialPresets);
-//       let savePresetReq = httpMock.expectOne(`${dictApiEndpoint}/set`);
-//       savePresetReq.flush({ code: 2, result: "arbitrary confirmation 
message" });
-//       httpMock.verify();
-//       tick();
-//       flush();
-
-//       
expect(userDictionaryService.getUserDictionary()[presetDictKey]).toEqual(JSON.stringify(initialPresets));
-
-//       presetService.deletePreset("testType", "testTarget", 
initialPresets[1]);
-//       savePresetReq = httpMock.expectOne(`${dictApiEndpoint}/set`);
-//       expect(savePresetReq.cancelled).toBeFalsy();
-//       expect(savePresetReq.request.method).toEqual("POST");
-//       expect(savePresetReq.request.responseType).toEqual("json");
-//       savePresetReq.flush({ code: 2, result: "arbitrary confirmation 
message" });
-//       httpMock.verify();
-//       tick();
-//       flush();
-//       
expect(userDictionaryService.getUserDictionary()[presetDictKey]).toEqual(JSON.stringify(endPresets));
-//     }));
-
-//     it("should save amended entry to user dictionary when a preset is 
updated", fakeAsync(() => {
-//       const userDictionaryService = TestBed.inject(DictionaryService);
-//       const dictApiEndpoint = 
`${AppSettings.getApiEndpoint()}/${DictionaryService.USER_DICTIONARY_ENDPOINT}`;
-//       const presetDictKey = `${(PresetService as 
any).DICT_PREFIX}-testType-testTarget`;
-//       const initialPresets = [{ testPresetKey: "testPresetValue" }, { 
testPresetKey: "testPresetValue2" }];
-//       const updatedPreset = { testPresetKey: "testPresetValue3" };
-//       const endPresets = [initialPresets[0], updatedPreset];
-
-//       presetService.savePresets("testType", "testTarget", initialPresets);
-//       let savePresetReq = httpMock.expectOne(`${dictApiEndpoint}/set`);
-//       savePresetReq.flush({ code: 2, result: "arbitrary confirmation 
message" });
-//       httpMock.verify();
-//       tick();
-//       flush();
-
-//       
expect(userDictionaryService.getUserDictionary()[presetDictKey]).toEqual(JSON.stringify(initialPresets));
-
-//       presetService.updatePreset("testType", "testTarget", 
initialPresets[1], updatedPreset);
-//       savePresetReq = httpMock.expectOne(`${dictApiEndpoint}/set`);
-//       expect(savePresetReq.cancelled).toBeFalsy();
-//       expect(savePresetReq.request.method).toEqual("POST");
-//       expect(savePresetReq.request.responseType).toEqual("json");
-//       savePresetReq.flush({ code: 2, result: "arbitrary confirmation 
message" });
-//       httpMock.verify();
-//       tick();
-//       flush();
-//       
expect(userDictionaryService.getUserDictionary()[presetDictKey]).toEqual(JSON.stringify(endPresets));
-//     }));
-
-//     it("should delete from dictionary service when empty preset list is 
saved", fakeAsync(() => {
-//       const userDictionaryService = TestBed.inject(DictionaryService);
-//       const dictApiEndpoint = 
`${AppSettings.getApiEndpoint()}/${DictionaryService.USER_DICTIONARY_ENDPOINT}`;
-
-//       presetService.savePresets("testType", "testTarget", []);
-//       let savePresetReq = httpMock.expectOne(`${dictApiEndpoint}/delete`);
-//       expect(savePresetReq.cancelled).toBeFalsy();
-//       expect(savePresetReq.request.method).toEqual("DELETE");
-//       expect(savePresetReq.request.responseType).toEqual("json");
-//       savePresetReq.flush({ code: 2, result: "arbitrary confirmation 
message" });
-//       httpMock.verify();
-//       tick();
-//       expect(
-//         userDictionaryService.getUserDictionary()[`${(PresetService as 
any).DICT_PREFIX}-testType-testTarget`]
-//       ).toBeUndefined();
-//       flush();
-//     }));
-
-//     it("should get user presets from the user dictionary", fakeAsync(() => {
-//       const userDictionaryService = TestBed.inject(DictionaryService);
-
-//       // cant use an expression as a property name, so the dict must be 
setup via assignment :(
-//       const testPresetKey = `${(PresetService as 
any).DICT_PREFIX}-testType-testTarget`;
-//       const testPresets = [{ testPresetKey: "testPresetValue" }];
-//       const testDict: any = {};
-//       testDict[testPresetKey] = JSON.stringify(testPresets);
-
-//       vi.spyOn(userDictionaryService, 
"forceGetUserDictionary").mockReturnValue(testDict);
-
-//       presetService = new PresetService(
-//         userDictionaryService,
-//         TestBed.inject(NzMessageService),
-//         TestBed.inject(WorkflowActionService),
-//         TestBed.inject(OperatorMetadataService)
-//       );
-
-//       const presets = presetService.getPresets("testType", "testTarget");
-//       expect(presets).toEqual(testPresets);
-//     }));
-//   });
-
-//   describe("operator preset handling", () => {
-//     let workflowActionService: WorkflowActionService;
-
-//     beforeEach(() => {
-//       workflowActionService = TestBed.inject(WorkflowActionService);
-//       workflowActionService.addOperator(mockPresetEnabledPredicate, 
mockPoint);
-//       
workflowActionService.setOperatorProperty(mockPresetEnabledPredicate.operatorID,
 {
-//         presetProperty: "testPresetProperty",
-//         normalProperty: "testNormalProperty",
-//       });
-//     });
-
-//     it("should not set operator properties if a non operator preset is 
applied", fakeAsync(() => {
-//       presetService.applyPreset("NotAnOperator", "NotAnOperatorID", { 
NotAPresetProperty: "presetApplied" });
-//       tick();
-
-//       expect(
-//         
workflowActionService.getTexeraGraph().getOperator(mockPresetEnabledPredicate.operatorID).operatorProperties
-//       ).toEqual({
-//         presetProperty: "testPresetProperty",
-//         normalProperty: "testNormalProperty",
-//       });
-//     }));
-
-//     it("should not set operator properties if an invalid operator preset is 
applied", fakeAsync(() => {
-//       expect(() => {
-//         presetService.applyPreset("operator", 
mockPresetEnabledPredicate.operatorID, {
-//           NotAPresetProperty: "presetApplied",
-//         });
-//         flush();
-//       }).toThrow();
-
-//       expect(
-//         
workflowActionService.getTexeraGraph().getOperator(mockPresetEnabledPredicate.operatorID).operatorProperties
-//       ).toEqual({
-//         presetProperty: "testPresetProperty",
-//         normalProperty: "testNormalProperty",
-//       });
-//     }));
-
-//     it("should set operator properties if a valid operator preset is 
applied", fakeAsync(() => {
-//       presetService.applyPreset("operator", 
mockPresetEnabledPredicate.operatorID, { presetProperty: "presetApplied" });
-//       tick();
-
-//       expect(
-//         
workflowActionService.getTexeraGraph().getOperator(mockPresetEnabledPredicate.operatorID).operatorProperties
-//       ).toEqual({
-//         presetProperty: "presetApplied",
-//         normalProperty: "testNormalProperty",
-//       });
-//     }));
-//   });
-
-//   describe("operator preset validation", () => {
-//     beforeEach(() => {
-//       const workflowActionService = TestBed.inject(WorkflowActionService);
-//       workflowActionService.addOperator(mockPresetEnabledPredicate, 
mockPoint);
-//     });
-
-//     it("should reject an empty preset", () => {
-//       expect(presetService.isValidOperatorPreset({}, 
mockPresetEnabledPredicate.operatorID)).toBe(false);
-//     });
-
-//     it("should reject preset with the wrong properties", () => {
-//       expect(
-//         presetService.isValidOperatorPreset(
-//           { wrongProperty: "wrongpropertyPreset" },
-//           mockPresetEnabledPredicate.operatorID
-//         )
-//       ).toBe(false);
-//     });
-
-//     it("should reject preset with empty properties", () => {
-//       expect(
-//         presetService.isValidOperatorPreset({ presetProperty: "" }, 
mockPresetEnabledPredicate.operatorID)
-//       ).toBe(false);
-//     });
-
-//     it("should accept a properly formatted preset", () => {
-//       expect(
-//         presetService.isValidOperatorPreset(
-//           { presetProperty: "presetHasBeenApplied" },
-//           mockPresetEnabledPredicate.operatorID
-//         )
-//       ).toBe(true);
-//     });
-
-//     it("should reject new presets if they already exist", () => {
-//       vi.spyOn(presetService, "getPresets").mockReturnValue([{ 
presetProperty: "presetHasBeenApplied" }]);
-
-//       expect(
-//         presetService.isValidNewOperatorPreset(
-//           { presetProperty: "presetHasBeenApplied" },
-//           mockPresetEnabledPredicate.operatorID
-//         )
-//       ).toBe(false);
-//     });
-
-//     it("should accept new presets if they are novel", () => {
-//       vi.spyOn(presetService, "getPresets").mockReturnValue([{ 
presetProperty: "presetHasBeenApplied" }]);
-
-//       expect(
-//         presetService.isValidNewOperatorPreset(
-//           { presetProperty: "alternatePreset" },
-//           mockPresetEnabledPredicate.operatorID
-//         )
-//       ).toBe(true);
-//     });
-//   });
-
-//   describe("operator preset schema generation", () => {
-//     it("should generate a preset schema", () => {
-//       const operatorSchema = <CustomJSONSchema7>{
-//         type: "object",
-//         properties: {
-//           presetProperty: {
-//             type: "string",
-//             description: "property that can be saved in presets",
-//             title: "presetProperty",
-//             "enable-presets": true,
-//           },
-//           normalProperty: {
-//             type: "string",
-//             description: "property that is excluded in presets",
-//             title: "normalProperty",
-//           },
-//         },
-//         required: ["normalProperty"],
-//       };
-
-//       const presetSchema = <CustomJSONSchema7>{
-//         type: "object",
-//         properties: {
-//           presetProperty: {
-//             type: "string",
-//             description: "property that can be saved in presets",
-//             title: "presetProperty",
-//             "enable-presets": true,
-//           },
-//         },
-//         required: ["presetProperty"],
-//         additionalProperties: false,
-//       };
-
-//       
expect(PresetService.getOperatorPresetSchema(operatorSchema)).toEqual(presetSchema);
-//     });
-
-//     it("should throw an error if the operator schema has no properties", () 
=> {
-//       const operatorSchema = <CustomJSONSchema7>{
-//         type: "object",
-//         properties: {},
-//         required: ["normalProperty"],
-//       };
-
-//       expect(() => 
PresetService.getOperatorPresetSchema(operatorSchema)).toThrow();
-//     });
-
-//     it("should throw an error if the operator schema has no preset 
properties", () => {
-//       const operatorSchema = <CustomJSONSchema7>{
-//         type: "object",
-//         properties: {
-//           normalProperty: {
-//             type: "string",
-//             description: "property that is excluded in presets",
-//             title: "normalProperty",
-//           },
-//         },
-//         required: ["normalProperty"],
-//       };
-
-//       expect(() => 
PresetService.getOperatorPresetSchema(operatorSchema)).toThrow();
-//     });
-//   });
-
-//   describe("operator preset generation", () => {
-//     describe("getOperatorPreset - throw errors if invalid", () => {
-//       it("should throw an error if operator properties is empty", () => {
-//         expect(() => 
PresetService.getOperatorPreset(mockPresetEnabledSchema.jsonSchema, 
{})).toThrow();
-//       });
-
-//       it("should throw an error if operator properties doesn't have all the 
preset properties", () => {
-//         expect(() =>
-//           
PresetService.getOperatorPreset(mockPresetEnabledSchema.jsonSchema, { 
wrongProperty: "wrongpropertyPreset" })
-//         ).toThrow();
-//       });
-
-//       it("should return a preset if operator properties has all the preset 
properties", () => {
-//         expect(
-//           
PresetService.getOperatorPreset(mockPresetEnabledSchema.jsonSchema, { 
presetProperty: "presetPropertyValue" })
-//         ).toEqual({ presetProperty: "presetPropertyValue" });
-//       });
-
-//       it("should return a preset if operator properties has a superset of 
preset properties", () => {
-//         expect(
-//           
PresetService.getOperatorPreset(mockPresetEnabledSchema.jsonSchema, {
-//             presetProperty: "presetPropertyValue",
-//             otherProperty: "othervalue",
-//           })
-//         ).toEqual({ presetProperty: "presetPropertyValue" });
-//       });
-//     });
-
-//     describe("filterOperatorPresetProperties - doesn't guarantee preset is 
valid", () => {
-//       it("should never add to operator properties", () => {
-//         
expect(PresetService.filterOperatorPresetProperties(mockPresetEnabledSchema.jsonSchema,
 {})).toEqual({});
-//       });
-
-//       it("should filter out non preset properties", () => {
-//         expect(
-//           
PresetService.filterOperatorPresetProperties(mockPresetEnabledSchema.jsonSchema,
 {
-//             wrongProperty: "wrongpropertyPreset",
-//           })
-//         ).toEqual({});
-//       });
-
-//       it("should not filter out preset properties", () => {
-//         expect(
-//           
PresetService.filterOperatorPresetProperties(mockPresetEnabledSchema.jsonSchema,
 {
-//             presetProperty: "presetPropertyValue",
-//           })
-//         ).toEqual({ presetProperty: "presetPropertyValue" });
-//       });
-
-//       it("should filter out non preset properties and leave behind preset 
properties", () => {
-//         expect(
-//           
PresetService.filterOperatorPresetProperties(mockPresetEnabledSchema.jsonSchema,
 {
-//             presetProperty: "presetPropertyValue",
-//             otherProperty: "othervalue",
-//           })
-//         ).toEqual({ presetProperty: "presetPropertyValue" });
-//       });
-//     });
-//   });
-// });
+
+import { TestBed } from "@angular/core/testing";
+import { NzMessageService } from "ng-zorro-antd/message";
+import { config, of } from "rxjs";
+import { UserConfigService } from 
"src/app/common/service/user/config/user-config.service";
+import { CustomJSONSchema7 } from "../../types/custom-json-schema.interface";
+import { JointUIService } from "../joint-ui/joint-ui.service";
+import { mockPresetEnabledSchema } from 
"../operator-metadata/mock-operator-metadata.data";
+import { OperatorMetadataService } from 
"../operator-metadata/operator-metadata.service";
+import { StubOperatorMetadataService } from 
"../operator-metadata/stub-operator-metadata.service";
+import { UndoRedoService } from "../undo-redo/undo-redo.service";
+import { mockPoint, mockPresetEnabledPredicate } from 
"../workflow-graph/model/mock-workflow-data";
+import { WorkflowActionService } from 
"../workflow-graph/model/workflow-action.service";
+import { WorkflowUtilService } from 
"../workflow-graph/util/workflow-util.service";
+import { commonTestProviders } from "../../../common/testing/test-utils";
+import { Preset, PresetService } from "./preset.service";
+
+// Ajv 8 defaults to strict mode and rejects unknown keywords at compile time, 
so
+// `isValidOperatorPreset` (which compiles operator schemas containing the
+// 'enable-presets' marker) throws before it can validate. Register the keyword
+// once as a no-op so the validation paths are exercisable in tests.
+const ajvInstance = (PresetService as any).ajv;
+if (!ajvInstance.getKeyword("enable-presets")) {
+  ajvInstance.addKeyword({ keyword: "enable-presets", schemaType: "boolean" });
+}
+
+describe("PresetService", () => {
+  let userConfigStub: {
+    fetchKey: ReturnType<typeof vi.fn>;
+    set: ReturnType<typeof vi.fn>;
+    delete: ReturnType<typeof vi.fn>;
+  };
+  let messageStub: {
+    success: ReturnType<typeof vi.fn>;
+    error: ReturnType<typeof vi.fn>;
+    info: ReturnType<typeof vi.fn>;
+    warning: ReturnType<typeof vi.fn>;
+  };
+  let presetService: PresetService;
+  let workflowActionService: WorkflowActionService;
+
+  // RxJS 7 reports errors thrown from a subscribe `next` handler via
+  // `config.onUnhandledError` on a macrotask, not synchronously, so a
+  // try/catch around the call would not see them. Capture them explicitly.
+  const captureRxjsUnhandled = async (run: () => void) => {
+    const captured: unknown[] = [];
+    const previous = config.onUnhandledError;
+    config.onUnhandledError = err => captured.push(err);
+    try {
+      run();
+      await new Promise(resolve => setTimeout(resolve, 0));
+    } finally {
+      config.onUnhandledError = previous;
+    }
+    return captured;
+  };
+
+  const presetType = "operator";
+  const presetTarget = mockPresetEnabledPredicate.operatorType;
+  const presetDictKey = `${presetType}-${presetTarget}`;
+
+  beforeEach(() => {
+    userConfigStub = {
+      fetchKey: vi.fn().mockReturnValue(of(null)),
+      set: vi.fn().mockReturnValue(of(void 0)),
+      delete: vi.fn().mockReturnValue(of(void 0)),
+    };
+    messageStub = {
+      success: vi.fn(),
+      error: vi.fn(),
+      info: vi.fn(),
+      warning: vi.fn(),
+    };
+
+    TestBed.configureTestingModule({
+      providers: [
+        PresetService,
+        WorkflowActionService,
+        WorkflowUtilService,
+        JointUIService,
+        UndoRedoService,
+        { provide: OperatorMetadataService, useClass: 
StubOperatorMetadataService },
+        { provide: UserConfigService, useValue: userConfigStub },
+        { provide: NzMessageService, useValue: messageStub },
+        ...commonTestProviders,
+      ],
+    });
+
+    presetService = TestBed.inject(PresetService);
+    workflowActionService = TestBed.inject(WorkflowActionService);
+  });
+
+  it("should be created", () => {
+    expect(presetService).toBeTruthy();
+  });
+
+  describe("preset I/O", () => {
+    it("emits an event on applyPresetStream when a preset is applied", () => {
+      const seen: any[] = [];
+      const sub = presetService.applyPresetStream.subscribe(value => 
seen.push(value));
+
+      const preset: Preset = { presetProperty: "applied" };
+      presetService.applyPreset("nonOperatorType", "anyTarget", preset);
+
+      expect(seen).toEqual([{ type: "nonOperatorType", target: "anyTarget", 
preset }]);
+      sub.unsubscribe();
+    });
+
+    it("emits an event on savePresetsStream when presets are saved", () => {
+      const seen: any[] = [];
+      const sub = presetService.savePresetsStream.subscribe(value => 
seen.push(value));
+
+      const presets: Preset[] = [{ presetProperty: "v1" }];
+      presetService.savePresets(presetType, presetTarget, presets);
+
+      expect(seen).toEqual([{ type: presetType, target: presetTarget, presets 
}]);
+      sub.unsubscribe();
+    });
+
+    it("writes through UserConfigService.set when saving a non-empty preset 
list", () => {
+      const presets: Preset[] = [{ presetProperty: "v1" }];
+      presetService.savePresets(presetType, presetTarget, presets);
+
+      expect(userConfigStub.set).toHaveBeenCalledTimes(1);
+      expect(userConfigStub.set).toHaveBeenCalledWith(presetDictKey, 
JSON.stringify(presets));
+      expect(userConfigStub.delete).not.toHaveBeenCalled();
+    });
+
+    it("calls UserConfigService.delete instead of set when saving an empty 
preset list", () => {
+      presetService.savePresets(presetType, presetTarget, []);
+
+      expect(userConfigStub.delete).toHaveBeenCalledTimes(1);
+      expect(userConfigStub.delete).toHaveBeenCalledWith(presetDictKey);
+      expect(userConfigStub.set).not.toHaveBeenCalled();
+    });
+
+    it("displays the success toast by default when saving presets", () => {
+      presetService.savePresets(presetType, presetTarget, [{ presetProperty: 
"v1" }]);
+      expect(messageStub.success).toHaveBeenCalledWith("Preset saved");
+    });
+
+    it("suppresses the toast when displayMessage is explicitly null", () => {
+      presetService.savePresets(presetType, presetTarget, [{ presetProperty: 
"v1" }], null);
+      expect(messageStub.success).not.toHaveBeenCalled();
+      expect(messageStub.error).not.toHaveBeenCalled();
+    });
+
+    it("createPreset appends to existing presets and writes back", () => {
+      const existing: Preset[] = [{ presetProperty: "v1" }];
+      userConfigStub.fetchKey.mockReturnValue(of(JSON.stringify(existing)));
+
+      presetService.createPreset(presetType, presetTarget, { presetProperty: 
"v2" });
+
+      expect(userConfigStub.set).toHaveBeenCalledWith(
+        presetDictKey,
+        JSON.stringify([{ presetProperty: "v1" }, { presetProperty: "v2" }])
+      );
+    });
+
+    it("createPreset does not write the preset back when it already exists", 
async () => {
+      userConfigStub.fetchKey.mockReturnValue(of(JSON.stringify([{ 
presetProperty: "v1" }])));
+
+      const errors = await captureRxjsUnhandled(() =>
+        presetService.createPreset(presetType, presetTarget, { presetProperty: 
"v1" })
+      );
+
+      expect(userConfigStub.set).not.toHaveBeenCalled();
+      expect(userConfigStub.delete).not.toHaveBeenCalled();
+      expect(errors).toHaveLength(1);
+      expect((errors[0] as Error).message).toMatch(/already exists/);
+    });
+
+    it("updatePreset does not write the preset back when the original preset 
is missing", async () => {
+      userConfigStub.fetchKey.mockReturnValue(of(JSON.stringify([{ 
presetProperty: "v1" }])));
+
+      const errors = await captureRxjsUnhandled(() =>
+        presetService.updatePreset(presetType, presetTarget, { presetProperty: 
"missing" }, { presetProperty: "v3" })
+      );
+
+      expect(userConfigStub.set).not.toHaveBeenCalled();
+      expect(errors).toHaveLength(1);
+      expect((errors[0] as Error).message).toMatch(/doesn't exist/);
+    });
+
+    it("deletePreset removes the matching preset via savePresets", () => {
+      const a: Preset = { presetProperty: "v1" };
+      const b: Preset = { presetProperty: "v2" };
+      userConfigStub.fetchKey.mockReturnValue(of(JSON.stringify([a, b])));
+
+      presetService.deletePreset(presetType, presetTarget, b);
+
+      expect(userConfigStub.set).toHaveBeenCalledWith(presetDictKey, 
JSON.stringify([a]));
+    });
+
+    it("deletePreset clears the dictionary entry when the last preset is 
removed", () => {
+      const only: Preset = { presetProperty: "v1" };
+      userConfigStub.fetchKey.mockReturnValue(of(JSON.stringify([only])));
+
+      presetService.deletePreset(presetType, presetTarget, only);
+
+      // savePresets routes empty arrays to delete(), not set().
+      expect(userConfigStub.delete).toHaveBeenCalledWith(presetDictKey);
+      expect(userConfigStub.set).not.toHaveBeenCalled();
+    });
+
+    it("getPresets returns the parsed preset array stored in user config", () 
=> {
+      const stored: Preset[] = [{ presetProperty: "v1" }];
+      userConfigStub.fetchKey.mockReturnValue(of(JSON.stringify(stored)));
+
+      let result: readonly Preset[] | undefined;
+      presetService.getPresets(presetType, presetTarget).subscribe(v => 
(result = v));
+      expect(result).toEqual(stored);
+    });
+
+    it("getPresets yields an empty array when no entry exists", () => {
+      userConfigStub.fetchKey.mockReturnValue(of(null));
+
+      let result: readonly Preset[] | undefined;
+      presetService.getPresets(presetType, presetTarget).subscribe(v => 
(result = v));
+      expect(result).toEqual([]);
+    });
+
+    it("getPresets emits an error when the stored value is not a valid preset 
array", () => {
+      userConfigStub.fetchKey.mockReturnValue(of(JSON.stringify([{ 
presetProperty: 42 }, "not-an-object"])));
+
+      let err: unknown;
+      // throws inside an rxjs map() — surface via the error subscriber, not 
toThrow.
+      presetService.getPresets(presetType, presetTarget).subscribe({
+        next: () => {},
+        error: (e: unknown) => (err = e),
+      });
+      expect(err).toBeInstanceOf(Error);
+      expect((err as Error).message).toMatch(/formatted incorrectly/);
+    });
+  });
+
+  describe("operator preset application", () => {
+    beforeEach(() => {
+      workflowActionService.addOperator(mockPresetEnabledPredicate, mockPoint);
+      
workflowActionService.setOperatorProperty(mockPresetEnabledPredicate.operatorID,
 {
+        presetProperty: "before",
+        normalProperty: "untouched",
+      });
+    });
+
+    it("does not set operator properties when applyPreset uses a non-operator 
type", () => {
+      presetService.applyPreset("notAnOperator", 
mockPresetEnabledPredicate.operatorID, { presetProperty: "applied" });
+
+      expect(
+        
workflowActionService.getTexeraGraph().getOperator(mockPresetEnabledPredicate.operatorID).operatorProperties
+      ).toEqual({ presetProperty: "before", normalProperty: "untouched" });
+    });
+
+    it("merges preset values into operator properties when a valid preset is 
applied", () => {
+      presetService.applyPreset("operator", 
mockPresetEnabledPredicate.operatorID, { presetProperty: "applied" });
+
+      // normalProperty is preserved because applyPreset merges, rather than 
replaces.
+      expect(
+        
workflowActionService.getTexeraGraph().getOperator(mockPresetEnabledPredicate.operatorID).operatorProperties
+      ).toEqual({ presetProperty: "applied", normalProperty: "untouched" });
+    });
+
+    it("does not change operator properties when an invalid preset is 
applied", async () => {
+      const errors = await captureRxjsUnhandled(() =>
+        presetService.applyPreset("operator", 
mockPresetEnabledPredicate.operatorID, {
+          notAPresetProperty: "applied",
+        })
+      );
+
+      expect(
+        
workflowActionService.getTexeraGraph().getOperator(mockPresetEnabledPredicate.operatorID).operatorProperties
+      ).toEqual({ presetProperty: "before", normalProperty: "untouched" });
+      expect(errors).toHaveLength(1);
+      expect((errors[0] as Error).message).toMatch(/Error applying preset/);
+    });
+
+    it("ignores apply events targeting an operator that does not exist on the 
graph", () => {
+      // unknown operator IDs are silently skipped so cross-workflow events 
don't raise.
+      expect(() => presetService.applyPreset("operator", "missing-op-id", { 
presetProperty: "applied" })).not.toThrow();
+    });
+  });
+
+  describe("operator preset validation", () => {
+    beforeEach(() => {
+      workflowActionService.addOperator(mockPresetEnabledPredicate, mockPoint);
+    });
+
+    it("rejects an empty preset", () => {
+      expect(presetService.isValidOperatorPreset({}, 
mockPresetEnabledPredicate.operatorID)).toBe(false);
+    });
+
+    it("rejects presets containing only properties that are not 
preset-enabled", () => {
+      expect(presetService.isValidOperatorPreset({ wrongProperty: "x" }, 
mockPresetEnabledPredicate.operatorID)).toBe(
+        false
+      );
+    });
+
+    it("rejects presets with empty string values", () => {
+      expect(presetService.isValidOperatorPreset({ presetProperty: "" }, 
mockPresetEnabledPredicate.operatorID)).toBe(
+        false
+      );
+    });
+
+    it("accepts presets that match the preset schema with non-empty values", 
() => {
+      expect(
+        presetService.isValidOperatorPreset({ presetProperty: "applied" }, 
mockPresetEnabledPredicate.operatorID)
+      ).toBe(true);
+    });
+
+    it("isValidNewOperatorPreset returns false when the preset already 
exists", () => {
+      const existing: Preset = { presetProperty: "applied" };
+      userConfigStub.fetchKey.mockReturnValue(of(JSON.stringify([existing])));
+
+      let result: boolean | undefined;
+      presetService
+        .isValidNewOperatorPreset(existing, 
mockPresetEnabledPredicate.operatorID)
+        .subscribe(v => (result = v));
+      expect(result).toBe(false);
+    });
+
+    it("isValidNewOperatorPreset returns true when the preset is novel", () => 
{
+      userConfigStub.fetchKey.mockReturnValue(of(JSON.stringify([{ 
presetProperty: "applied" }])));
+
+      let result: boolean | undefined;
+      presetService
+        .isValidNewOperatorPreset({ presetProperty: "novel" }, 
mockPresetEnabledPredicate.operatorID)
+        .subscribe(v => (result = v));
+      expect(result).toBe(true);
+    });
+
+    it("isValidNewOperatorPreset short-circuits to false when the preset 
itself is invalid", () => {
+      let result: boolean | undefined;
+      presetService.isValidNewOperatorPreset({}, 
mockPresetEnabledPredicate.operatorID).subscribe(v => (result = v));
+      expect(result).toBe(false);
+    });
+  });
+
+  describe("static schema helpers", () => {
+    it("getOperatorPresetSchema keeps only enable-preset properties and marks 
them required", () => {
+      const operatorSchema = <CustomJSONSchema7>{
+        type: "object",
+        properties: {
+          presetProperty: {
+            type: "string",
+            description: "property that can be saved in presets",
+            title: "presetProperty",
+            "enable-presets": true,
+          },
+          normalProperty: {
+            type: "string",
+            description: "property that is excluded in presets",
+            title: "normalProperty",
+          },
+        },
+        required: ["normalProperty"],
+      };
+
+      expect(PresetService.getOperatorPresetSchema(operatorSchema)).toEqual({
+        type: "object",
+        properties: {
+          presetProperty: {
+            type: "string",
+            description: "property that can be saved in presets",
+            title: "presetProperty",
+            "enable-presets": true,
+          },
+        },
+        required: ["presetProperty"],
+        additionalProperties: false,
+      });
+    });
+
+    it("getOperatorPresetSchema throws when the operator schema has no 
properties", () => {
+      expect(() =>
+        PresetService.getOperatorPresetSchema(<CustomJSONSchema7>{ type: 
"object", properties: {} })
+      ).toThrow();
+    });
+
+    it("getOperatorPresetSchema throws when no property is preset-enabled", () 
=> {
+      expect(() =>
+        PresetService.getOperatorPresetSchema(<CustomJSONSchema7>{
+          type: "object",
+          properties: {
+            normalProperty: { type: "string", title: "normalProperty" },
+          },
+        })
+      ).toThrow();
+    });
+
+    describe("getOperatorPreset", () => {
+      it("throws when operator properties are empty", () => {
+        expect(() => 
PresetService.getOperatorPreset(mockPresetEnabledSchema.jsonSchema, 
{})).toThrow();
+      });
+
+      it("throws when operator properties miss a required preset property", () 
=> {
+        expect(() =>
+          PresetService.getOperatorPreset(mockPresetEnabledSchema.jsonSchema, 
{ wrongProperty: "x" })
+        ).toThrow();
+      });
+
+      it("returns the preset when properties cover all preset fields", () => {
+        
expect(PresetService.getOperatorPreset(mockPresetEnabledSchema.jsonSchema, { 
presetProperty: "v" })).toEqual({
+          presetProperty: "v",
+        });
+      });
+
+      it("strips non-preset properties when returning the preset", () => {
+        expect(
+          PresetService.getOperatorPreset(mockPresetEnabledSchema.jsonSchema, {
+            presetProperty: "v",
+            otherProperty: "extra",
+          })
+        ).toEqual({ presetProperty: "v" });
+      });
+    });
+
+    describe("filterOperatorPresetProperties", () => {
+      it("returns empty when input is empty (never adds keys)", () => {
+        
expect(PresetService.filterOperatorPresetProperties(mockPresetEnabledSchema.jsonSchema,
 {})).toEqual({});
+      });
+
+      it("filters out non-preset properties only when at least one preset 
property is present", () => {
+        // Ajv 8's removeAdditional traversal short-circuits when `required` 
fails,
+        // so an input that contains *only* non-preset keys is left untouched.
+        // The "+ extras" case below covers the normal stripping path.
+        expect(
+          
PresetService.filterOperatorPresetProperties(mockPresetEnabledSchema.jsonSchema,
 { wrongProperty: "x" })
+        ).toEqual({ wrongProperty: "x" });
+      });
+
+      it("keeps preset properties and strips extras", () => {
+        expect(
+          
PresetService.filterOperatorPresetProperties(mockPresetEnabledSchema.jsonSchema,
 {
+            presetProperty: "v",
+            otherProperty: "extra",
+          })
+        ).toEqual({ presetProperty: "v" });
+      });
+    });
+  });
+});
diff --git a/frontend/src/tsconfig.spec.json b/frontend/src/tsconfig.spec.json
index e9ebccf86d..8a5e449e59 100644
--- a/frontend/src/tsconfig.spec.json
+++ b/frontend/src/tsconfig.spec.json
@@ -18,7 +18,6 @@
     
"**/app/workspace/component/left-panel/settings/settings.component.spec.ts",
     "**/app/workspace/component/menu/menu.component.spec.ts",
     "**/app/workspace/component/workspace.component.spec.ts",
-    "**/app/workspace/service/preset/preset.service.spec.ts",
 
     // jointjs paper geometry: every test in this suite asserts on
     // graph layout math (positions, link routing, hit testing) that


Reply via email to