@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.
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { AI_MODELS, aiManifestSchema } from "./ai-manifest.js"
3
- import { envelopeSchemaOf, instructionSchema } from "./port-spec.js"
3
+ import { instructionSchema } from "./port-spec.js"
4
4
 
5
- const envelope = envelopeSchemaOf({ type: "string" })
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: envelope },
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: envelope },
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('inputs は "in" 必須・instruction は任意(kind が固定する2口)', () => {
60
- // in は必須
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: { in: {}, instruction: instructionSchema() },
70
- }).inputs.instruction,
71
- ).toEqual(instructionSchema())
72
- // in を2口に分ける前に同期された config.json(in だけ)もそのまま読める
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
- Object.keys(
75
- aiManifestSchema.parse({ ...valid, inputs: { in: {} } }).inputs,
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: envelope } }),
108
+ aiManifestSchema.parse({ ...valid, outputs: { reply: textOutput } }),
88
109
  ).not.toThrow()
89
110
  expect(() =>
90
111
  aiManifestSchema.parse({
91
112
  ...valid,
92
- outputs: { spam: envelope, sales: envelope, support: envelope },
113
+ outputs: { spam: textOutput, sales: textOutput, support: textOutput },
93
114
  }),
94
115
  ).not.toThrow()
95
116
  })
96
117
 
97
- it("outputs は全出口に封筒型 { data, description } を強制する(data は任意の型)", () => {
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" } } }, // data キーなし
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" }, // description が optional
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 名を拒否する", () => {
@@ -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(), jsonSchemaSchema),
61
+ inputs: z.record(z.string(), aiInputValueSchema),
46
62
  outputs: z.record(z.string(), jsonSchemaSchema),
47
63
  })
48
64
  .superRefine((manifest, ctx) => {
49
- // in の構成は kind が固定する(ai は上流データの in 差し戻しの instruction の2口)。
50
- // ルールの正は port-spec 側で、ここは参照するだけ
51
- const inViolation = manifestInKeysViolation(
52
- "ai",
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: ["outputs"],
63
- message: "outputs は1件以上宣言してください",
71
+ path: ["inputs"],
72
+ message: "inputs は1件以上宣言してください",
64
73
  })
65
74
  }
66
- // Human 承認 UI が依存する封筒型 { data: T, description: string } を全出口に強制
67
- // (どの出口も human に接続できる、という AI ノードの不変条件。saveGraph と同じ判定。
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: ["outputs", key],
74
- message: `outputs.${key} は封筒型 { data, description } で宣言してください(両方必須・description は string)`,
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 ? [{ from_port_id: from, to_port_id: 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 = (io: "in" | "out", key: string): GraphPort => ({
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: { type: "object" },
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(境界の分類と V1 制約)", () => {
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.entryNodeId).toBe(b.id)
71
- expect(result.exitPortId).toBe(outOf(c).id)
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("孤立した島(入出力なし)も抽出できる(entry/exit は null)", () => {
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.entryNodeId).toBeNull()
83
- expect(result.exitPortId).toBeNull()
86
+ expect(result.entryPortIds).toEqual([])
87
+ expect(result.exitPortIds).toEqual([])
84
88
  })
85
89
 
86
- it("入口が2ノードになる選択は拒否する", () => {
90
+ it("入口が複数でも型が同じなら成立する(同型の合流)", () => {
87
91
  const { a, b, c, d, nodes, connections } = chain()
88
- // A→B D→C を追加:選択 {B, C} への外部入力が B と C の2ノードに入る
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("出口が2ポートになる選択は拒否する(同一ポートからの fan-out は許す)", () => {
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
- expect(result.ok).toBe(false)
102
- if (!result.ok) expect(result.reason).toContain("出口")
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
- expect(ok.ok).toBe(true)
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("空・1ノードのみ・start/end 含みは拒否する", () => {
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 childEndInPortId = uuid()
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
- childEndInPortId,
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
- childEndInPortId,
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 → 入口ノード、出口ポート → end.in が配線される", () => {
179
- const { plan, childStartOutPortId, childEndInPortId } = build()
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: childEndInPortId,
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
  })