@mawaru/sdk 0.13.0 → 0.15.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.
- package/_schemas/ai-manifest.test.ts +54 -59
- package/_schemas/ai-manifest.ts +38 -23
- package/_schemas/duplicate-selection.test.ts +20 -0
- package/_schemas/duplicate-selection.ts +18 -2
- package/_schemas/extract-component.test.ts +201 -24
- package/_schemas/extract-component.ts +137 -54
- package/_schemas/graph.test.ts +95 -72
- package/_schemas/graph.ts +22 -29
- package/_schemas/node.ts +4 -0
- package/_schemas/port-spec.test.ts +120 -129
- package/_schemas/port-spec.ts +76 -111
- package/dist/_schemas/ai-manifest.d.ts +12 -1
- package/dist/_schemas/ai-manifest.js +35 -20
- package/dist/_schemas/duplicate-selection.js +18 -2
- package/dist/_schemas/extract-component.d.ts +4 -3
- package/dist/_schemas/extract-component.js +105 -38
- package/dist/_schemas/graph.d.ts +18 -0
- package/dist/_schemas/graph.js +21 -26
- package/dist/_schemas/node.d.ts +1 -0
- package/dist/_schemas/node.js +4 -0
- package/dist/_schemas/port-spec.d.ts +2 -5
- package/dist/_schemas/port-spec.js +63 -94
- package/dist/typegen.js +6 -6
- package/docs/development.md +5 -1
- package/package.json +2 -3
- package/skills/create-loop/SKILL.md +1 -1
- package/templates/CLAUDE.md +1 -1
- package/templates/echo/main.ts +1 -1
- package/templates/mawaru-runner.yml +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import { AI_MODELS, aiManifestSchema } from "./ai-manifest.js"
|
|
3
|
-
import {
|
|
3
|
+
import { instructionSchema } from "./port-spec.js"
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const textOutput = { type: "string" }
|
|
6
6
|
|
|
7
7
|
const valid = {
|
|
8
8
|
name: "返信下書き",
|
|
@@ -11,8 +11,8 @@ const valid = {
|
|
|
11
11
|
model: "claude-opus-4-8",
|
|
12
12
|
skills: ["answer-faq"],
|
|
13
13
|
env: ["ANTHROPIC_API_KEY"],
|
|
14
|
-
inputs: { in: {} },
|
|
15
|
-
outputs: { main:
|
|
14
|
+
inputs: { in: { schema: {} } },
|
|
15
|
+
outputs: { main: textOutput },
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
describe("aiManifestSchema", () => {
|
|
@@ -28,8 +28,8 @@ describe("aiManifestSchema", () => {
|
|
|
28
28
|
it("name / description / model / skills / env は省略できる(既定値が補われる)", () => {
|
|
29
29
|
const manifest = aiManifestSchema.parse({
|
|
30
30
|
prompt: valid.prompt,
|
|
31
|
-
inputs: { in: {} },
|
|
32
|
-
outputs: { main:
|
|
31
|
+
inputs: { in: { schema: {} } },
|
|
32
|
+
outputs: { main: textOutput },
|
|
33
33
|
})
|
|
34
34
|
expect(manifest.name).toBeUndefined()
|
|
35
35
|
expect(manifest.model).toBe("claude-opus-5")
|
|
@@ -56,90 +56,85 @@ describe("aiManifestSchema", () => {
|
|
|
56
56
|
).toThrow()
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
-
it(
|
|
60
|
-
//
|
|
59
|
+
it("inputs は key 自由・1件以上(in / instruction の固定構成は廃止)", () => {
|
|
60
|
+
// 0件は拒否
|
|
61
61
|
expect(() => aiManifestSchema.parse({ ...valid, inputs: {} })).toThrow()
|
|
62
|
+
// key は自由(旧固定の in / instruction 以外も宣言できる)
|
|
62
63
|
expect(() =>
|
|
63
|
-
aiManifestSchema.parse({ ...valid, inputs: { data: {} } }),
|
|
64
|
-
).toThrow()
|
|
65
|
-
// 固定ポートの instruction も宣言に載る(ノードの現在の姿を書き出すため)
|
|
66
|
-
expect(
|
|
67
64
|
aiManifestSchema.parse({
|
|
68
65
|
...valid,
|
|
69
|
-
inputs: {
|
|
70
|
-
})
|
|
71
|
-
).
|
|
72
|
-
//
|
|
66
|
+
inputs: { task: { schema: {} }, feedback: { schema: {} } },
|
|
67
|
+
}),
|
|
68
|
+
).not.toThrow()
|
|
69
|
+
// key はポート key の規則(英小文字始まり・英数字と _)
|
|
70
|
+
for (const key of ["Bad", "1st", "a-b", ""]) {
|
|
71
|
+
expect(() =>
|
|
72
|
+
aiManifestSchema.parse({ ...valid, inputs: { [key]: { schema: {} } } }),
|
|
73
|
+
).toThrow()
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("inputs は { schema, resume } の新形式で宣言でき、resume が読める", () => {
|
|
78
|
+
const parsed = aiManifestSchema.parse({
|
|
79
|
+
...valid,
|
|
80
|
+
inputs: {
|
|
81
|
+
in: { schema: { type: "object" } },
|
|
82
|
+
instruction: { schema: instructionSchema(), resume: true },
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
expect(parsed.inputs.in).toEqual({ schema: { type: "object" } })
|
|
86
|
+
expect(parsed.inputs.instruction?.schema).toEqual(instructionSchema())
|
|
87
|
+
expect(parsed.inputs.instruction?.resume).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("旧形式(値が素の JSON Schema)は { schema: 値 } として読める", () => {
|
|
91
|
+
const legacy = aiManifestSchema.parse({
|
|
92
|
+
...valid,
|
|
93
|
+
inputs: { in: { type: "object" }, instruction: instructionSchema() },
|
|
94
|
+
})
|
|
95
|
+
expect(legacy.inputs.in).toEqual({ schema: { type: "object" } })
|
|
96
|
+
expect(legacy.inputs.instruction).toEqual({
|
|
97
|
+
schema: instructionSchema(),
|
|
98
|
+
})
|
|
99
|
+
// 未宣言 {} も旧形式として { schema: {} } になる
|
|
73
100
|
expect(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
),
|
|
77
|
-
).toEqual(["in"])
|
|
78
|
-
// kind が固定しない key は宣言できない
|
|
79
|
-
expect(() =>
|
|
80
|
-
aiManifestSchema.parse({ ...valid, inputs: { in: {}, extra: {} } }),
|
|
81
|
-
).toThrow()
|
|
101
|
+
aiManifestSchema.parse({ ...valid, inputs: { in: {} } }).inputs.in,
|
|
102
|
+
).toEqual({ schema: {} })
|
|
82
103
|
})
|
|
83
104
|
|
|
84
105
|
it("outputs は1件以上・key 自由(AI の複数出口=分岐)", () => {
|
|
85
106
|
expect(() => aiManifestSchema.parse({ ...valid, outputs: {} })).toThrow()
|
|
86
107
|
expect(() =>
|
|
87
|
-
aiManifestSchema.parse({ ...valid, outputs: { reply:
|
|
108
|
+
aiManifestSchema.parse({ ...valid, outputs: { reply: textOutput } }),
|
|
88
109
|
).not.toThrow()
|
|
89
110
|
expect(() =>
|
|
90
111
|
aiManifestSchema.parse({
|
|
91
112
|
...valid,
|
|
92
|
-
outputs: { spam:
|
|
113
|
+
outputs: { spam: textOutput, sales: textOutput, support: textOutput },
|
|
93
114
|
}),
|
|
94
115
|
).not.toThrow()
|
|
95
116
|
})
|
|
96
117
|
|
|
97
|
-
it("outputs
|
|
118
|
+
it("outputs は任意の JSON Schema を受け付ける(形の強制はしない)", () => {
|
|
98
119
|
for (const schema of [
|
|
99
120
|
{}, // any
|
|
100
121
|
{ type: "string" },
|
|
101
|
-
{ type: "object", properties: { text: { type: "string" } } },
|
|
102
|
-
{
|
|
103
|
-
type: "object",
|
|
104
|
-
properties: { data: { type: "string" } }, // description なし
|
|
105
|
-
required: ["data"],
|
|
106
|
-
},
|
|
122
|
+
{ type: "object", properties: { text: { type: "string" } } },
|
|
123
|
+
{ type: "array", items: { type: "number" } },
|
|
107
124
|
{
|
|
125
|
+
// かつて強制していた { data, description } もただの object として valid
|
|
108
126
|
type: "object",
|
|
109
127
|
properties: {
|
|
110
128
|
data: { type: "string" },
|
|
111
|
-
description: { type: "string" },
|
|
129
|
+
description: { type: "string" },
|
|
112
130
|
},
|
|
113
|
-
required: ["data"],
|
|
131
|
+
required: ["data", "description"],
|
|
114
132
|
},
|
|
115
133
|
]) {
|
|
116
134
|
expect(() =>
|
|
117
135
|
aiManifestSchema.parse({ ...valid, outputs: { main: schema } }),
|
|
118
|
-
).toThrow()
|
|
119
|
-
// 妥当な出口に混ざっていても、封筒型でない出口があれば弾く
|
|
120
|
-
expect(() =>
|
|
121
|
-
aiManifestSchema.parse({
|
|
122
|
-
...valid,
|
|
123
|
-
outputs: { main: envelope, other: schema },
|
|
124
|
-
}),
|
|
125
|
-
).toThrow()
|
|
136
|
+
).not.toThrow()
|
|
126
137
|
}
|
|
127
|
-
// data が任意の構造(カタログ外の object)でも通る
|
|
128
|
-
expect(() =>
|
|
129
|
-
aiManifestSchema.parse({
|
|
130
|
-
...valid,
|
|
131
|
-
outputs: {
|
|
132
|
-
main: envelopeSchemaOf({
|
|
133
|
-
type: "object",
|
|
134
|
-
properties: {
|
|
135
|
-
title: { type: "string" },
|
|
136
|
-
count: { type: "number" },
|
|
137
|
-
},
|
|
138
|
-
required: ["title"],
|
|
139
|
-
}),
|
|
140
|
-
},
|
|
141
|
-
}),
|
|
142
|
-
).not.toThrow()
|
|
143
138
|
})
|
|
144
139
|
|
|
145
140
|
it("ディレクトリ名として不正な skill 名を拒否する", () => {
|
package/_schemas/ai-manifest.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { z } from "zod"
|
|
2
|
-
import { jsonSchemaSchema } from "./node.js"
|
|
3
|
-
import { isAiEnvelopeSchema, manifestInKeysViolation } from "./port-spec.js"
|
|
2
|
+
import { jsonSchemaSchema, PORT_KEY_PATTERN } from "./node.js"
|
|
4
3
|
|
|
5
4
|
// AI ノードのモデル選択肢(既定は Opus:下書き品質が価値の中心。費用はユーザー持ち)。
|
|
6
5
|
// エイリアス("opus" 等)は採らずバージョンを固定する:実行のたびに黙って別モデルへ
|
|
@@ -15,6 +14,23 @@ export const aiModelSchema = z.enum(AI_MODELS)
|
|
|
15
14
|
export type AiModel = z.infer<typeof aiModelSchema>
|
|
16
15
|
export const DEFAULT_AI_MODEL = "claude-opus-5" satisfies AiModel
|
|
17
16
|
|
|
17
|
+
// 入力口の宣言:スキーマ+実行属性。resume: true の口から入力が届いた実行は
|
|
18
|
+
// 同じノードの直近セッションを復元して再開する(backend が ports.resume へ同期して
|
|
19
|
+
// payload 発行時に参照する。ポート型固定の撤去とセッション再開のポート属性化.md)
|
|
20
|
+
export const aiInputDeclSchema = z.object({
|
|
21
|
+
schema: jsonSchemaSchema,
|
|
22
|
+
resume: z.boolean().optional(),
|
|
23
|
+
})
|
|
24
|
+
export type AiInputDecl = z.infer<typeof aiInputDeclSchema>
|
|
25
|
+
|
|
26
|
+
// 旧形式(値が素の JSON Schema)は { schema: 値 } として読む。判別は「schema キーを
|
|
27
|
+
// 持つか」:JSON Schema のトップレベルに "schema" キーは現れない(メタスキーマ参照は
|
|
28
|
+
// "$schema")ので実用上一意。書き戻し(backend の syncNodeConfigs)は常に新形式で書く
|
|
29
|
+
const aiInputValueSchema = z.union([
|
|
30
|
+
aiInputDeclSchema,
|
|
31
|
+
jsonSchemaSchema.transform((schema): AiInputDecl => ({ schema })),
|
|
32
|
+
])
|
|
33
|
+
|
|
18
34
|
// nodes/ai/<dir>/config.json(実行repoの構造見直し)。AI ノードの定義(prompt/model/skills/
|
|
19
35
|
// 入出力スキーマ/env)は repo 側が正で、エディタが取り込んで ports を自動生成する
|
|
20
36
|
// (実行時の正は ports テーブルのまま。プログラム規約 v2 と同じ関係)。
|
|
@@ -42,38 +58,37 @@ export const aiManifestSchema = z
|
|
|
42
58
|
// 実行中の経過(発話・ツール呼び出し)を Actions ログへ逐次出すデバッグフラグ。
|
|
43
59
|
// 通常時は config.json にキーを書かない(インスペクタも false ならキーを消す)
|
|
44
60
|
verbose: z.boolean().optional(),
|
|
45
|
-
inputs: z.record(z.string(),
|
|
61
|
+
inputs: z.record(z.string(), aiInputValueSchema),
|
|
46
62
|
outputs: z.record(z.string(), jsonSchemaSchema),
|
|
47
63
|
})
|
|
48
64
|
.superRefine((manifest, ctx) => {
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
Object.keys(manifest.inputs),
|
|
54
|
-
)
|
|
55
|
-
if (inViolation) {
|
|
56
|
-
ctx.addIssue({ code: "custom", path: ["inputs"], message: inViolation })
|
|
57
|
-
}
|
|
58
|
-
// outputs は1件以上・key 自由(複数出口=分岐。AIノードの複数出口(分岐).md)
|
|
59
|
-
if (Object.keys(manifest.outputs).length < 1) {
|
|
65
|
+
// inputs は1件以上・key 自由(in / instruction の固定構成は廃止。
|
|
66
|
+
// セッション再開は key ではなく resume 属性で決まる)
|
|
67
|
+
const inKeys = Object.keys(manifest.inputs)
|
|
68
|
+
if (inKeys.length < 1) {
|
|
60
69
|
ctx.addIssue({
|
|
61
70
|
code: "custom",
|
|
62
|
-
path: ["
|
|
63
|
-
message: "
|
|
71
|
+
path: ["inputs"],
|
|
72
|
+
message: "inputs は1件以上宣言してください",
|
|
64
73
|
})
|
|
65
74
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// data=T は任意の JSON Schema)
|
|
69
|
-
for (const [key, schema] of Object.entries(manifest.outputs)) {
|
|
70
|
-
if (!isAiEnvelopeSchema(schema)) {
|
|
75
|
+
for (const key of inKeys) {
|
|
76
|
+
if (!PORT_KEY_PATTERN.test(key)) {
|
|
71
77
|
ctx.addIssue({
|
|
72
78
|
code: "custom",
|
|
73
|
-
path: ["
|
|
74
|
-
message: `
|
|
79
|
+
path: ["inputs", key],
|
|
80
|
+
message: `inputs の key が不正です: ${key}(英小文字始まり・英数字と _ のみ)`,
|
|
75
81
|
})
|
|
76
82
|
}
|
|
77
83
|
}
|
|
84
|
+
// outputs は1件以上・key 自由(複数出口=分岐。AIノードの複数出口(分岐).md)。
|
|
85
|
+
// スキーマは任意の JSON Schema(形の強制はしない)
|
|
86
|
+
if (Object.keys(manifest.outputs).length < 1) {
|
|
87
|
+
ctx.addIssue({
|
|
88
|
+
code: "custom",
|
|
89
|
+
path: ["outputs"],
|
|
90
|
+
message: "outputs は1件以上宣言してください",
|
|
91
|
+
})
|
|
92
|
+
}
|
|
78
93
|
})
|
|
79
94
|
export type AiManifest = z.infer<typeof aiManifestSchema>
|
|
@@ -216,6 +216,26 @@ describe("duplicateSelection(選択ノードの複製)", () => {
|
|
|
216
216
|
)
|
|
217
217
|
})
|
|
218
218
|
|
|
219
|
+
it("手動ルート(waypoints)は dx / dy を適用して引き継ぐ", () => {
|
|
220
|
+
const { b, c, nodes, connections } = chain()
|
|
221
|
+
const manual = connections.map((conn, i) =>
|
|
222
|
+
i === 1
|
|
223
|
+
? {
|
|
224
|
+
...conn,
|
|
225
|
+
waypoints: [
|
|
226
|
+
{ x: 150, y: 0 },
|
|
227
|
+
{ x: 150, y: 100 },
|
|
228
|
+
],
|
|
229
|
+
}
|
|
230
|
+
: conn,
|
|
231
|
+
)
|
|
232
|
+
const result = dup(nodes, manual, [b.id, c.id], 40, -20)
|
|
233
|
+
expect(result.connections[0]?.waypoints).toEqual([
|
|
234
|
+
{ x: 190, y: -20 },
|
|
235
|
+
{ x: 190, y: 80 },
|
|
236
|
+
])
|
|
237
|
+
})
|
|
238
|
+
|
|
219
239
|
it("dx / dy を全ノードへ一律に適用し、相対配置を保つ", () => {
|
|
220
240
|
const { b, c, nodes, connections } = chain()
|
|
221
241
|
const result = dup(nodes, connections, [b.id, c.id], 40, -20)
|
|
@@ -37,11 +37,27 @@ export const duplicateSelection = (params: {
|
|
|
37
37
|
}))
|
|
38
38
|
|
|
39
39
|
// 両端が複製対象のポートである線(=選択内で完結する線)だけを張り替えて写す。
|
|
40
|
-
//
|
|
40
|
+
// 外部との線(入口・出口)はコピーしない。手動ルート(waypoints)は
|
|
41
|
+
// ノードと同じ移動量で写し、調整済みの形を保つ
|
|
41
42
|
const copiedConnections = connections.flatMap((c) => {
|
|
42
43
|
const from = portIdMap.get(c.from_port_id)
|
|
43
44
|
const to = portIdMap.get(c.to_port_id)
|
|
44
|
-
return from && to
|
|
45
|
+
return from && to
|
|
46
|
+
? [
|
|
47
|
+
{
|
|
48
|
+
from_port_id: from,
|
|
49
|
+
to_port_id: to,
|
|
50
|
+
...(c.waypoints
|
|
51
|
+
? {
|
|
52
|
+
waypoints: c.waypoints.map((p) => ({
|
|
53
|
+
x: p.x + dx,
|
|
54
|
+
y: p.y + dy,
|
|
55
|
+
})),
|
|
56
|
+
}
|
|
57
|
+
: {}),
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
: []
|
|
45
61
|
})
|
|
46
62
|
|
|
47
63
|
return { nodes: copied, connections: copiedConnections }
|
|
@@ -4,18 +4,22 @@ import {
|
|
|
4
4
|
buildExtractPlan,
|
|
5
5
|
} from "./extract-component.js"
|
|
6
6
|
import type { GraphNode } from "./graph.js"
|
|
7
|
-
import type { GraphPort } from "./node.js"
|
|
7
|
+
import type { GraphPort, JsonSchema } from "./node.js"
|
|
8
8
|
|
|
9
9
|
// テスト用の決定的な uuid(graph.test.ts と同じ流儀)
|
|
10
10
|
let seq = 0
|
|
11
11
|
const uuid = () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`
|
|
12
12
|
|
|
13
|
-
const port = (
|
|
13
|
+
const port = (
|
|
14
|
+
io: "in" | "out",
|
|
15
|
+
key: string,
|
|
16
|
+
schema: JsonSchema = { type: "object" },
|
|
17
|
+
): GraphPort => ({
|
|
14
18
|
id: uuid(),
|
|
15
19
|
io,
|
|
16
20
|
key,
|
|
17
21
|
name: key,
|
|
18
|
-
schema
|
|
22
|
+
schema,
|
|
19
23
|
})
|
|
20
24
|
|
|
21
25
|
const makeNode = (
|
|
@@ -62,44 +66,60 @@ const chain = () => {
|
|
|
62
66
|
return { a, b, c, d, nodes, connections }
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
describe("analyzeExtractSelection
|
|
69
|
+
describe("analyzeExtractSelection(境界の分類と成立条件)", () => {
|
|
66
70
|
it("直列の中間2ノード:入口・出口と内部/入口/出口線を分類する", () => {
|
|
67
71
|
const { a, b, c, d, nodes, connections } = chain()
|
|
68
72
|
const result = analyzeExtractSelection(nodes, connections, [b.id, c.id])
|
|
69
73
|
if (!result.ok) throw new Error(result.reason)
|
|
70
|
-
expect(result.
|
|
71
|
-
expect(result.
|
|
74
|
+
expect(result.entryPortIds).toEqual([inOf(b).id])
|
|
75
|
+
expect(result.exitPortIds).toEqual([outOf(c).id])
|
|
72
76
|
expect(result.internal).toEqual([connect(b, c)])
|
|
73
77
|
expect(result.inbound).toEqual([connect(a, b)])
|
|
74
78
|
expect(result.outbound).toEqual([connect(c, d)])
|
|
75
79
|
})
|
|
76
80
|
|
|
77
|
-
it("
|
|
81
|
+
it("孤立した島(入出力なし)も抽出できる(入口・出口は空)", () => {
|
|
78
82
|
const { b, c, nodes } = chain()
|
|
79
83
|
const internalOnly = [connect(b, c)]
|
|
80
84
|
const result = analyzeExtractSelection(nodes, internalOnly, [b.id, c.id])
|
|
81
85
|
if (!result.ok) throw new Error(result.reason)
|
|
82
|
-
expect(result.
|
|
83
|
-
expect(result.
|
|
86
|
+
expect(result.entryPortIds).toEqual([])
|
|
87
|
+
expect(result.exitPortIds).toEqual([])
|
|
84
88
|
})
|
|
85
89
|
|
|
86
|
-
it("
|
|
90
|
+
it("入口が複数でも型が同じなら成立する(同型の合流)", () => {
|
|
87
91
|
const { a, b, c, d, nodes, connections } = chain()
|
|
88
|
-
// A→B
|
|
92
|
+
// A→B に加えて D→C:選択 {B, C} への外部入力が B.in と C.in の2ポートに入る。
|
|
93
|
+
// どちらの in も {type:"object"} なので成立する
|
|
89
94
|
const withSecondEntry = [...connections, connect(d, c)]
|
|
90
95
|
const result = analyzeExtractSelection(nodes, withSecondEntry, [b.id, c.id])
|
|
96
|
+
if (!result.ok) throw new Error(result.reason)
|
|
97
|
+
expect(result.entryPortIds.sort()).toEqual([inOf(b).id, inOf(c).id].sort())
|
|
98
|
+
void a
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("入口の型が揃っていない選択は拒否する", () => {
|
|
102
|
+
const { a, b, d, nodes, connections } = chain()
|
|
103
|
+
// in の型が違うノードを選択に足し、外部から線を入れる
|
|
104
|
+
const e = {
|
|
105
|
+
...makeNode("program", "E", 200, 200),
|
|
106
|
+
ports: [port("in", "in", { type: "string" }), port("out", "main")],
|
|
107
|
+
}
|
|
108
|
+
const withE = [...nodes, e]
|
|
109
|
+
const conns = [...connections, connect(d, e)]
|
|
110
|
+
const result = analyzeExtractSelection(withE, conns, [b.id, e.id])
|
|
91
111
|
expect(result.ok).toBe(false)
|
|
92
|
-
if (!result.ok) expect(result.reason).toContain("
|
|
112
|
+
if (!result.ok) expect(result.reason).toContain("入口の型")
|
|
93
113
|
void a
|
|
94
114
|
})
|
|
95
115
|
|
|
96
|
-
it("
|
|
116
|
+
it("出口は複数ポートでも成立する(同一ポートからの fan-out は1出口扱い)", () => {
|
|
97
117
|
const { a, b, c, d, nodes, connections } = chain()
|
|
98
118
|
// B→D を追加:選択 {B, C} から外への出力が B.main と C.main の2ポート
|
|
99
119
|
const twoExits = [...connections, connect(b, d)]
|
|
100
120
|
const result = analyzeExtractSelection(nodes, twoExits, [b.id, c.id])
|
|
101
|
-
|
|
102
|
-
|
|
121
|
+
if (!result.ok) throw new Error(result.reason)
|
|
122
|
+
expect(result.exitPortIds.sort()).toEqual([outOf(b).id, outOf(c).id].sort())
|
|
103
123
|
|
|
104
124
|
// C.main → D に加えて C.main → A(同一ポートの fan-out)は出口1つ扱い
|
|
105
125
|
const fanOut = [
|
|
@@ -107,13 +127,24 @@ describe("analyzeExtractSelection(境界の分類と V1 制約)", () => {
|
|
|
107
127
|
{ from_port_id: outOf(c).id, to_port_id: inOf(a).id },
|
|
108
128
|
]
|
|
109
129
|
const ok = analyzeExtractSelection(nodes, fanOut, [b.id, c.id])
|
|
110
|
-
|
|
130
|
+
if (!ok.ok) throw new Error(ok.reason)
|
|
131
|
+
expect(ok.exitPortIds).toEqual([outOf(c).id])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("1ノード選択:そのノードの in が入口、その out が出口になる", () => {
|
|
135
|
+
const { a, b, c, nodes, connections } = chain()
|
|
136
|
+
const result = analyzeExtractSelection(nodes, connections, [b.id])
|
|
137
|
+
if (!result.ok) throw new Error(result.reason)
|
|
138
|
+
expect(result.entryPortIds).toEqual([inOf(b).id])
|
|
139
|
+
expect(result.exitPortIds).toEqual([outOf(b).id])
|
|
140
|
+
expect(result.internal).toEqual([])
|
|
141
|
+
expect(result.inbound).toEqual([connect(a, b)])
|
|
142
|
+
expect(result.outbound).toEqual([connect(b, c)])
|
|
111
143
|
})
|
|
112
144
|
|
|
113
|
-
it("空・
|
|
145
|
+
it("空・start/end 含みは拒否する", () => {
|
|
114
146
|
const { b, nodes, connections } = chain()
|
|
115
147
|
expect(analyzeExtractSelection(nodes, connections, []).ok).toBe(false)
|
|
116
|
-
expect(analyzeExtractSelection(nodes, connections, [b.id]).ok).toBe(false)
|
|
117
148
|
|
|
118
149
|
const start = makeNode("start", "開始")
|
|
119
150
|
const withStart = [...nodes, start]
|
|
@@ -127,10 +158,20 @@ describe("analyzeExtractSelection(境界の分類と V1 制約)", () => {
|
|
|
127
158
|
})
|
|
128
159
|
|
|
129
160
|
describe("buildExtractPlan(子グラフと親置換の生成)", () => {
|
|
161
|
+
const childEndNode = (): GraphNode => ({
|
|
162
|
+
id: uuid(),
|
|
163
|
+
kind: "end",
|
|
164
|
+
name: "終了",
|
|
165
|
+
ports: [port("in", "in", {}), port("out", "main", {})],
|
|
166
|
+
hooks: [],
|
|
167
|
+
position_x: 560,
|
|
168
|
+
position_y: 200,
|
|
169
|
+
})
|
|
170
|
+
|
|
130
171
|
const build = () => {
|
|
131
172
|
const { a, b, c, d, nodes, connections } = chain()
|
|
132
173
|
const childStartOutPortId = uuid()
|
|
133
|
-
const
|
|
174
|
+
const childEnd = childEndNode()
|
|
134
175
|
const componentLoopId = uuid()
|
|
135
176
|
const plan = buildExtractPlan({
|
|
136
177
|
nodes,
|
|
@@ -139,7 +180,7 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
|
|
|
139
180
|
name: "PRレビュー",
|
|
140
181
|
componentLoopId,
|
|
141
182
|
childStartOutPortId,
|
|
142
|
-
|
|
183
|
+
childEnd,
|
|
143
184
|
newId: uuid,
|
|
144
185
|
})
|
|
145
186
|
if (!plan.ok) throw new Error(plan.reason)
|
|
@@ -152,7 +193,7 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
|
|
|
152
193
|
connections,
|
|
153
194
|
plan,
|
|
154
195
|
childStartOutPortId,
|
|
155
|
-
|
|
196
|
+
childEnd,
|
|
156
197
|
componentLoopId,
|
|
157
198
|
}
|
|
158
199
|
}
|
|
@@ -175,18 +216,20 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
|
|
|
175
216
|
expect(internal).toBeDefined()
|
|
176
217
|
})
|
|
177
218
|
|
|
178
|
-
it("子:start.main →
|
|
179
|
-
const { plan, childStartOutPortId,
|
|
219
|
+
it("子:start.main → 入口、出口ポート → end.in が配線される(出口1つは seed の in を使う)", () => {
|
|
220
|
+
const { plan, childStartOutPortId, childEnd } = build()
|
|
180
221
|
const [nb, nc] = plan.childNodes
|
|
181
222
|
if (!nb || !nc) throw new Error("コピーがない")
|
|
182
223
|
expect(plan.childConnections).toContainEqual({
|
|
183
224
|
from_port_id: childStartOutPortId,
|
|
184
225
|
to_port_id: inOf(nb).id,
|
|
185
226
|
})
|
|
227
|
+
// seed 済みの end.in がそのまま使われ、名前は出口ポート名になる
|
|
186
228
|
expect(plan.childConnections).toContainEqual({
|
|
187
229
|
from_port_id: outOf(nc).id,
|
|
188
|
-
to_port_id:
|
|
230
|
+
to_port_id: inOf(childEnd).id,
|
|
189
231
|
})
|
|
232
|
+
expect(inOf(plan.childEnd).name).toBe(outOf(nc).name)
|
|
190
233
|
})
|
|
191
234
|
|
|
192
235
|
it("親:選択が groupNode に置き換わり、外部との線が in/main へ繋ぎ直される", () => {
|
|
@@ -199,6 +242,10 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
|
|
|
199
242
|
expect(plan.groupNode.name).toBe("PRレビュー")
|
|
200
243
|
expect(plan.groupNode.position_x).toBe(150) // (100+200)/2
|
|
201
244
|
expect(plan.groupNode.position_y).toBe(50) // (0+100)/2
|
|
245
|
+
// 出口1つ:out は互換の main 1口
|
|
246
|
+
expect(
|
|
247
|
+
plan.groupNode.ports.filter((p) => p.io === "out").map((p) => p.key),
|
|
248
|
+
).toEqual(["main"])
|
|
202
249
|
|
|
203
250
|
// 親 nodes:A, D, groupNode(B, C は消える)
|
|
204
251
|
expect(plan.parentNodes.map((n) => n.id).sort()).toEqual(
|
|
@@ -217,4 +264,134 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
|
|
|
217
264
|
void b
|
|
218
265
|
void c
|
|
219
266
|
})
|
|
267
|
+
|
|
268
|
+
it("複数入口(同型):start.main から全入口へ fan-out し、親の同一起点は group.in への1本に畳まれる", () => {
|
|
269
|
+
// X.main が B と C の両方へ入る(今回の実例:開発.main → レビューと監視)
|
|
270
|
+
const { a, b, c, d, nodes, connections } = chain()
|
|
271
|
+
const conns = [
|
|
272
|
+
...connections,
|
|
273
|
+
{ from_port_id: outOf(a).id, to_port_id: inOf(c).id },
|
|
274
|
+
]
|
|
275
|
+
const childStartOutPortId = uuid()
|
|
276
|
+
const plan = buildExtractPlan({
|
|
277
|
+
nodes,
|
|
278
|
+
connections: conns,
|
|
279
|
+
selectedIds: [b.id, c.id],
|
|
280
|
+
name: "レビュー",
|
|
281
|
+
componentLoopId: uuid(),
|
|
282
|
+
childStartOutPortId,
|
|
283
|
+
childEnd: childEndNode(),
|
|
284
|
+
newId: uuid,
|
|
285
|
+
})
|
|
286
|
+
if (!plan.ok) throw new Error(plan.reason)
|
|
287
|
+
const [nb, nc] = plan.childNodes
|
|
288
|
+
if (!nb || !nc) throw new Error("コピーがない")
|
|
289
|
+
// 子:start.main → B.in と C.in の両方
|
|
290
|
+
expect(plan.childConnections).toContainEqual({
|
|
291
|
+
from_port_id: childStartOutPortId,
|
|
292
|
+
to_port_id: inOf(nb).id,
|
|
293
|
+
})
|
|
294
|
+
expect(plan.childConnections).toContainEqual({
|
|
295
|
+
from_port_id: childStartOutPortId,
|
|
296
|
+
to_port_id: inOf(nc).id,
|
|
297
|
+
})
|
|
298
|
+
// 親:A.main → group.in は1本だけ(重複線を作らない)
|
|
299
|
+
const inboundToGroup = plan.parentConnections.filter(
|
|
300
|
+
(conn) => conn.to_port_id === inOf(plan.groupNode).id,
|
|
301
|
+
)
|
|
302
|
+
expect(inboundToGroup).toEqual([
|
|
303
|
+
{ from_port_id: outOf(a).id, to_port_id: inOf(plan.groupNode).id },
|
|
304
|
+
])
|
|
305
|
+
void d
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it("複数出口:出発ポートごとに end の in と group の out が生え、親は対応する out から繋ぎ直される", () => {
|
|
309
|
+
const { a, b, c, d, nodes, connections } = chain()
|
|
310
|
+
// B→D を追加:出口は B.main と C.main の2つ
|
|
311
|
+
const conns = [...connections, connect(b, d)]
|
|
312
|
+
const childEnd = childEndNode()
|
|
313
|
+
const plan = buildExtractPlan({
|
|
314
|
+
nodes,
|
|
315
|
+
connections: conns,
|
|
316
|
+
selectedIds: [b.id, c.id],
|
|
317
|
+
name: "レビュー",
|
|
318
|
+
componentLoopId: uuid(),
|
|
319
|
+
childStartOutPortId: uuid(),
|
|
320
|
+
childEnd,
|
|
321
|
+
newId: uuid,
|
|
322
|
+
})
|
|
323
|
+
if (!plan.ok) throw new Error(plan.reason)
|
|
324
|
+
const [nb, nc] = plan.childNodes
|
|
325
|
+
if (!nb || !nc) throw new Error("コピーがない")
|
|
326
|
+
|
|
327
|
+
// 子 end:in が出口の数だけある(1つ目は seed 再利用・2つ目は追加)
|
|
328
|
+
const endIns = plan.childEnd.ports.filter((p) => p.io === "in")
|
|
329
|
+
expect(endIns).toHaveLength(2)
|
|
330
|
+
// 出口ポート → 対応する end.in の配線
|
|
331
|
+
const endInByExit = new Map(
|
|
332
|
+
plan.childConnections
|
|
333
|
+
.filter((conn) => endIns.some((p) => p.id === conn.to_port_id))
|
|
334
|
+
.map((conn) => [conn.from_port_id, conn.to_port_id] as const),
|
|
335
|
+
)
|
|
336
|
+
expect(endInByExit.get(outOf(nc).id)).toBeDefined()
|
|
337
|
+
expect(endInByExit.get(outOf(nb).id)).toBeDefined()
|
|
338
|
+
|
|
339
|
+
// group:出口ごとの out(key は end.in の射影)
|
|
340
|
+
const groupOuts = plan.groupNode.ports.filter((p) => p.io === "out")
|
|
341
|
+
expect(groupOuts).toHaveLength(2)
|
|
342
|
+
expect(groupOuts.map((p) => p.key).sort()).toEqual(
|
|
343
|
+
endIns.map((p) => p.key).sort(),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
// 親:C.main → D は「C.main に対応する out」→ D、B.main → D は「B.main に対応する out」→ D
|
|
347
|
+
const parentOutConns = plan.parentConnections.filter((conn) =>
|
|
348
|
+
groupOuts.some((p) => p.id === conn.from_port_id),
|
|
349
|
+
)
|
|
350
|
+
expect(parentOutConns).toHaveLength(2)
|
|
351
|
+
expect(new Set(parentOutConns.map((c2) => c2.to_port_id))).toEqual(
|
|
352
|
+
new Set([inOf(d).id]),
|
|
353
|
+
)
|
|
354
|
+
// 2本は別々の out から出る(同じ out からの fan-out ではない)
|
|
355
|
+
expect(new Set(parentOutConns.map((c2) => c2.from_port_id)).size).toBe(2)
|
|
356
|
+
void a
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it("1ノード選択:start.main → ノード → end.in と配線され、親では component ノードに置き換わる", () => {
|
|
360
|
+
const { a, b, c, nodes, connections } = chain()
|
|
361
|
+
const childStartOutPortId = uuid()
|
|
362
|
+
const childEnd = childEndNode()
|
|
363
|
+
const plan = buildExtractPlan({
|
|
364
|
+
nodes,
|
|
365
|
+
connections,
|
|
366
|
+
selectedIds: [b.id],
|
|
367
|
+
name: "待つだけ",
|
|
368
|
+
componentLoopId: uuid(),
|
|
369
|
+
childStartOutPortId,
|
|
370
|
+
childEnd,
|
|
371
|
+
newId: uuid,
|
|
372
|
+
})
|
|
373
|
+
if (!plan.ok) throw new Error(plan.reason)
|
|
374
|
+
expect(plan.childNodes).toHaveLength(1)
|
|
375
|
+
const [nb] = plan.childNodes
|
|
376
|
+
if (!nb) throw new Error("コピーがない")
|
|
377
|
+
expect(nb.wait).toEqual({ wait_type: "duration", duration_seconds: 90 })
|
|
378
|
+
expect(plan.childConnections).toContainEqual({
|
|
379
|
+
from_port_id: childStartOutPortId,
|
|
380
|
+
to_port_id: inOf(nb).id,
|
|
381
|
+
})
|
|
382
|
+
expect(plan.childConnections).toContainEqual({
|
|
383
|
+
from_port_id: outOf(nb).id,
|
|
384
|
+
to_port_id: inOf(childEnd).id,
|
|
385
|
+
})
|
|
386
|
+
// 親:B が component ノードに置き換わり、A.main → group.in/group.main → C.in
|
|
387
|
+
expect(plan.parentNodes.some((n) => n.id === b.id)).toBe(false)
|
|
388
|
+
expect(plan.parentConnections).toContainEqual({
|
|
389
|
+
from_port_id: outOf(a).id,
|
|
390
|
+
to_port_id: inOf(plan.groupNode).id,
|
|
391
|
+
})
|
|
392
|
+
expect(plan.parentConnections).toContainEqual({
|
|
393
|
+
from_port_id: outOf(plan.groupNode).id,
|
|
394
|
+
to_port_id: inOf(c).id,
|
|
395
|
+
})
|
|
396
|
+
})
|
|
220
397
|
})
|