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