Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-01 23:30:25 +09:00
3 changed files with 493 additions and 44 deletions
+19
View File
@@ -206,6 +206,25 @@ export async function cfLogicValue(
return send("/logic-value", { key, designs });
}
/** 마스터 로직의 흐름 — 줄에 「흐름」 이 있으면 그대로(converted false) · 없으면 옛 로직을 흐름도로 바꿔 읽음(converted true ·
* notes = 못 옮긴 곳 쉬운 말 · 파일은 안 바뀜) — `GET /api/m01/logic/flow` */
export async function cfLogicFlow(
file: string,
key: string,
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/m01/logic/flow?${new URLSearchParams({ file, key })}`, {
credentials: "include",
});
} catch {
throw new CfError(0, "");
}
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText);
return data as { flow: CfLogic; converted: boolean; notes: string[] };
}
/** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(프로젝트 일위대가가 쓸 꼴) */
export async function cfDesignInputs(key: string): Promise<CfDesign[]> {
const qs = new URLSearchParams({ key }).toString();
@@ -1,42 +1,83 @@
/* =============================================================================
* M02_DF_InputPanel_Logic.ts
* 입력 판 — 입력 갈래 「다른 로직」: 저장한 흐름도 로직(또는 견본)을 찾아 고르면
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값)
* 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽)와 내가 만든 흐름도 로직 가운데 하나를 고르면
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길
* 옛 로직은 서버가 흐름도로 바꿔 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { cfGet, cfList, cfPlain, type CfListItem } from "./M02_DF_Api";
import type { CfLogic } from "./M02_DF_Model";
import { logicRefOf, setKind, setLogicInput } from "./M02_DF_InputPanel_Build";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api";
import type { CfLogic, CfLogicRef } from "./M02_DF_Model";
import {
filterLogics,
logicBooks,
logicChapters,
logicRefOf,
masterRef,
pageRange,
setKind,
setLogicInput,
wouldCycle,
type MasterLogicBrief,
} from "./M02_DF_InputPanel_Build";
const TEXT = {
Help: [
"저장한 로직이나 견본을 불러와 그 결과를 이 상자의 알약으로 씀",
"Use a saved logic or sample as an input",
"마스터 로직이나 내가 만든 흐름도 로직을 불러와 그 결과를 이 상자의 알약으로 씀",
"Use any master logic or a logic of mine as an input",
],
Search: ["로직 이름으로 찾기", "Search by name"],
Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
Book: ["원문", "Source"],
Chapter: ["장", "Chapter"],
All: ["전체", "All"],
Loading: ["찾는 중…", "Searching…"],
None: ["찾는 로직 없음", "No logic found"],
Sample: ["견본", "Sample"],
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
Prev: ["이전", "Prev"],
Next: ["다음", "Next"],
Mine: ["내가 만든 것", "Mine"],
Blocked: ["읽을 수 없음", "Unreadable"],
Chosen: ["고른 로직", "Chosen logic"],
Outs: ["내보내는 알약 — 그 로직의 출력", "Pills it sends — its outputs"],
Converted: [
"옛 로직을 흐름도로 바꿔 읽음 — 파일은 안 바뀜",
"Old logic read as a flow — the file is unchanged",
],
Notes: ["못 옮긴 곳", "Not carried over"],
Outs: ["내보내는 알약 — 그 로직의 출력 · 값 · 단위", "Pills it sends — its outputs"],
Designs: [
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 그 로직의 예시값)",
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 예시값)",
"Pills it takes — its design inputs",
],
NoOut: ["출력 상자가 없음", "No output box"],
NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"],
Change: ["다른 로직 고르기", "Pick another"],
Remove: ["빼기", "Remove"],
Self: ["이 로직 자신은 못 씀", "Can't use itself"],
Self: ["이 로직 자신은 못 고름", "Can't pick itself"],
Cycle: [
"돌고 도는 로직이 됨 — 이 로직을 부르는 로직은 못 고름",
"That would loop — a logic that uses this one can't be picked",
],
Failed: ["불러오지 못함", "Could not load"],
Wait: ["값 셈 중…", "Calculating…"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string;
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
Object.entries(v).reduce(
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
TEXT[key][currentLanguageIndex] as string,
);
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
const plain = (e: unknown): string => {
try {
@@ -46,6 +87,40 @@ const plain = (e: unknown): string => {
}
};
const PAGE = 12;
const MINE = "흐름도";
/** 마스터 로직 목록 한 번 + 내 흐름도 로직(견본은 뺌) */
let cache: Promise<MasterLogicBrief[]> | null = null;
const loadAll = (): Promise<MasterLogicBrief[]> =>
(cache ??= Promise.all([fetchLogics(), cfList().catch(() => [])])
.then(([master, mine]) => [
...mine
.filter((i) => !i.sample)
.map((i): MasterLogicBrief => ({
file: MINE,
구분: MINE,
상세구분: t("Mine"),
키: i.key,
원문번호: "",
이름: i.name,
결과단위: i.resultUnit,
})),
...master,
])
.catch((e: unknown) => ((cache = null), Promise.reject(e))));
/** 고른 로직에서 읽은 것 — 옛 로직을 바꿔 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */
const readNotes = new Map<string, { converted: boolean; notes: string[] }>();
/** 로직 하나를 읽어 흐름도로 — 내 흐름도 로직 = 서버 저장 그대로 · 마스터 = 흐름 받기 길 */
async function flowOf(
item: Pick<MasterLogicBrief, "file" | "키">,
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
if (item.file === MINE) return { flow: await cfGet(item.키), converted: false, notes: [] };
return cfLogicFlow(item.file, item.키);
}
/** 다른 로직 입력 판 */
export function renderLogicInput(
host: HTMLElement,
@@ -55,32 +130,86 @@ export function renderLogicInput(
): void {
const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic;
if (ref) return void showChosen(host, lg, boxId, ref, on);
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
const list = el("div", { className: "m02-dfip__list" });
let all: CfListItem[] = [];
const draw = (): void => {
const word = q.input.value.trim().toLowerCase();
const rows = all.filter(
(i) => i.key !== lg.key && (!word || i.name.toLowerCase().includes(word)),
const all = { value: "", text: t("All") };
let book = "";
let chapter = "";
let page = 1;
let items: MasterLogicBrief[] = [];
const bookBox = createSelectField({
label: t("Book"),
options: [all],
value: "",
compact: true,
onChange: (v) => ((book = v), (chapter = ""), (page = 1), fillChapters(), draw()),
});
const chapterBox = createSelectField({
label: t("Chapter"),
options: [all],
value: "",
compact: true,
onChange: (v) => ((chapter = v), (page = 1), draw()),
});
const fillChapters = (): void =>
chapterBox.setOptions(
[all, ...logicChapters(items, book).map((c) => ({ value: c, text: c }))],
chapter,
);
const list = el("div", { className: "m02-dfip__list" });
const info = el("span", { className: "m02-dfip__muted" });
const prev = createButton({
label: t("Prev"),
variant: "ghost",
size: "sm",
onClick: () => ((page -= 1), draw()),
});
const next = createButton({
label: t("Next"),
variant: "ghost",
size: "sm",
onClick: () => ((page += 1), draw()),
});
const draw = (): void => {
const rows = filterLogics(
items.filter((i) => i.키 !== lg.key), // 자기 자신은 목록에서 뺌
{ text: q.input.value, book, chapter },
);
const range = pageRange(rows.length, page, PAGE);
page = Math.min(page, range.pages);
const shown = rows.slice((page - 1) * PAGE, page * PAGE);
info.textContent = t("Total", {
n: rows.length,
a: range.from,
b: range.to,
p: page,
m: range.pages,
});
prev.disabled = page <= 1;
next.disabled = page >= range.pages;
list.replaceChildren(
...(rows.length
? rows.map((i) => {
...(shown.length
? shown.map((i) => {
const b = el("button", {
className: "m02-dfip__row",
attrs: { type: "button" },
attrs: {
type: "button",
...(i.blocked ? { disabled: "true", title: (i.reasons ?? []).join(" · ") } : {}),
},
children: [
el("span", {
className: "m02-dfip__name",
children: [
el("strong", { text: i.name }),
el("strong", { text: i.이름 }),
el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }),
el("span", {
className: "m02-dfip__tag",
text: i.sample ? t("Sample") : t("Mine"),
text: i.blocked ? t("Blocked") : i.file === MINE ? t("Mine") : i.상세구분,
}),
],
}),
el("span", { className: "m02-dfip__muted", text: i.resultUnit }),
el("span", { className: "m02-dfip__muted", text: i.결과단위 }),
],
});
b.addEventListener("click", () => void pick(i));
@@ -89,56 +218,114 @@ export function renderLogicInput(
: [el("p", { className: "m02-dfip__muted", text: t("None") })]),
);
};
const pick = async (i: CfListItem): Promise<void> => {
/** 순환 검사에 쓰는 읽기 — 목록에서 파일을 찾아 흐름을 읽음 */
const loadForCycle = async (key: string): Promise<CfLogic | null> => {
const it = items.find((x) => x.키 === key);
return it ? (await flowOf(it)).flow : null;
};
const pick = async (i: MasterLogicBrief): Promise<void> => {
try {
const other = await cfGet(i.key);
const next = setLogicInput(lg, boxId, logicRefOf(i.key, other));
if (i.키 === lg.key) return void showToast(t("Self"), "error");
if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error");
const got = await flowOf(i);
readNotes.set(i.키, { converted: got.converted, notes: got.notes });
const picked = got.converted
? masterRef(i.키, got.flow, true, i.결과단위)
: { ...logicRefOf(i.키, got.flow), name: i.이름 };
const done = setLogicInput(lg, boxId, picked);
// 기본 이름이면 고른 로직 이름으로
on.change({
...next,
boxes: next.boxes.map((b) =>
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: other.name || b.name } : b,
...done,
boxes: done.boxes.map((b) =>
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: i.이름 || b.name } : b,
),
});
} catch (e) {
showToast(plain(e), "error");
}
};
q.input.addEventListener("input", draw);
let timer = 0;
q.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => ((page = 1), draw()), 200);
});
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Help") }), q.root, list);
void cfList().then(
(items) => ((all = items), draw()),
host.replaceChildren(
el("p", { className: "m02-dfip__muted", text: t("Help") }),
el("div", { className: "m02-dfip__bar", children: [bookBox.root, chapterBox.root, q.root] }),
info,
list,
el("div", { className: "m02-dfip__chips", children: [prev, next] }),
);
void loadAll().then(
(rows) => {
items = rows;
bookBox.setOptions([all, ...logicBooks(rows).map((b) => ({ value: b, text: b }))], "");
fillChapters();
draw();
},
(e) => list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })),
);
}
/** 고른 로직 — 내보내는 알약 · 받는 알약 */
/** 고른 로직 — 내보내는 알약(값 · 단위) · 받는 알약(예시값) */
function showChosen(
host: HTMLElement,
lg: CfLogic,
boxId: string,
ref: NonNullable<CfLogic["boxes"][number]["input"]>["logic"],
ref: CfLogicRef,
on: { change(lg: CfLogic): void },
): void {
if (!ref) return;
const pillLine = (p: { name: string; unit: string }): HTMLElement =>
const read = readNotes.get(ref.key);
const values = new Map<string, HTMLElement>();
const errors = el("p", { className: "m02-dfip__muted is-bad" });
const line = (name: string, unit: string, valueNode?: HTMLElement, extra = ""): HTMLElement =>
el("div", {
className: "m02-dfip__item",
children: [
el("span", { className: "m02-dfip__name", text: p.name }),
el("span", { className: "m02-dfip__muted", text: p.unit || "-" }),
el("span", { className: "m02-dfip__name", text: name }),
...(valueNode
? [valueNode]
: extra
? [el("span", { className: "m02-dfip__muted", text: extra })]
: []),
el("span", { className: "m02-dfip__muted", text: unit || "-" }),
],
});
host.replaceChildren(
el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }),
...(read?.converted
? [
el("p", { className: "m02-dfip__muted", text: t("Converted") }),
...(read.notes.length
? [
el("details", {
children: [
el("summary", { text: `${t("Notes")} · ${read.notes.length}` }),
...read.notes.map((n) => el("p", { text: n })),
],
}),
]
: []),
]
: []),
el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }),
...(ref.outputs.length
? ref.outputs.map(pillLine)
? ref.outputs.map((o) => {
const v = el("strong", { className: "m02-dfip__value", text: "—" });
values.set(o.name, v);
return line(o.name, o.unit, v);
})
: [el("p", { className: "m02-dfip__muted", text: t("NoOut") })]),
errors,
el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }),
...(ref.designs.length
? ref.designs.map(pillLine)
? ref.designs.map((d) =>
line(d.name, d.unit, undefined, String(ref.examples?.[d.name] ?? "")),
)
: [el("p", { className: "m02-dfip__muted", text: t("NoDesign") })]),
el("div", {
className: "m02-dfip__chips",
@@ -158,4 +345,17 @@ function showChosen(
],
}),
);
// 값 — 서버 계산 길(받는 알약 예시값으로)
errors.textContent = t("Wait");
void cfLogicValue(ref.key, ref.examples ?? {}).then(
(r) => {
for (const [name, node] of values) {
const v = r.outputs[name] ?? (r.cost as unknown as Record<string, unknown>)[name];
if (typeof v === "number") node.textContent = fmt(v);
else if (typeof v === "string") node.textContent = v;
}
errors.textContent = r.errors.join(" · ");
},
(e) => (errors.textContent = plain(e)),
);
}
+230
View File
@@ -0,0 +1,230 @@
"""단가산출 흐름도 입력 판 — 다른 로직 입력 = 마스터 로직 고르기(순수 부분 · `M02_DF_InputPanel_Build.ts`).
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림. 견본 로직 줄 · 흐름은 이 시험 안에 둠(마스터 내용에 기대지 않음).
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_InputPanel_Build.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
const m = box.exports;
(async () => {
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = [];
for (const j of jobs) {
if (j.name === "wouldCycle") {
const graph = j.args[2];
out.push(await m.wouldCycle(j.args[0], j.args[1], async (k) => graph[k] ?? null));
} else out.push(await m[j.name](...j.args));
}
process.stdout.write(JSON.stringify(out));
})();
"""
def call(name: str, *args):
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps([{"name": name, "args": list(args)}], ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)[0]
def row(key, name, book, chapter, no="", unit="원/㎡", **more):
return {
"file": f"로직_{book}.json",
"구분": book,
"상세구분": chapter,
"키": key,
"원문번호": no,
"이름": name,
"결과단위": unit,
**more,
}
ITEMS = [
row("GF000219", "돌쌓기 메쌓기(인력)", "산림품셈", "13장 구조물", "13-4-1"),
row("GF000220", "돌쌓기 찰쌓기(인력)", "산림품셈", "13장 구조물", "13-4-2"),
row("GF000025", "떼붙임(재배잔디)", "산림품셈", "05장 녹화", "5-12"),
row("GC000051", "보행자 안전통로", "건설품셈", "01장 공통", "1-3-9", "원/m"),
row("DF-0001", "내 흄관 로직", "흐름도", "내가 만든 것", "", "원/m"),
]
def keys(items):
return [i["키"] for i in items]
def test_찾기는_띄어쓰기로_나눈_말을_모두_포함():
got = call("filterLogics", ITEMS, {"text": "돌쌓기 메쌓기"})
assert keys(got) == ["GF000219"]
assert keys(call("filterLogics", ITEMS, {"text": "돌쌓기"})) == ["GF000219", "GF000220"]
assert keys(call("filterLogics", ITEMS, {"text": "돌 쌓기메쌓기"})) == [
"GF000219"
] # 띄어쓰기는 가리지 않음
assert keys(call("filterLogics", ITEMS, {"text": "13-4"})) == [
"GF000219",
"GF000220",
] # 원문번호
assert keys(call("filterLogics", ITEMS, {"text": "gc000051"})) == [
"GC000051"
] # 번호 · 대소문자
assert call("filterLogics", ITEMS, {"text": ""}) == ITEMS
def test_원문_장으로_거름():
assert keys(call("filterLogics", ITEMS, {"text": "", "book": "건설품셈"})) == ["GC000051"]
got = call("filterLogics", ITEMS, {"text": "", "book": "산림품셈", "chapter": "05장 녹화"})
assert keys(got) == ["GF000025"]
assert call("filterLogics", ITEMS, {"text": "메쌓기", "book": "건설품셈"}) == []
def test_원문_장_목록은_나온_차례():
assert call("logicBooks", ITEMS) == ["산림품셈", "건설품셈", "흐름도"]
assert call("logicChapters", ITEMS, "산림품셈") == ["13장 구조물", "05장 녹화"]
assert call("logicChapters", ITEMS, "") == [
"13장 구조물",
"05장 녹화",
"01장 공통",
"내가 만든 것",
]
def box(id_, kind, name, **more):
return {
"id": id_,
"kind": kind,
"name": name,
"color": "#fff",
"unit": "-",
"x": 0,
"y": 0,
**more,
}
FLOW = {
"key": "GF000219",
"name": "돌쌓기 메쌓기(인력)",
"resultUnit": "원/㎡",
"userElements": [],
"links": [],
"boxes": [
box(
"b1",
"input",
"입력",
input={
"mode": "variable",
"elements": [],
"variables": [
{"name": "뒷길이", "type": "number", "unit": "㎝", "example": 30},
{
"name": "돌",
"type": "choice",
"unit": "",
"choices": ["깬돌", "견치돌"],
"example": "깬돌",
},
],
},
),
box("o1", "output", "노무비", unit="원/㎡", output={"cost": "노무비", "unit": "원/㎡"}),
],
}
def test_흐름_로직_고르기는_출력_상자와_설계_때_정할_것():
ref = call("masterRef", "GF000219", FLOW, False, "원/㎡")
assert ref["key"] == "GF000219"
assert ref["outputs"] == [{"name": "노무비", "unit": "원/㎡"}]
assert ref["designs"] == [{"name": "뒷길이", "unit": "㎝"}, {"name": "돌", "unit": "-"}]
assert ref["examples"] == {"뒷길이": 30, "돌": "깬돌"}
def test_옛_로직_변환은_노무비_재료비_경비_계_넷():
ref = call("masterRef", "GF000219", FLOW, True, "원/㎡")
assert [o["name"] for o in ref["outputs"]] == ["노무비", "재료비", "경비", "계"]
assert {o["unit"] for o in ref["outputs"]} == {"원/㎡"}
assert [d["name"] for d in ref["designs"]] == ["뒷길이", "돌"] # 받는 알약은 흐름 그대로
def test_이어지지_않은_다른_로직_받는_알약도_설계_때_정할_것():
inner = {
"mode": "logic",
"elements": [],
"variables": [],
"logic": {
"key": "GF000250",
"outputs": [{"name": "작업량", "unit": "㎥/시간"}],
"designs": [{"name": "지역", "unit": "-"}, {"name": "기계", "unit": "-"}],
"examples": {"지역": "서울"},
},
}
flow = {**FLOW, "boxes": [*FLOW["boxes"], box("b2", "input", "작업량", input=inner)]}
flow["links"] = [
{"id": "l1", "from": {"box": "b1", "name": "돌"}, "to": {"box": "b2", "name": "기계"}}
]
ref = call("logicRefOf", "X", flow)
names = [d["name"] for d in ref["designs"]]
assert "지역" in names and "기계" not in names # 선이 이어진 받는 알약은 뺌
assert ref["examples"]["지역"] == "서울"
def test_자기_자신과_돌고_도는_로직은_막음():
graph = {
"A": {
"boxes": [
box(
"b",
"input",
"x",
input={"mode": "logic", "elements": [], "variables": [], "logic": {"key": "B"}},
)
]
},
"B": {
"boxes": [
box(
"b",
"input",
"x",
input={
"mode": "logic",
"elements": [],
"variables": [],
"logic": {"key": "SELF"},
},
)
]
},
"C": {"boxes": []},
}
assert call("wouldCycle", "SELF", "SELF", graph) is True # 자기 자신
assert call("wouldCycle", "SELF", "A", graph) is True # A → B → SELF
assert call("wouldCycle", "SELF", "C", graph) is False
assert call("wouldCycle", None, "A", graph) is False # 새 로직은 아직 키가 없어 돌 수 없음
assert call("wouldCycle", "SELF", "없음", graph) is False # 못 읽으면 막지 않음