From 87aabc4a0d8d704c3391e4b7d0a9eebfad79ff3e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:42:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(m01-easycalc):=20=EC=89=AC=EC=9A=B4=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20Api=20=EB=AA=B8=20?= =?UTF-8?q?=E2=80=94=20=EC=84=9C=EB=B2=84=20=EA=B8=B8=20=EA=B0=90=EC=8B=B8?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EA=B8=B0=EA=B3=84=20=EC=A7=9D=20=C2=B7=20?= =?UTF-8?q?=EC=89=AC=EC=9A=B4=20=EB=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listEasy(품셈 기본 / 내가 만든 것 · 돈 아닌 로직 「쉬운 길로 못 엶」) · openEasy(자동값) · calcLater(0.3초 · 최신 응답만) - newEasy · copyEasy · editEasy · deleteEasy — /logic/* 만(/save 안 씀) · usesOf = 담은 조합 + 부르는 내 로직 - listMachines · machineElement — 기종 → 기계 시간당 경비 로직 부름 글 · 넘길 설계 값(부착 전용 · 손료만은 뒤로) - plainError — 0 · 403 · 404 · 409 · 422 검사 글을 쉬운 말로(키 · 날 글 걸름) - 시험 test_m01_easycalc_api.py — 사본 폴더 왕복 · 쓰는 곳 막음 · 기계 짝 · 쉬운 말 · 금지 낱말 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts | 412 ++++++++++++++++++-- resources/tester/test_m01_easycalc_api.py | 321 +++++++++++++++ 2 files changed, 704 insertions(+), 29 deletions(-) create mode 100644 resources/tester/test_m01_easycalc_api.py diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts index 5d8860a01..2fb76bacf 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts @@ -3,9 +3,22 @@ * 쉬운 단가산출 Api — 서버 길 감싸기(목록 · 열기 + 자동값 · 시험 계산 뒤에서 · 새로 · 본뜨기 · * 고치기 · 지우기 · 쓰는 곳) · 403 · 409 · 422 를 쉬운 말로 · 기계 기종 → 부를 기계경비 로직 · 넘길 값 * 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 쓰기는 `/logic/*` 만(`/save` 안 씀) + * 시험: `resources/tester/test_m01_easycalc_api.py`(서버 왕복 + 아래 「순수」 칸을 Node 로) * ========================================================================== */ -import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import { API_BASE_URL } from "@config/config_frontend"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + ApiError, + copyLogic, + fetchAuto, + fetchLogic, + runCalc, + type CalcAnswer, + type LogicInput, + type LogicRow, +} from "../M01_MasterData_UI_Logic_Api"; +import { safeReason } from "../M01_MasterData_UI_Logic_Text"; /** 목록 한 줄 */ export interface EasyListItem { @@ -62,55 +75,396 @@ export interface EasyMachineElement { needInputs: LogicInput[]; } -const notReady = (): never => { - throw new Error("준비 중"); -}; - -export async function listEasy(_q: string): Promise { - return notReady(); +/** 기계 시간당 경비 로직 하나 — 기종 짝 규칙의 재료 */ +export interface MachineLogic { + key: string; + name: string; + resultUnit: string; + inputs: LogicInput[]; } -export async function openEasy(_key: string): Promise { - return notReady(); +/* ── 순수 시작 — 아래 끝까지 밖 이름을 안 씀(`currentLanguageIndex` 만) · 시험이 Node 로 바로 돌림 ── */ +const TEXT = { + Base: ["품셈 기본", "Standard"], + Mine: ["내가 만든 것", "Mine"], + NotMoney: [ + "금액이 아닌 값(작업량 등)을 내는 것이라 쉬운 길로 못 엶 — 지금 화면에서 봄", + "Gives a non-money value (output etc.) — open it in the full screen", + ], + E403: [ + "품셈 기본은 바로 못 고침 — 「이걸 바탕으로 고치기」로 내 것을 만들어 고침", + "Standard items are read-only — use “Edit a copy”", + ], + E404: [ + "찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요", + "Not found — it may be deleted; reopen the list", + ], + E409: [ + "다른 곳에서 먼저 고쳤음 — 다시 열어 최신 것으로 고쳐 주세요", + "Changed elsewhere first — reopen and edit again", + ], + Net: ["서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요", "Server unreachable — try again"], + Generic: [ + "저장 검사에 걸림 — 고친 줄을 다시 확인해 주세요", + "Did not pass the check — review your changes", + ], + More: ["그 밖 {n} 건", "{n} more"], + UnknownName: [ + "「{name}」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요", + "「{name}」 is not defined — add it as a design value", + ], + NeedCost: [ + "넣을 자리(사람 품값 · 재료값 · 기계·경비)를 골라 주세요", + "Pick where it goes (labor · material · equipment)", + ], + NoLines: [ + "줄이 하나도 없음 — 사람 · 재료 · 기계 줄을 하나 이상 더해 주세요", + "No lines — add at least one labor, material or machine line", + ], + Source: ["근거 글 모양이 맞지 않음", "The source text has a wrong shape"], + CallNeeds: [ + "부르는 다른 일위대가가 「{name}」 값을 받아야 함 — 설계 때 정하는 값에 더해 주세요", + "The called item needs 「{name}」 — add it as a design value", + ], + CallExtra: [ + "부르는 다른 일위대가는 「{name}」 값을 받지 않음", + "The called item does not take 「{name}」", + ], + Kind: [ + "고른 항목과 줄 종류가 맞지 않음 — 다시 골라 주세요", + "The picked item does not match the line type — pick again", + ], + Loop: [ + "서로를 부르는 일위대가가 생김 — 한쪽을 빼 주세요", + "Items call each other — remove one side", + ], + Unread: ["수량 글을 읽지 못함 — 숫자로 넣어 주세요", "Cannot read the amount — enter a number"], + Gone: [ + "이어진 항목이 없어짐 — 다른 곳에서 쓰고 있으면 지울 수 없음", + "A linked item is missing — items still in use cannot be deleted", + ], + NoName: ["이름을 넣어 주세요", "Enter a name"], + Broken: ["저장 모양이 덜 갖춰짐 — 다시 열어 주세요", "The saved shape is incomplete — reopen it"], + Cut: ["합계 자르기 설정이 맞지 않음", "The rounding setting is wrong"], + Material: [ + "재료 찾는 말에 맞는 자료가 없음 — 다시 골라 주세요", + "No material matches — pick again", + ], + Stop: [ + "이 값으로는 셈하지 못함 — 설계 때 정하는 값을 확인해 주세요", + "Cannot be calculated with these values — check the design values", + ], + NoMachine: [ + "이 기계는 시간당 값을 셀 자료가 없음 — 다른 기계를 골라 주세요", + "No hourly cost data for this machine — pick another", + ], +} as const satisfies Record; + +type SayKey = keyof typeof TEXT; + +function say(key: SayKey, fill: Record = {}): string { + const text: string = TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]; + return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m)); } +/** 검사 글 한 줄 → 쉬운 말 — 모르는 꼴은 뭉뚱그림(키 · 날 글이 화면에 안 나가게) */ +function plainCheck(line: string): string { + const name = (re: RegExp): string | null => re.exec(line)?.[1] ?? null; + const unknown = name(/모르는 이름 「([^」]+)」/); + if (unknown) return say("UnknownName", { name: unknown }); + const needs = name(/입력 「([^」]+)」 안 줌/); + if (needs) return say("CallNeeds", { name: needs }); + const extra = name(/에 입력 「([^」]+)」 없음/); + if (extra) return say("CallExtra", { name: extra }); + if (/「호표」 도 「결과」 도 없음/.test(line)) return say("NoLines"); + if (/비목 「/.test(line)) return say("NeedCost"); + if (/출처 모양/.test(line)) return say("Source"); + if (/요소 그룹이 종류와 다름|종류 「|고르기 조건은 종류/.test(line)) return say("Kind"); + if (/돌고 도는 참조/.test(line)) return say("Loop"); + if (/식 못 읽음/.test(line)) return say("Unread"); + if (/없는 (로직|요소|키)|로직 키/.test(line)) return say("Gone"); + if (/「이름」 이 없음|이름 없음/.test(line)) return say("NoName"); + if (/끝수/.test(line)) return say("Cut"); + if (/검색어|후보 0|고르기 조건/.test(line)) return say("Material"); + if (/칸 「[^」]+」 없음/.test(line)) return say("Broken"); + return say("Generic"); +} + +/** 상태 번호 + 서버 몸 → 쉬운 말(여러 줄은 줄바꿈 · 셋까지) · 0 = 서버에 못 닿음 */ +function plainOf(status: number, detail: unknown): string { + if (status === 0) return say("Net"); + if (status === 403) return say("E403"); + if (status === 404) return say("E404"); + if (status === 409) return say("E409"); + const body = detail as { errors?: unknown } | null; + const lines = + status === 422 && body && Array.isArray(body.errors) + ? body.errors.map((x) => String(x)) + : typeof detail === "string" + ? [detail] + : []; + if (!lines.length) return say("Generic"); + const plain = [...new Set(lines.map(plainCheck))]; + const shown = plain.slice(0, 3); + if (plain.length > 3) shown.push(say("More", { n: plain.length - 3 })); + return shown.join("\n"); +} + +/** 계산 멈춤 까닭 — 키(`GX000001`)가 섞인 글은 뭉뚱그림 */ +function plainStop(text: string): string { + return /[A-Z]{2}\d{6}/.test(text) ? say("Stop") : text; +} + +const MACHINE = "기계"; +/** 부착물 아닌 입력 — 이 밖의 입력(집게 · 브레이커)을 받는 로직은 부착 장비 전용 */ +const MACHINE_PLAIN = new Set(["기계", "지역", "보정작업", "조종원직종"]); + +/** 그 로직이 받는 기종(분류번호) 목록 */ +function machineCodes(m: MachineLogic): string[] { + const spec = m.inputs.find((i) => i.이름 === MACHINE); + return (spec?.고르기 ?? []).map(String); +} + +/** 짝 고르는 차례 — [부착 전용, 손료만, 손료만이면서 보정, 기종 수] 가 작은 것부터. + * 손료만 = 「지역」(연료 · 노임 값 자리)을 안 받음 · 기종 수가 작을수록 그 기종 전용(굴착기 · 조종원 없음 …) */ +function machineRank(m: MachineLogic): number[] { + const names = m.inputs.map((i) => i.이름); + const attach = names.some((n) => !MACHINE_PLAIN.has(n)) ? 1 : 0; + const lossOnly = names.includes("지역") ? 0 : 1; + const boost = lossOnly && names.includes("보정작업") ? 1 : 0; + return [attach, lossOnly, boost, machineCodes(m).length]; +} + +/** 기종 → 부를 기계경비 로직 하나 · 받는 로직이 없으면 null */ +export function pickMachine(family: MachineLogic[], code: string): MachineLogic | null { + const ranked = family + .filter((m) => machineCodes(m).includes(code)) + .map((m) => ({ m, rank: machineRank(m) })) + .sort((a, b) => { + for (let i = 0; i < a.rank.length; i += 1) { + if (a.rank[i] !== b.rank[i]) return a.rank[i] - b.rank[i]; + } + return a.m.key < b.m.key ? -1 : 1; + }); + return ranked[0]?.m ?? null; +} + +/** 로직 부름 글 — 기계는 고정 분류번호 · 나머지 입력은 같은 이름의 설계 때 정하는 값으로 넘김 */ +export function machineCall(m: MachineLogic, code: string): EasyMachineElement { + const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`); + const needInputs = m.inputs.filter((i) => i.이름 !== MACHINE); + const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join(""); + return { element: `로직(${m.key}, ${MACHINE}='${code}'${rest})`, needInputs }; +} +/* ── 순수 끝 ── */ + +const OWN = "로직_자체.json"; // 내가 만든 것 한 자리(서버 `Store_Make.OWN`) +const OWNER = "현장"; +const CALC_WAIT_MS = 300; +const MACHINE_FILE = "건설기계"; // 기계 시간당 경비 로직이 든 장 파일(「08장 건설기계」) + +async function request(path: string, body?: unknown): Promise { + let response: Response; + try { + response = await fetch(`${API_BASE_URL}/m01${path}`, { + method: body === undefined ? "GET" : "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch { + throw new ApiError(0, ""); + } + const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); + return data; +} + +const query = (params: Record): string => new URLSearchParams(params).toString(); + +interface Summary { + file: string; + 키: string; + 구분: string; + 상세구분: string; + 이름: string; + 결과단위: string; +} + +const isMoney = (unit: string): boolean => unit.startsWith("원"); + +/** 목록 — 내가 만든 것 먼저 · 돈 아닌 로직은 「쉬운 길로 못 엶」 */ +export async function listEasy(q: string): Promise { + const got = await request<{ logics: Summary[] }>(`/logics?${query({ q })}`); + const items = got.logics.map((s): EasyListItem => { + const isBase = s.file !== OWN; + const canOpen = isMoney(s.결과단위); + return { + key: s.키, + name: s.이름, + isBase, + owner: say(isBase ? "Base" : "Mine"), + resultUnit: s.결과단위, + canOpen, + whyNot: canOpen ? null : say("NotMoney"), + where: isBase ? `${s.구분} ${s.상세구분}`.trim() : s.상세구분, + }; + }); + return [...items.filter((x) => !x.isBase), ...items.filter((x) => x.isBase)]; +} + +/** 열기 — 로직 줄 · 판본 · 견본 설계 값(자동값 · 못 받으면 빈 채로) */ +export async function openEasy(key: string): Promise { + const [one, auto] = await Promise.all([fetchLogic(key), fetchAuto(key)]); + return { + row: one.logic, + version: one.version, + isBase: one.file !== OWN, + trial: trialFor(one.logic, auto), + }; +} + +/** 로직이 받는 설계 값만 — 모르는 이름을 보내면 서버가 멈춤 */ +function trialFor(row: LogicRow, trial: Record): Record { + return Object.fromEntries( + (row.입력 ?? []).filter((i) => i.이름 in trial).map((i) => [i.이름, trial[i.이름]]), + ); +} + +let calcSeq = 0; +let calcTimer: ReturnType | undefined; + /** 0.3초 뒤 서버 시험 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */ export function calcLater( - _row: LogicRow, - _trial: Record, - _onDone: (ans: CalcAnswer | null, plain: string | null) => void, + row: LogicRow, + trial: Record, + onDone: (ans: CalcAnswer | null, plain: string | null) => void, ): void { - notReady(); + const mine = ++calcSeq; + clearTimeout(calcTimer); + calcTimer = setTimeout(() => { + runCalc({ key: row.키 || "", inputs: trialFor(row, trial), row }) + .then((ans) => { + if (mine === calcSeq) onDone(ans, ans.ok ? null : plainStop(safeReason(ans.reason))); + }) + .catch((e: unknown) => { + if (mine === calcSeq) onDone(null, plainError(e)); + }); + }, CALC_WAIT_MS); } -export async function newEasy(_row: LogicRow): Promise { - return notReady(); +/** 새로 — 키 · 갈래는 서버가 줌 · 근거 글이 비면 「자체」(검사가 빈 글을 막음) */ +export async function newEasy(row: LogicRow): Promise { + const logic = { ...row, 출처: row.출처 || "자체" }; + const got = await request<{ version: string; key: string; logic: LogicRow }>("/logic/new", { + logic, + owner: OWNER, + }); + return { key: got.key, version: got.version, row: got.logic }; } -export async function copyEasy(_key: string): Promise { - return notReady(); +/** 이걸 바탕으로 고치기 — 품셈 기본 · 내 것 모두 본뜸 */ +export async function copyEasy(key: string): Promise { + const got = await copyLogic(key); + return { key: got.key, version: got.version, row: got.logic }; } -export async function editEasy(_key: string, _version: string, _row: LogicRow): Promise { - return notReady(); +/** 고치기 — 내가 만든 것만(품셈 기본은 403) · 판본이 다르면 409 */ +export async function editEasy(key: string, version: string, row: LogicRow): Promise { + const got = await request<{ version: string; logic: LogicRow }>("/logic/edit", { + key, + version, + logic: row, + }); + return { key, version: got.version, row: got.logic }; } -export async function deleteEasy(_key: string, _version: string): Promise<{ version: string }> { - return notReady(); +/** 지우기 — 내가 만든 것만 · 쓰는 곳이 남으면 422 */ +export async function deleteEasy(key: string, version: string): Promise<{ version: string }> { + const got = await request<{ version: string }>("/logic/delete", { key, version }); + return { version: got.version }; } -export async function usesOf(_key: string): Promise { - return notReady(); +/** 쓰는 곳 — 담은 일위대가 조합(마스터 층) + 이 로직을 부르는 내가 만든 것. + * ponytail: 부르는 로직은 내가 만든 것만 하나씩 받아 훑음(품셈 기본은 내 것을 안 부름) — + * 내 것이 수백이 되면 서버에 「부르는 로직」 길을 더할 것 */ +export async function usesOf(key: string): Promise { + const [combos, mine] = await Promise.all([ + request<{ combos: { 키: string; 이름: string }[] }>(`/logic/combos?${query({ key })}`), + request<{ logics: Summary[] }>(`/logics?${query({ sub: "자체" })}`), + ]); + const call = `로직(${key}`; + const others = mine.logics.filter((s) => s.키 !== key); + const rows = await Promise.all(others.map((s) => fetchLogic(s.키))); + const callers = rows + .filter((one) => JSON.stringify(one.logic).includes(call)) + .map((one): EasyUse => ({ kind: "logic", key: one.logic.키, name: one.logic.이름 })); + return [ + ...combos.combos.map((c): EasyUse => ({ kind: "combo", key: c.키, name: c.이름 })), + ...callers, + ]; } -export async function listMachines(_q: string): Promise { - return notReady(); +let family: Promise | null = null; + +/** 기계 시간당 경비 로직 묶음 — 한 번 받아 둠(실패하면 다음에 다시) */ +function machineFamily(): Promise { + family ??= request<{ logics: Summary[] }>("/logics") + .then((got) => + Promise.all( + got.logics + .filter((s) => s.file.includes(MACHINE_FILE) && isMoney(s.결과단위)) + .map((s) => fetchLogic(s.키)), + ), + ) + .then((all) => + all + .map((one) => ({ + key: one.logic.키, + name: one.logic.이름, + resultUnit: one.logic.결과단위, + inputs: one.logic.입력 ?? [], + })) + .filter((m) => machineCodes(m).length > 0), + ) + .catch((e: unknown) => { + family = null; + throw e; + }); + return family; } -export async function machineElement(_기종: string): Promise { - return notReady(); +interface MachineRow { + 원문번호?: string; + 이름?: string; + 규격?: string; } -export function plainError(_e: unknown): string { - return notReady(); +/** 기계 고르기 — 이름 · 분류번호 찾기 · 시간당 값을 셀 수 있는 기종만 */ +export async function listMachines(q: string): Promise { + const [all, got] = await Promise.all([ + machineFamily(), + request<{ items: MachineRow[] }>(`/elements?${query({ group: MACHINE, q, limit: "200" })}`), + ]); + return got.items.flatMap((row): EasyMachine[] => { + const code = String(row.원문번호 ?? ""); + const m = pickMachine(all, code); + if (!m) return []; + const unit = m.resultUnit.split("/")[1] ?? ""; + return [{ 분류번호: code, 이름: row.이름 ?? "", 규격: row.규격 ?? "", 단위: unit }]; + }); +} + +/** 기종 → 호표 「요소」 글 · 받아야 할 설계 값(지역 등) — 호표 종류는 「로직」 */ +export async function machineElement(기종: string): Promise { + const m = pickMachine(await machineFamily(), 기종); + if (!m) throw new Error(say("NoMachine")); + return machineCall(m, 기종); +} + +/** 어떤 실패든 화면에 찍을 쉬운 말 한두 줄 */ +export function plainError(e: unknown): string { + if (e instanceof ApiError) return plainOf(e.status, e.detail); + if (e instanceof Error && e.message === say("NoMachine")) return e.message; + return say("Net"); } diff --git a/resources/tester/test_m01_easycalc_api.py b/resources/tester/test_m01_easycalc_api.py new file mode 100644 index 000000000..6f3001696 --- /dev/null +++ b/resources/tester/test_m01_easycalc_api.py @@ -0,0 +1,321 @@ +"""M01 쉬운 단가산출 Api(`M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts`) 시험 — PLAN 34-3b. + +· 서버 길 왕복 — Api 가 부르는 몸 그대로 새로 → 고치기 → 시험 계산 → 지우기(임시 폴더 사본에서만) +· 쓰는 곳 — 담은 조합 · 부르는 내 로직이 지우기를 막음 +· 기계 짝 규칙 · 쉬운 말 바꾸기 — Api 파일의 「순수」 칸을 Node 로 바로 돌림 +견본 로직 줄은 시험 안에 둠 — 단가 값은 사본에서 읽어 비교(진짜 값에 안 기댐). +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store + +ROOT = Path(__file__).resolve().parents[2] +API = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Api.ts" +REAL = store.FOLDER +OWN = "로직_자체.json" +NO_NODE = shutil.which("node") is None + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in REAL.glob("*.json"): + if path.name == OWN: + continue # 내 로직은 빈 자리에서 시작 + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _call(client: TestClient, url: str, body: dict | None = None, want: int = 200) -> dict: + res = client.get(url) if body is None else client.post(url, json=body) + assert res.status_code == want, res.text + return res.json() + + +def _labor() -> tuple[str, Decimal]: + """값이 있는 인력 한 줄(키 · 노임) — 사본에서 읽음.""" + rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"] + row = next(r for r in rows if r.get("상태") == "공표" and isinstance(r.get("값"), (int, float))) + return row["키"], Decimal(str(row["값"])) + + +def _row(name: str, lines: list[dict], inputs: list | None = None) -> dict: + """쉬운 길이 보내는 최소 줄 — 키 · 갈래 · 소유는 서버가 채움.""" + return { + "키": "", + "원문번호": "", + "이름": name, + "결과단위": "원/㎡", + "출처": "자체", + "입력": inputs or [], + "중간": [], + "호표": lines, + "덧줄": [], + "끝수": None, + } + + +def _person(key: str, qty: str) -> dict: + return { + "종류": "인력", + "요소": key, + "이름": "사람", + "단위": "인", + "수량": qty, + "비목": "노무비", + } + + +def _node(expr: str) -> object: + """Api 파일의 「순수」 칸 + 식 하나를 Node 로 돌려 JSON 답.""" + src = API.read_text(encoding="utf-8") + block = re.search(r"/\* ── 순수 시작[^\n]*\n([\s\S]*?)/\* ── 순수 끝", src) + assert block, "순수 칸 표시를 못 찾음" + script = ( + "const currentLanguageIndex = 0;\n" + + block.group(1).replace("export function", "function") + + f"\nconsole.log(JSON.stringify({expr}));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +# ── 서버 길 왕복 ─────────────────────────────────────────────────────── +def test_새로_고치기_시험계산_지우기_왕복(client: TestClient) -> None: + real_before = (REAL / OWN).exists() + key_l, price = _labor() + + made = _call( + client, + "/api/m01/logic/new", + {"logic": _row("시험 쉬운 줄", [_person(key_l, "0.5")]), "owner": "현장"}, + ) + key = made["key"] + assert key.startswith("GX") and made["file"] == OWN and made["logic"]["소유"] == "현장" + + # 목록 · 열기 — 내 것은 사본의 자체 파일에서 + listed = _call(client, "/api/m01/logics?q=시험 쉬운 줄")["logics"] + assert [x["file"] for x in listed] == [OWN] and listed[0]["결과단위"].startswith("원") + opened = _call(client, f"/api/m01/logic?key={key}") + assert opened["version"] == made["version"] + + # 고치기 → 시험 계산(저장 전 줄을 그대로 보냄) — 금액 = 수량 × 노임 + row = {**opened["logic"], "호표": [_person(key_l, "2")]} + edited = _call( + client, "/api/m01/logic/edit", {"key": key, "version": opened["version"], "logic": row} + ) + calc = _call(client, "/api/m01/calc", {"key": key, "inputs": {}, "row": edited["logic"]}) + assert calc["ok"] is True + assert Decimal(str(calc["sums"]["노무비"])) == 2 * price == Decimal(str(calc["sums"]["계"])) + + # 저장 안 한 새 줄도 키 "" 로 셈 + draft = _call( + client, + "/api/m01/calc", + {"key": "", "inputs": {}, "row": _row("새 것", [_person(key_l, "1")])}, + ) + assert Decimal(str(draft["sums"]["계"])) == price + + # 낡은 판본 409 · 품셈 기본 고치기 403 + _call( + client, + "/api/m01/logic/edit", + {"key": key, "version": made["version"], "logic": row}, + want=409, + ) + base = next(x for x in _call(client, "/api/m01/logics")["logics"] if x["file"] != OWN) + base_one = _call(client, f"/api/m01/logic?key={base['키']}") + body = {"key": base["키"], "version": base_one["version"], "logic": base_one["logic"]} + _call(client, "/api/m01/logic/edit", body, want=403) + + _call(client, "/api/m01/logic/delete", {"key": key, "version": edited["version"]}) + assert client.get(f"/api/m01/logic?key={key}").status_code == 404 + assert (REAL / OWN).exists() == real_before # 진짜 마스터 폴더는 안 건드림 + + +def test_쓰는_곳_조합과_부르는_로직이_지우기를_막음(client: TestClient) -> None: + key_l, _ = _labor() + a = _call(client, "/api/m01/logic/new", {"logic": _row("바탕 줄", [_person(key_l, "1")])}) + call = {"종류": "로직", "요소": f"로직({a['key']})", "이름": "바탕", "단위": "㎡", "수량": "1"} + b = _call(client, "/api/m01/logic/new", {"logic": _row("부르는 줄", [call])}) + + # 부르는 내 로직 — usesOf 가 훑는 목록(구분 「자체」) · 몸에 「로직(<키>」 + mine = _call(client, "/api/m01/logics?sub=자체")["logics"] + assert {x["키"] for x in mine} == {a["key"], b["key"]} + body = json.dumps(_call(client, f"/api/m01/logic?key={b['key']}")["logic"], ensure_ascii=False) + assert f"로직({a['key']}" in body + + blocked = client.post("/api/m01/logic/delete", json={"key": a["key"], "version": b["version"]}) + assert blocked.status_code == 422, blocked.text + errors = blocked.json()["detail"]["errors"] + if not NO_NODE: # 서버 글 그대로 쉬운 말로 — 키가 안 새어 나감 + plain = _node(f"plainOf(422, {json.dumps({'errors': errors}, ensure_ascii=False)})") + assert "지울 수 없음" in plain and not re.search(r"[A-Z]{2}\d{6}", plain) + + # 부르는 줄을 지우면 바탕을 지울 수 있음 + _call(client, "/api/m01/logic/delete", {"key": b["key"], "version": b["version"]}) + now = _call(client, f"/api/m01/logic?key={a['key']}")["version"] + + # 담은 조합(마스터 층 파일)도 막음 — /logic/combos 가 그 조합을 줌 + combo_file = store.FOLDER / "일위대가조합.json" + combos = ( + json.loads(combo_file.read_text(encoding="utf-8")) + if combo_file.exists() + else {"그룹": "일위대가조합", "판": "2026", "줄": []} + ) + combos["줄"].append( + {"키": "UA999999", "이름": "시험 조합", "구분": "자체", "상세구분": "", "단위": "㎡", + "담은로직": [{"로직": a["key"], "메모": None}], "본뜬키": None, "출처": "자체", + "소유": "현장", "비고": None} + ) # fmt: skip + combo_file.write_text(json.dumps(combos, ensure_ascii=False, indent=2), encoding="utf-8") + store.cache.stale(["일위대가조합.json"], set()) + used = _call(client, f"/api/m01/logic/combos?key={a['key']}")["combos"] + assert [c["키"] for c in used] == ["UA999999"] + _call(client, "/api/m01/logic/delete", {"key": a["key"], "version": now}, want=422) + + +# ── 기계 짝 규칙 ─────────────────────────────────────────────────────── +def _m(key: str, codes: list[str], *extra: str) -> dict: + inputs = [{"이름": "기계", "고르기": codes}] + [{"이름": n, "고르기": ["가"]} for n in extra] + return {"key": key, "name": key, "resultUnit": "원/시간", "inputs": inputs} + + +FAMILY = [ + _m("M_GENERAL", ["A", "B", "C", "D"], "지역"), + _m("M_BOOST", ["A", "B", "C", "D", "E"], "지역", "보정작업"), + _m("M_DIGGER", ["B"], "지역", "보정작업"), + _m("M_NOOP", ["C"], "지역"), + _m("M_CLAW", ["B"], "집게", "지역", "보정작업"), + _m("M_LOSS", ["A", "F", "G"]), + _m("M_LOSS_BOOST", ["F", "G", "H"], "보정작업"), +] + + +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_기계_짝_규칙() -> None: + fam = json.dumps(FAMILY, ensure_ascii=False) + picks = _node( + f"['A','B','C','D','E','F','H','Z'].map((c) => pickMachine({fam}, c)?.key ?? null)" + ) + # 일반 > 보정 · 전용(굴착기 · 조종원 없음)이 일반보다 먼저 · 부착 전용은 안 고름 · + # 손료만은 다른 길이 없을 때만 · 손료만 안에서는 보정 아닌 것 먼저 · 없는 기종 null + assert picks == [ + "M_GENERAL", + "M_DIGGER", + "M_NOOP", + "M_GENERAL", + "M_BOOST", + "M_LOSS", + "M_LOSS_BOOST", + None, + ] + + call = _node(f"machineCall({fam}[2], 'B')") + assert call["element"] == "로직(M_DIGGER, 기계='B', 지역=지역, 보정작업=보정작업)" + assert [i["이름"] for i in call["needInputs"]] == ["지역", "보정작업"] + quoted = _node( + "machineCall({key:'K', name:'', resultUnit:'원/시간', inputs:[{이름:'기계'},{이름:'조종 직종'}]}, 'X')" + ) + assert quoted["element"] == "로직(K, 기계='X', '조종 직종'='조종 직종')" + + +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_기계_부름글이_서버_검사와_계산을_지남(client: TestClient) -> None: + """사본의 기계 시간당 경비 로직으로 짝을 지어 새 줄에 넣음 — 저장 · 계산이 됨(종류 「기계」 꼴 안 만듦).""" + family = [] + for path in store.FOLDER.glob("로직_*건설기계*.json"): + for row in json.loads(path.read_text(encoding="utf-8"))["줄"]: + if row["결과단위"].startswith("원"): + family.append( + { + "key": row["키"], + "name": row["이름"], + "resultUnit": row["결과단위"], + "inputs": row["입력"], + } + ) + if not family: + pytest.skip("사본에 기계 시간당 경비 로직이 없음") + fam = json.dumps(family, ensure_ascii=False) + code = next( + str(c) + for m in family + for i in m["inputs"] + if i["이름"] == "기계" + for c in i.get("고르기", []) + ) + got = _node( + f"(() => {{ const m = pickMachine({fam}, '{code}'); return m && machineCall(m, '{code}'); }})()" + ) + assert got and got["element"].startswith("로직(") + + line = {"종류": "로직", "요소": got["element"], "이름": "기계", "단위": "시간", "수량": "0.5"} + made = _call( + client, "/api/m01/logic/new", {"logic": _row("기계 시험", [line], got["needInputs"])} + ) + trial = {i["이름"]: (i.get("고르기") or [None])[0] for i in got["needInputs"]} + calc = _call(client, "/api/m01/calc", {"key": made["key"], "inputs": trial}) + assert calc["ok"] is True, calc + assert Decimal(str(calc["sums"]["계"])) > 0 and "비목" in calc["lines"][0] + _call(client, "/api/m01/logic/delete", {"key": made["key"], "version": made["version"]}) + + +# ── 쉬운 말 ───────────────────────────────────────────────────────────── +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_plainError_상태와_검사글을_쉬운_말로() -> None: + simple = _node("[0, 403, 404, 409, 500].map((s) => plainOf(s, 'x'))") + assert simple[0].startswith("서버와 이어지지") + assert "바탕으로 고치기" in simple[1] and "찾을 수 없음" in simple[2] + assert "다른 곳에서 먼저 고쳤음" in simple[3] and "검사에 걸림" in simple[4] + + errors = [ + "로직_자체.json · GX000001 호표 사람 수량 · 모르는 이름 「면적」", + "로직_자체.json · GX000001 호표 사람 · 비목 「None」", + "로직_자체.json · GX000001 시험 · 「호표」 도 「결과」 도 없음", + "로직_자체.json · GX000001 호표 기계 요소 · 「기계경비」 입력 「지역」 안 줌", + "알 수 없는 글 GX000009 찾기(QF000001).값", + ] + plain = _node(f"plainOf(422, {json.dumps({'errors': errors}, ensure_ascii=False)})").split("\n") + assert plain[0] == "「면적」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요" + assert "넣을 자리" in plain[1] and "줄이 하나도 없음" in plain[2] + assert plain[3] == "그 밖 2 건" # 셋까지 + 나머지 수 + assert not any(re.search(r"[A-Z]{2}\d{6}|찾기\(", x) for x in plain) + assert "「지역」" in _node( + f"plainOf(422, {json.dumps({'errors': errors[3:4]}, ensure_ascii=False)})" + ) + assert _node("plainOf(400, '로직 줄에 「이름」 이 없음')") == "이름을 넣어 주세요" + assert _node("plainStop('「GX000001」 입력 「돌」 없음')").startswith("이 값으로는") + + +def test_화면_글에_금지_낱말이_없음() -> None: + """설계 1절 8 — 키 · 식 · 호표 · 덧줄 · 비목 · 중간 값 · 끝수 · 정본 · 자체 · 별칭 안 씀(한국어 글).""" + src = API.read_text(encoding="utf-8") + block = re.search(r"const TEXT = \{([\s\S]*?)\} as const", src) + assert block + korean = re.findall(r'\[\s*"([^"]*)",', block.group(1)) + assert len(korean) > 20 + banned = ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭") + for text in korean: + assert not any(word in text for word in banned), text