@rytass/bpm-core-shared 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/eslint.config.mjs +19 -0
  3. package/jest.config.cts +10 -0
  4. package/package.json +2 -3
  5. package/project.json +49 -0
  6. package/src/lib/condition.ts +55 -0
  7. package/src/lib/form.ts +77 -0
  8. package/src/lib/identity.ts +25 -0
  9. package/src/lib/organization.ts +52 -0
  10. package/src/lib/shared.spec.ts +13 -0
  11. package/src/lib/status.ts +29 -0
  12. package/src/lib/workflow-command.spec.ts +278 -0
  13. package/src/lib/workflow-command.ts +848 -0
  14. package/src/lib/workflow-graph.ts +974 -0
  15. package/src/lib/workflow-toolset.spec.ts +230 -0
  16. package/src/lib/workflow-toolset.ts +1083 -0
  17. package/src/lib/workflow.ts +216 -0
  18. package/tsconfig.json +23 -0
  19. package/tsconfig.lib.json +16 -0
  20. package/tsconfig.spec.json +16 -0
  21. package/src/index.js +0 -13
  22. package/src/index.js.map +0 -1
  23. package/src/lib/condition.d.ts +0 -43
  24. package/src/lib/condition.js +0 -3
  25. package/src/lib/condition.js.map +0 -1
  26. package/src/lib/form.d.ts +0 -48
  27. package/src/lib/form.js +0 -3
  28. package/src/lib/form.js.map +0 -1
  29. package/src/lib/identity.d.ts +0 -24
  30. package/src/lib/identity.js +0 -3
  31. package/src/lib/identity.js.map +0 -1
  32. package/src/lib/organization.d.ts +0 -47
  33. package/src/lib/organization.js +0 -3
  34. package/src/lib/organization.js.map +0 -1
  35. package/src/lib/status.d.ts +0 -7
  36. package/src/lib/status.js +0 -3
  37. package/src/lib/status.js.map +0 -1
  38. package/src/lib/workflow-command.d.ts +0 -156
  39. package/src/lib/workflow-command.js +0 -292
  40. package/src/lib/workflow-command.js.map +0 -1
  41. package/src/lib/workflow-graph.d.ts +0 -96
  42. package/src/lib/workflow-graph.js +0 -605
  43. package/src/lib/workflow-graph.js.map +0 -1
  44. package/src/lib/workflow-toolset.d.ts +0 -105
  45. package/src/lib/workflow-toolset.js +0 -702
  46. package/src/lib/workflow-toolset.js.map +0 -1
  47. package/src/lib/workflow.d.ts +0 -160
  48. package/src/lib/workflow.js +0 -3
  49. package/src/lib/workflow.js.map +0 -1
  50. /package/src/{index.d.ts → index.ts} +0 -0
package/CHANGELOG.md CHANGED
@@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  Releases are managed by [`nx release`](https://nx.dev/recipes/nx-release) with
9
9
  Conventional Commits — see `nx.json` for the release config.
10
10
 
11
+ ## 0.5.0 — 2026-07-06
12
+
13
+ No source changes. Version aligned with `@rytass/bpm-core-client` and
14
+ `@rytass/bpm-core-nestjs-module` 0.5.0 (fixed release group). The new
15
+ `BPMMemberSearchPage` paged-search types added in that release live in
16
+ `@rytass/bpm-core-nestjs-module`, not here.
17
+
18
+ ## 0.4.0 — 2026-07-02
19
+
20
+ No source changes. Version aligned with `@rytass/bpm-core-client` and
21
+ `@rytass/bpm-core-nestjs-module` 0.4.0 (fixed release group).
22
+
11
23
  ## 0.3.0 — 2026-06-06
12
24
 
13
25
  No source changes. Version aligned with `@rytass/bpm-core-client` and
@@ -0,0 +1,19 @@
1
+ import baseConfig from '../../eslint.config.mjs';
2
+
3
+ export default [
4
+ ...baseConfig,
5
+ {
6
+ files: ['**/*.json'],
7
+ rules: {
8
+ '@nx/dependency-checks': [
9
+ 'error',
10
+ {
11
+ ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
12
+ },
13
+ ],
14
+ },
15
+ languageOptions: {
16
+ parser: await import('jsonc-eslint-parser'),
17
+ },
18
+ },
19
+ ];
@@ -0,0 +1,10 @@
1
+ module.exports = {
2
+ displayName: 'shared',
3
+ preset: '../../jest.preset.js',
4
+ testEnvironment: 'node',
5
+ transform: {
6
+ '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
7
+ },
8
+ moduleFileExtensions: ['ts', 'js', 'html'],
9
+ coverageDirectory: '../../coverage/libs/shared',
10
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rytass/bpm-core-shared",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shared TypeScript contracts for the Rytass BPM approval workflow stack (workflow, form, condition, identity, organization, status types).",
5
5
  "keywords": [
6
6
  "bpm",
@@ -94,6 +94,5 @@
94
94
  },
95
95
  "dependencies": {
96
96
  "tslib": "^2.3.0"
97
- },
98
- "type": "commonjs"
97
+ }
99
98
  }
package/project.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "shared",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "sourceRoot": "libs/shared/src",
5
+ "projectType": "library",
6
+ "tags": [],
7
+ "targets": {
8
+ "build": {
9
+ "executor": "@nx/js:tsc",
10
+ "outputs": ["{options.outputPath}"],
11
+ "options": {
12
+ "outputPath": "dist/libs/shared",
13
+ "main": "libs/shared/src/index.ts",
14
+ "tsConfig": "libs/shared/tsconfig.lib.json",
15
+ "assets": [
16
+ "libs/shared/*.md",
17
+ { "input": ".", "glob": "LICENSE", "output": "." }
18
+ ]
19
+ }
20
+ },
21
+ "typecheck": {
22
+ "executor": "nx:run-commands",
23
+ "options": {
24
+ "command": "tsc --noEmit -p libs/shared/tsconfig.lib.json"
25
+ }
26
+ },
27
+ "pkg-quality": {
28
+ "executor": "nx:run-commands",
29
+ "dependsOn": ["build"],
30
+ "options": {
31
+ "commands": [
32
+ "pnpm exec publint dist/libs/shared"
33
+ ],
34
+ "parallel": false
35
+ }
36
+ },
37
+ "pkg-quality-attw": {
38
+ "executor": "nx:run-commands",
39
+ "dependsOn": ["build"],
40
+ "options": {
41
+ "command": "pnpm exec attw --pack dist/libs/shared"
42
+ }
43
+ },
44
+ "prepublish-check": {
45
+ "executor": "nx:noop",
46
+ "dependsOn": ["typecheck", "lint", "build", "pkg-quality"]
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,55 @@
1
+ export type ConditionContextType =
2
+ | 'INITIATOR_POLICY'
3
+ | 'ENTRY_CONDITION'
4
+ | 'FLOW_CONDITION'
5
+ | 'APPROVER_RESOLVER'
6
+ | 'FORM_FIELD_CONDITION'
7
+ | 'DELEGATION_CONDITION';
8
+
9
+ export interface SubjectContext {
10
+ readonly customFields: Readonly<Record<string, unknown>>;
11
+ readonly email: string;
12
+ readonly memberId: string;
13
+ readonly name: string;
14
+ readonly org: OrgContext;
15
+ readonly position: PositionContext;
16
+ readonly roles: readonly string[];
17
+ }
18
+
19
+ export interface OrgContext {
20
+ readonly code: string;
21
+ readonly costCenter?: string;
22
+ readonly id: string;
23
+ readonly location?: string;
24
+ readonly name: string;
25
+ readonly parentId: string | null;
26
+ readonly path: readonly string[];
27
+ readonly type: string;
28
+ }
29
+
30
+ export interface PositionContext {
31
+ readonly code: string;
32
+ readonly id: string;
33
+ readonly level: number;
34
+ readonly name: string;
35
+ }
36
+
37
+ export interface EnvContext {
38
+ readonly now: string;
39
+ }
40
+
41
+ export interface InstanceContext {
42
+ readonly id: string;
43
+ readonly startedAt: string;
44
+ readonly templateId: string;
45
+ readonly templateVersion: number;
46
+ }
47
+
48
+ export interface LastDecisionContext {
49
+ readonly action: 'approved' | 'rejected' | 'returned';
50
+ readonly comment: string;
51
+ readonly decidedAt: string;
52
+ readonly decidedBy: SubjectContext;
53
+ readonly nodeId: string;
54
+ readonly taskId: string;
55
+ }
@@ -0,0 +1,77 @@
1
+ export interface FormDefinitionSchema {
2
+ readonly fields: readonly FormFieldDefinition[];
3
+ readonly schemaVersion: 1;
4
+ }
5
+
6
+ export type FormFieldDefinition =
7
+ | TextFieldDefinition
8
+ | NumberFieldDefinition
9
+ | DateFieldDefinition
10
+ | SelectFieldDefinition
11
+ | BooleanFieldDefinition
12
+ | FileUploadFieldDefinition;
13
+
14
+ interface BaseFormFieldDefinition<TType extends string> {
15
+ readonly defaultValue?: FormFieldValue;
16
+ readonly description?: string;
17
+ readonly fieldKey: string;
18
+ readonly label: string;
19
+ readonly placeholder?: string;
20
+ readonly readonlyWhen?: string;
21
+ readonly required: boolean;
22
+ readonly requiredWhen?: string;
23
+ readonly type: TType;
24
+ readonly visibleWhen?: string;
25
+ }
26
+
27
+ export type FormFieldValue =
28
+ | boolean
29
+ | number
30
+ | string
31
+ | readonly string[]
32
+ | null;
33
+
34
+ export type TextFieldDefinition = BaseFormFieldDefinition<
35
+ 'text' | 'textarea'
36
+ > & {
37
+ readonly maxLength?: number;
38
+ readonly minLength?: number;
39
+ };
40
+
41
+ export type NumberFieldDefinition = BaseFormFieldDefinition<
42
+ 'number' | 'money'
43
+ > & {
44
+ readonly maximum?: number;
45
+ readonly minimum?: number;
46
+ };
47
+
48
+ export type DateFieldDefinition = BaseFormFieldDefinition<'date' | 'datetime'>;
49
+
50
+ export type SelectFieldDefinition = BaseFormFieldDefinition<
51
+ 'select' | 'radio' | 'checkbox'
52
+ > & {
53
+ readonly options: readonly FormFieldOption[];
54
+ };
55
+
56
+ export interface FormFieldOption {
57
+ readonly label: string;
58
+ readonly value: string;
59
+ }
60
+
61
+ export type BooleanFieldDefinition = BaseFormFieldDefinition<'boolean'>;
62
+
63
+ export type FileUploadFieldDefinition =
64
+ BaseFormFieldDefinition<'file_upload'> & {
65
+ readonly acceptedMimeTypes?: readonly string[];
66
+ readonly maxFiles?: number;
67
+ };
68
+
69
+ export interface FormUiSchema {
70
+ readonly layout: readonly FormLayoutItem[];
71
+ readonly schemaVersion: 1;
72
+ }
73
+
74
+ export interface FormLayoutItem {
75
+ readonly fieldKey: string;
76
+ readonly width: 'FULL' | 'HALF' | 'THIRD';
77
+ }
@@ -0,0 +1,25 @@
1
+ export interface MemberMetadata {
2
+ readonly memberId: string;
3
+ readonly name: string;
4
+ readonly email: string;
5
+ /**
6
+ * Opaque host-shaped JSON. BPM stores the object verbatim, serializes
7
+ * it back to GraphQL clients as `MemberProfile.customFieldsJson`, and
8
+ * never introspects keys — anything the host puts here flows through
9
+ * unchanged. Hosts using `createBPMMemberBaseResolverProvider` populate
10
+ * this field via the `options.readCustomFields?.(member)` hook;
11
+ * standalone `BPMMemberResolver` implementations return whatever shape
12
+ * the consumer UI needs. Use it as a side channel for picker labels,
13
+ * org context strings, avatar URLs, or any other host-specific
14
+ * metadata that should appear in member-display UIs.
15
+ */
16
+ readonly customFields: Readonly<Record<string, unknown>>;
17
+ }
18
+
19
+ export interface MemberMetadataCacheEntry {
20
+ readonly id: string;
21
+ readonly memberId: string;
22
+ readonly metadata: MemberMetadata;
23
+ readonly fetchedAt: string;
24
+ readonly expiresAt: string;
25
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Org-unit type — matches the BPM GraphQL `OrgUnitType` enum SDL names
3
+ * (UPPERCASE). The server stores the value lower-cased internally
4
+ * (`'company'` etc. in the database row), but every read and write
5
+ * across the GraphQL wire and the REST surface uses the UPPERCASE form.
6
+ *
7
+ * Consumers of `@rytass/bpm-core-client/organization` must pass these
8
+ * UPPERCASE literals to `createOrgUnit` / `updateOrgUnit` (and the
9
+ * `readOrganizationDashboard` `orgUnitType` filter), and will receive
10
+ * UPPERCASE values back on `OrgUnitRecord.type`.
11
+ */
12
+ export type OrgUnitType = 'COMPANY' | 'DIVISION' | 'DEPARTMENT' | 'TEAM';
13
+
14
+ export type ManagerResolutionScopeType = 'MEMBER' | 'ORG_UNIT' | 'POSITION';
15
+
16
+ export interface OrgUnit {
17
+ readonly id: string;
18
+ readonly parentId: string | null;
19
+ readonly code: string;
20
+ readonly name: string;
21
+ readonly type: OrgUnitType;
22
+ readonly path: string;
23
+ readonly metadata: Readonly<Record<string, unknown>>;
24
+ }
25
+
26
+ export interface Position {
27
+ readonly id: string;
28
+ readonly code: string;
29
+ readonly name: string;
30
+ readonly level: number;
31
+ readonly metadata: Readonly<Record<string, unknown>>;
32
+ }
33
+
34
+ export interface Membership {
35
+ readonly id: string;
36
+ readonly memberId: string;
37
+ readonly orgUnitId: string;
38
+ readonly positionId: string | null;
39
+ readonly isPrimary: boolean;
40
+ readonly effectiveFrom: string;
41
+ readonly effectiveTo: string | null;
42
+ }
43
+
44
+ export interface ManagerResolution {
45
+ readonly id: string;
46
+ readonly scopeType: ManagerResolutionScopeType;
47
+ readonly scopeId: string;
48
+ readonly managerMemberId: string;
49
+ readonly priority: number;
50
+ readonly effectiveFrom: string;
51
+ readonly effectiveTo: string | null;
52
+ }
@@ -0,0 +1,13 @@
1
+ import { WorkflowDefinition } from './workflow';
2
+
3
+ describe('shared', () => {
4
+ it('exports workflow definition types', () => {
5
+ const workflow: WorkflowDefinition = {
6
+ edges: [],
7
+ meta: { schemaVersion: 1 },
8
+ nodes: [],
9
+ };
10
+
11
+ expect(workflow.meta.schemaVersion).toBe(1);
12
+ });
13
+ });
@@ -0,0 +1,29 @@
1
+ export type VersionStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
2
+
3
+ export type ApprovalInstanceState =
4
+ | 'DRAFT'
5
+ | 'RUNNING'
6
+ | 'APPROVED'
7
+ | 'REJECTED'
8
+ | 'RETURNED'
9
+ | 'CANCELLED'
10
+ | 'EXPIRED';
11
+
12
+ export type WorkflowTokenStatus = 'ACTIVE' | 'WAITING' | 'CONSUMED';
13
+
14
+ export type TaskStatus =
15
+ | 'PENDING'
16
+ | 'IN_PROGRESS'
17
+ | 'COMPLETED'
18
+ | 'TRANSFERRED'
19
+ | 'CANCELLED';
20
+
21
+ export type TaskDecisionAction =
22
+ | 'APPROVED'
23
+ | 'REJECTED'
24
+ | 'RETURNED'
25
+ | 'TRANSFERRED';
26
+
27
+ export type DelegationRuleStatus = 'ACTIVE' | 'REVOKED' | 'EXPIRED';
28
+
29
+ export type DelegationScopeType = 'ALL' | 'TEMPLATE_LIST' | 'CONDITION_BASED';
@@ -0,0 +1,278 @@
1
+ import {
2
+ readFallbackWorkflowDefinition,
3
+ readWorkflowDefinitionIssue,
4
+ } from './workflow-graph';
5
+ import { WorkflowDefinition } from './workflow';
6
+ import {
7
+ WorkflowCommandOptions,
8
+ WorkflowDesignerState,
9
+ applyWorkflowCommand,
10
+ applyWorkflowMacroCommand,
11
+ } from './workflow-command';
12
+
13
+ function deterministicEdgeIds(): WorkflowCommandOptions {
14
+ let counter = 0;
15
+
16
+ return {
17
+ createEdgeId: (source, target): string =>
18
+ `edge_${source}_${target}_${(counter += 1)}`,
19
+ };
20
+ }
21
+
22
+ function initialState(): WorkflowDesignerState {
23
+ return {
24
+ definition: readFallbackWorkflowDefinition(),
25
+ editingEdgeId: null,
26
+ formDefinitionVersionId: null,
27
+ formSchema: null,
28
+ initiatorPolicyCel: null,
29
+ selectedEdgeIds: [],
30
+ selectedNodeId: 'start',
31
+ };
32
+ }
33
+
34
+ describe('applyWorkflowCommand', () => {
35
+ it('adds a user task between start and end, flagging layout', () => {
36
+ const result = applyWorkflowCommand(
37
+ initialState(),
38
+ { nodeType: 'userTask', type: 'addNode' },
39
+ deterministicEdgeIds(),
40
+ );
41
+
42
+ expect(result.changed).toBe(true);
43
+ expect(result.effects.layout).toBe(true);
44
+ expect(
45
+ result.state.definition.nodes.some((node) => node.type === 'userTask'),
46
+ ).toBe(true);
47
+ // start -> userTask -> end
48
+ expect(result.state.definition.edges).toHaveLength(2);
49
+ });
50
+
51
+ it('only node inserts and autoLayout request a relayout', () => {
52
+ const base = applyWorkflowCommand(
53
+ initialState(),
54
+ { nodeType: 'userTask', type: 'addNode' },
55
+ deterministicEdgeIds(),
56
+ );
57
+
58
+ // addNode positions the new node -> relayout.
59
+ expect(base.effects.layout).toBe(true);
60
+
61
+ // Connecting already-positioned nodes preserves manual layout.
62
+ expect(
63
+ applyWorkflowCommand(base.state, {
64
+ source: 'start',
65
+ target: 'end',
66
+ type: 'connectEdge',
67
+ }).effects.layout,
68
+ ).toBe(false);
69
+
70
+ // autoLayout is the explicit tidy step.
71
+ expect(
72
+ applyWorkflowCommand(base.state, { type: 'autoLayout' }).effects.layout,
73
+ ).toBe(true);
74
+ });
75
+
76
+ it('rejects an invalid connection (end node cannot be a source)', () => {
77
+ const result = applyWorkflowCommand(initialState(), {
78
+ source: 'end',
79
+ target: 'start',
80
+ type: 'connectEdge',
81
+ });
82
+
83
+ expect(result.changed).toBe(false);
84
+ expect(result.error).toContain('無法連線');
85
+ });
86
+
87
+ it('refuses to delete the start node', () => {
88
+ const result = applyWorkflowCommand(initialState(), {
89
+ nodeId: 'start',
90
+ type: 'deleteNode',
91
+ });
92
+
93
+ expect(result.changed).toBe(false);
94
+ expect(result.error).toContain('無法刪除');
95
+ });
96
+
97
+ it('sets a user task approver and resets the decision policy to SINGLE', () => {
98
+ const withNode = applyWorkflowCommand(
99
+ initialState(),
100
+ { nodeType: 'userTask', type: 'addNode' },
101
+ deterministicEdgeIds(),
102
+ ).state;
103
+ const nodeId = withNode.definition.nodes.find(
104
+ (node) => node.type === 'userTask',
105
+ )?.id;
106
+
107
+ const result = applyWorkflowCommand(withNode, {
108
+ approverResolver: { positionId: 'pos-1', type: 'POSITION' },
109
+ nodeId: nodeId ?? '',
110
+ type: 'setUserTaskApprover',
111
+ });
112
+ const updated = result.state.definition.nodes.find(
113
+ (node) => node.id === nodeId,
114
+ );
115
+
116
+ expect(updated?.type).toBe('userTask');
117
+
118
+ if (updated?.type === 'userTask') {
119
+ expect(updated.data.approverResolver).toEqual({
120
+ positionId: 'pos-1',
121
+ type: 'POSITION',
122
+ });
123
+ expect(updated.data.decisionPolicy).toEqual({ type: 'SINGLE' });
124
+ }
125
+ });
126
+
127
+ it('reports a validation issue for an unconfigured DIRECT approver', () => {
128
+ const withNode = applyWorkflowCommand(
129
+ initialState(),
130
+ { nodeType: 'userTask', type: 'addNode' },
131
+ deterministicEdgeIds(),
132
+ ).state;
133
+ const nodeId =
134
+ withNode.definition.nodes.find((node) => node.type === 'userTask')?.id ??
135
+ '';
136
+
137
+ const result = applyWorkflowCommand(withNode, {
138
+ approverResolver: { memberIds: [], type: 'DIRECT' },
139
+ nodeId,
140
+ type: 'setUserTaskApprover',
141
+ });
142
+
143
+ expect(result.issue).toBe('簽核節點需要指定簽核會員。');
144
+ });
145
+
146
+ it('compiles an edge condition against the bound form schema', () => {
147
+ // Build: start -> gateway -> end, then condition the gateway output.
148
+ const base = applyWorkflowCommand(
149
+ {
150
+ ...initialState(),
151
+ formSchema: {
152
+ fields: [
153
+ {
154
+ fieldKey: 'amount',
155
+ label: '金額',
156
+ required: true,
157
+ type: 'money',
158
+ },
159
+ ],
160
+ schemaVersion: 1,
161
+ },
162
+ },
163
+ { nodeType: 'exclusiveGateway', type: 'addNode' },
164
+ deterministicEdgeIds(),
165
+ ).state;
166
+ const gatewayOutEdge = base.definition.edges.find((edge) =>
167
+ base.definition.nodes.some(
168
+ (node) =>
169
+ node.id === edge.source && node.type === 'exclusiveGateway',
170
+ ),
171
+ );
172
+
173
+ const result = applyWorkflowCommand(base, {
174
+ edgeId: gatewayOutEdge?.id ?? '',
175
+ fieldKey: 'amount',
176
+ operator: 'GREATER_THAN',
177
+ type: 'setEdgeCondition',
178
+ value: '1000',
179
+ });
180
+ const updatedEdge = result.state.definition.edges.find(
181
+ (edge) => edge.id === gatewayOutEdge?.id,
182
+ );
183
+
184
+ expect(updatedEdge?.data.condition).toBe('form.amount > 1000');
185
+ expect(updatedEdge?.data.isDefault).toBe(false);
186
+ });
187
+ });
188
+
189
+ describe('applyWorkflowMacroCommand', () => {
190
+ it('insertApprovalStep adds a user task and sets its approver in one call', () => {
191
+ const result = applyWorkflowMacroCommand(
192
+ initialState(),
193
+ {
194
+ approverResolver: { memberIds: ['m-1'], type: 'DIRECT' },
195
+ label: '部門簽核',
196
+ type: 'insertApprovalStep',
197
+ },
198
+ deterministicEdgeIds(),
199
+ );
200
+ const userTask = result.state.definition.nodes.find(
201
+ (node) => node.type === 'userTask',
202
+ );
203
+
204
+ expect(result.changed).toBe(true);
205
+ expect(userTask?.data.label).toBe('部門簽核');
206
+
207
+ if (userTask?.type === 'userTask') {
208
+ expect(userTask.data.approverResolver).toEqual({
209
+ memberIds: ['m-1'],
210
+ type: 'DIRECT',
211
+ });
212
+ }
213
+ });
214
+ });
215
+
216
+ describe('readWorkflowDefinitionIssue — exclusive gateway default path', () => {
217
+ function gatewayDefinition(
218
+ edges: WorkflowDefinition['edges'],
219
+ ): WorkflowDefinition {
220
+ return {
221
+ edges,
222
+ meta: { schemaVersion: 1 },
223
+ nodes: [
224
+ { data: { label: '開始' }, id: 'start', position: { x: 0, y: 0 }, type: 'startEvent' },
225
+ {
226
+ data: { direction: 'split', label: '分流', triggerMode: 'AND' },
227
+ id: 'gw',
228
+ position: { x: 0, y: 0 },
229
+ type: 'exclusiveGateway',
230
+ },
231
+ {
232
+ data: { endState: 'APPROVED', label: '完成', triggerMode: 'AND' },
233
+ id: 'end',
234
+ position: { x: 0, y: 0 },
235
+ type: 'endEvent',
236
+ },
237
+ ],
238
+ };
239
+ }
240
+
241
+ it('flags a gateway whose outputs all have conditions but no "其他情況" default', () => {
242
+ const issue = readWorkflowDefinitionIssue(
243
+ gatewayDefinition([
244
+ { data: {}, id: 'e0', source: 'start', target: 'gw' },
245
+ {
246
+ data: { condition: 'form.amount > 100000', label: '金額>10萬' },
247
+ id: 'e1',
248
+ source: 'gw',
249
+ target: 'end',
250
+ },
251
+ ]),
252
+ );
253
+
254
+ expect(issue).toContain('其他情況');
255
+ });
256
+
257
+ it('passes once one output edge is the default path', () => {
258
+ const issue = readWorkflowDefinitionIssue(
259
+ gatewayDefinition([
260
+ { data: {}, id: 'e0', source: 'start', target: 'gw' },
261
+ {
262
+ data: { condition: 'form.amount > 100000', label: '金額>10萬' },
263
+ id: 'e1',
264
+ source: 'gw',
265
+ target: 'end',
266
+ },
267
+ {
268
+ data: { isDefault: true, label: '其他情況' },
269
+ id: 'e2',
270
+ source: 'gw',
271
+ target: 'end',
272
+ },
273
+ ]),
274
+ );
275
+
276
+ expect(issue).toBeNull();
277
+ });
278
+ });