Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_7
This commit is contained in:
@@ -4,13 +4,18 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts");
|
||||
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, require);
|
||||
const m = box.exports;
|
||||
// 상대 경로 TS(값 함수 = `M02_Labor_Values_Model.ts` 다시 내보냄)는 이어 트랜스파일
|
||||
function loadTs(file) {
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const local = (request) =>
|
||||
request.startsWith(".") ? loadTs(`${path.resolve(path.dirname(file), request)}.ts`) : require(request);
|
||||
new Function("exports", "module", "require", js)(box.exports, box, local);
|
||||
return box.exports;
|
||||
}
|
||||
const m = loadTs(path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts"));
|
||||
|
||||
// 같은 이름(높이)의 입력이 두 로직에 있음
|
||||
const list = {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* M02 항목 목록(`M02_MasterTemplete_ItemList.ts`) 칸 · 찾기 · 쪽 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고
|
||||
* JSON 으로 냄. 화면 부품(ui_template · M01 머리 · css)은 빈 것으로 갈음 · 나무 자료(`TreePanel_Model`)는 진짜. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const M02 = path.join(__dirname, "..", "..", "M02_MasterTemplete");
|
||||
const load = (name, stubs) => {
|
||||
const js = ts.transpileModule(fs.readFileSync(path.join(M02, name), "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const need = (id) => (id in stubs ? stubs[id] : {});
|
||||
new Function("exports", "module", "require", js)(box.exports, box, need);
|
||||
return box.exports;
|
||||
};
|
||||
const model = load("M02_MasterTemplete_TreePanel_Model.ts", {});
|
||||
const m = load("M02_MasterTemplete_ItemList.ts", {
|
||||
"./M02_MasterTemplete_TreePanel_Model": model,
|
||||
});
|
||||
|
||||
const row = (열, 경로, extra = {}) => ({
|
||||
key: `SS-${열}`,
|
||||
열,
|
||||
경로,
|
||||
이름: 경로.join(" "),
|
||||
단위: "m",
|
||||
도번: `SD-${열}`,
|
||||
면적번: `SA-${열}`,
|
||||
수정일: { structure: "2026-10-01T09:00:00", area: "2026-10-03T10:00:00" },
|
||||
labor: null,
|
||||
...extra,
|
||||
});
|
||||
const rows = [
|
||||
row("c1", ["기슭막이", "메쌓기", "1.2m"], {
|
||||
labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4 },
|
||||
수정일: { structure: "2026-10-01T09:00:00", labor: "2026-10-04T11:00:00" },
|
||||
}),
|
||||
row("c2", ["기슭막이", "찰쌓기"], { labor: { 번호: "SU-000002", 로직수: 0, 합계: null } }),
|
||||
row("c3", ["옹벽"], { 도번: "", 수정일: {} }),
|
||||
];
|
||||
const cells = (kind) => rows.map((r) => m.ITEM_COLS[kind].map((c) => c.get(r)));
|
||||
const many = Array.from({ length: 65 }, (_, i) => row(`n${i}`, ["배수", `측구 ${i}`]));
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
heads: Object.fromEntries(
|
||||
Object.entries(m.ITEM_COLS).map(([k, cols]) => [k, cols.map((c) => c.head)]),
|
||||
),
|
||||
cells: {
|
||||
structure: cells("structure"),
|
||||
area: cells("area"),
|
||||
labor: cells("labor"),
|
||||
item: cells("item"),
|
||||
},
|
||||
find: m.pageOf(rows, m.ITEM_COLS.structure, "기슭 찰", 1).shown.map((r) => r.열),
|
||||
findLabor: m.pageOf(rows, m.ITEM_COLS.labor, "SU-000001", 1).shown.map((r) => r.열),
|
||||
findNone: m.pageOf(rows, m.ITEM_COLS.structure, "없는말", 1),
|
||||
page3: (({ page, total, shown }) => ({ page, total, n: shown.length }))(
|
||||
m.pageOf(many, m.ITEM_COLS.structure, "", 3),
|
||||
),
|
||||
pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page,
|
||||
pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,92 @@
|
||||
/* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림.
|
||||
* 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */
|
||||
import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands";
|
||||
import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set";
|
||||
import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
let pass = 0;
|
||||
const failures: string[] = [];
|
||||
const eq = (name: string, got: unknown, want: unknown): void => {
|
||||
if (JSON.stringify(got) === JSON.stringify(want)) pass += 1;
|
||||
else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`);
|
||||
};
|
||||
|
||||
const book = (): Workbook =>
|
||||
({
|
||||
종류: "통합문서",
|
||||
판: 1,
|
||||
열: "t",
|
||||
서식: [{}],
|
||||
시트: [
|
||||
{
|
||||
id: "s1",
|
||||
이름: "면적산출",
|
||||
칸: { B1: { 값: 12.5 } },
|
||||
연결: [{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }],
|
||||
},
|
||||
{ id: "s2", 이름: "둘", 칸: {}, 연결: [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }] },
|
||||
],
|
||||
활성: "s1",
|
||||
}) as unknown as Workbook;
|
||||
const links = (b: Workbook) => b.시트.map((s) => s.연결 ?? null);
|
||||
const run = (b: Workbook, c: Command | null | false) => {
|
||||
if (!c) return null;
|
||||
return applyCommand(b, c).undo;
|
||||
};
|
||||
|
||||
// 새 키를 칸에 — 이름 · 단위 · 자재가 같이 들어감 · 소문자 · 빈칸 주소도 받음
|
||||
let b = book();
|
||||
const mt = { 코드: "MT000008", 이름: "치즐", 규격: "", 단위: "EA" };
|
||||
let undo = run(
|
||||
b,
|
||||
linkCommand(b, "s1", "자재|MT000008", " d2 ", { 이름: "치즐", 단위: "EA", 자재: mt }),
|
||||
);
|
||||
eq("새 연결", b.시트[0].연결?.[1], {
|
||||
키: "자재|MT000008",
|
||||
칸: "D2",
|
||||
이름: "치즐",
|
||||
단위: "EA",
|
||||
자재: mt,
|
||||
});
|
||||
// 되돌리기 = 처음 그대로
|
||||
run(b, undo);
|
||||
eq("되돌림", links(b), links(book()));
|
||||
|
||||
// 있는 키를 다른 칸으로 — 옛 칸 빠지고 이름 · 단위는 있던 것
|
||||
b = book();
|
||||
run(b, linkCommand(b, "s1", "깔기|㎡", "B1"));
|
||||
eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]);
|
||||
|
||||
// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나)
|
||||
b = book();
|
||||
run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" }));
|
||||
eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]);
|
||||
|
||||
// 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다
|
||||
b = book();
|
||||
undo = run(b, linkCommand(b, "s1", "치기|㎥", "A1"));
|
||||
eq("시트 넘어 옮김", links(b), [
|
||||
[
|
||||
{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" },
|
||||
{ 키: "치기|㎥", 칸: "A1", 이름: "치기" },
|
||||
],
|
||||
null,
|
||||
]);
|
||||
run(b, undo);
|
||||
eq("시트 넘어 되돌림", links(b), links(book()));
|
||||
|
||||
// 풀기 — 그 키만 빠지고 마지막이면 `연결` 칸이 없어짐
|
||||
b = book();
|
||||
run(b, linkCommand(b, "s1", "깔기|㎡", null));
|
||||
eq("풀기", links(b), [null, [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }]]);
|
||||
|
||||
// 바뀔 것 없음 = null · 틀린 주소 = false · 없는 시트 = false
|
||||
b = book();
|
||||
eq("이미 그 칸", linkCommand(b, "s1", "깔기|㎡", "b6"), null);
|
||||
eq("없는 키 풀기", linkCommand(b, "s1", "없음", null), null);
|
||||
eq("틀린 주소", linkCommand(b, "s1", "깔기|㎡", "가1"), false);
|
||||
eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false);
|
||||
eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false);
|
||||
eq("문서 그대로", links(b), links(book()));
|
||||
|
||||
console.log(JSON.stringify({ pass, fail: failures.length, failures }));
|
||||
@@ -137,11 +137,14 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀():
|
||||
|
||||
|
||||
def test_선택_입력_카드는_sub2_파일_한_벌(out):
|
||||
"""두 벌 금지 — 모달의 값 받기 카드는 `M02_MasterTemplete_Inputs.ts`(M02 카드와 같은 파일)."""
|
||||
"""두 벌 금지 — 모달의 값 받기 카드는 `M02_Labor_Values.ts`(39-3 · 일위대가 입력 모달과 같은
|
||||
값 칸) · 옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄."""
|
||||
assert not (M02 / "M02_MasterTemplete_InputsCard.ts").exists()
|
||||
inputs = (M02 / "M02_MasterTemplete_Inputs.ts").read_text(encoding="utf-8")
|
||||
values = (M02 / "M02_Labor_Values.ts").read_text(encoding="utf-8")
|
||||
assert "export function createInputsCard(" in inputs # M02 자리 고르기(link)
|
||||
assert "export function createInputsValueCard(" in inputs # 모달 값 받기(view · value)
|
||||
assert "export function createInputsValueCard(" in values # 모달 값 받기(view · value)
|
||||
assert "createInputsValueCard," in inputs and '} from "./M02_Labor_Values";' in inputs
|
||||
for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"):
|
||||
text = (UI / name).read_text(encoding="utf-8")
|
||||
assert "M02_MasterTemplete_Inputs" in text and 'InputsCard"' not in text, name
|
||||
@@ -183,6 +186,8 @@ def test_소스가_700줄을_넘지_않음():
|
||||
M02 / "M02_MasterTemplete_ItemSource.ts",
|
||||
M02 / "M02_MasterTemplete_Inputs.ts",
|
||||
M02 / "M02_MasterTemplete_Inputs_Model.ts",
|
||||
M02 / "M02_Labor_Values.ts",
|
||||
M02 / "M02_Labor_Values_Model.ts",
|
||||
):
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from fastapi.testclient import TestClient
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M01_MasterData import M01_MasterData_Router as router_module
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as combos_router
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts"))
|
||||
@@ -285,6 +286,12 @@ def test_메모가_글자_아니면_막는다(client: TestClient) -> None:
|
||||
|
||||
|
||||
# ── 미리 보기 ─────────────────────────────────────────────────────────
|
||||
# M02 미리 보기 길은 39-3 에 은퇴(일위대가 상세 = calc 길) — 같은 계산(`combo_preview`)을 바로 부름
|
||||
def _preview(body: dict) -> dict:
|
||||
got = combo_store.combo_preview(body["combo"], body["inputs"])
|
||||
return json.loads(json.dumps(got, default=str, ensure_ascii=False))
|
||||
|
||||
|
||||
def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별로_같다(client: TestClient) -> None:
|
||||
key = _make(client)["key"]
|
||||
each = {k: _inputs(k) for k in SAMPLE}
|
||||
@@ -293,7 +300,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별
|
||||
for name in ("노무비", "재료비", "경비", "계")
|
||||
}
|
||||
|
||||
got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": each})
|
||||
got = _preview({"combo": key, "inputs": each})
|
||||
assert got["멈춤"] == [], got["멈춤"]
|
||||
assert [one["로직"] for one in got["줄"]] == list(SAMPLE)
|
||||
for name in ("노무비", "재료비", "경비", "계"):
|
||||
@@ -308,7 +315,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별
|
||||
def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하지_않는다(client: TestClient) -> None:
|
||||
before = _list(client)
|
||||
row = {"이름": "저장 안 한 것", "담은로직": [{"로직": STACK, "메모": None}]}
|
||||
got = _post(client, f"{SYS}/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}})
|
||||
got = _preview({"combo": row, "inputs": {STACK: _inputs(STACK)}})
|
||||
assert got["멈춤"] == [] and Decimal(str(got["계"])) > 0
|
||||
assert _list(client) == before # 아무것도 안 생김
|
||||
|
||||
@@ -316,7 +323,7 @@ def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하
|
||||
def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: TestClient) -> None:
|
||||
key = _make(client, keys=(STACK, LAYER))["key"]
|
||||
only = {STACK: _inputs(STACK)} # LAYER 입력은 안 줌 — 그 줄만 멈춤
|
||||
got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": only})
|
||||
got = _preview({"combo": key, "inputs": only})
|
||||
stuck = [one for one in got["줄"] if not one["ok"]]
|
||||
assert len(stuck) == 1 and stuck[0]["로직"] == LAYER and stuck[0]["까닭"]
|
||||
assert len(got["멈춤"]) == 1
|
||||
@@ -327,6 +334,6 @@ def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: T
|
||||
def test_정본_로직은_조합을_만들어도_안_바뀐다(client: TestClient) -> None:
|
||||
before = _get(client, "/api/m01/logic", key=STACK)
|
||||
key = _make(client)["key"]
|
||||
_post(client, f"{SYS}/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}})
|
||||
_preview({"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}})
|
||||
after = _get(client, "/api/m01/logic", key=STACK)
|
||||
assert after["version"] == before["version"] and after["logic"] == before["logic"]
|
||||
|
||||
@@ -75,3 +75,17 @@ def test_저장_안_한_카드_고침_물음():
|
||||
assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle)
|
||||
assert main.index("await panel.saveCards();") < main.index("await panel.reload();")
|
||||
assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main
|
||||
|
||||
|
||||
def test_면적산출_카드_알약_줄_켬끔_기본_숨김():
|
||||
cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts")
|
||||
assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in cards # 없으면 숨김
|
||||
assert 'writeStateRaw("m02-card-pills", String(shown));' in cards
|
||||
assert 'sheets[0].host.classList.toggle("is-pills-off", !shown);' in cards
|
||||
assert "pills.hidden = !sheetEdit;" in cards # 고칠 수 있는 카드만(보기만은 알약 줄 없음)
|
||||
css = _text(M02 / "M02_MasterTemplete_Structure.css")
|
||||
assert re.search(r"\.m02-struct__sheet\.is-pills-off \.ss-links \{\s*display: none;", css)
|
||||
state = _text(ROOT / "A00_Common" / "b_page_state.ts")
|
||||
assert '"m02-card-pills": { bucket: "pref", scope: "global" },' in state
|
||||
# 상세(전체 편집기)는 켬끔 없음 — 늘 보임
|
||||
assert "is-pills-off" not in _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
|
||||
@@ -44,8 +44,8 @@ def test_네_카드가_틀을_씀_수정_단추_한_모양():
|
||||
assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost)
|
||||
assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임
|
||||
assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards
|
||||
inputs = _text("M02_MasterTemplete_Inputs.ts")
|
||||
assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드
|
||||
inputs = _text("M02_MasterTemplete_Inputs.ts") + _text("M02_Labor_Values.ts")
|
||||
assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드(39-3 에 `M02_Labor_Values.ts` 로)
|
||||
assert "shell.fail(" in inputs and "shell.loading()" in inputs
|
||||
assert "m02-struct__card ui-collapsible" not in inputs # 손으로 만든 겉 틀 없음
|
||||
drawing = _text("M02_MasterTemplete_DrawingCard.ts")
|
||||
|
||||
@@ -54,9 +54,29 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
app.include_router(items_router.router)
|
||||
app.include_router(combos_router.router)
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
# 문(39-6 · 시스템 관리자만)은 `test_조합_길은_시스템_관리자만` 이 봄 — 여기는 그 안의 층 규칙
|
||||
app.dependency_overrides[combos_router._door] = lambda: None
|
||||
return {"client": TestClient(app), "session": session, "storage": storage}
|
||||
|
||||
|
||||
def test_조합_길은_시스템_관리자만(world):
|
||||
"""39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만."""
|
||||
world["client"].app.dependency_overrides.pop(combos_router._door)
|
||||
for role in ("USER", "ADMIN"):
|
||||
client = _as(world, role)
|
||||
assert client.get(URL.format("system")).status_code == 403
|
||||
assert client.get("/api/m02/combos/all").status_code == 403
|
||||
assert client.get("/api/m02/combos/key/UA000001").status_code == 403
|
||||
assert _new(client, "personal", _combo("내 것"), 403) is not None
|
||||
assert client.get("/api/m02/logic-lib/logics").status_code == 200
|
||||
world["session"]["is_master"] = True
|
||||
assert _as(world, "USER").get(URL.format("system")).status_code == 403 # 회사 마스터도
|
||||
world["session"]["is_master"] = False
|
||||
admin = _as(world, "SYSTEM_ADMIN")
|
||||
assert admin.get(URL.format("system")).status_code == 200
|
||||
assert admin.get(URL.format("company")).status_code == 200
|
||||
|
||||
|
||||
def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient:
|
||||
world["session"].update(role=role, user_id=user)
|
||||
return world["client"]
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""M02 항목 목록(PLAN 39-5) — 칸 · 찾기 · 쪽(TS · `helper_m02_item_list.cjs`) ·
|
||||
서버 목록 칸(임시 storage).
|
||||
|
||||
① 종류마다 칸(도면 · 면적산출 · 일위대가 · 항목 통째) · 빈 번호 「—」 · 합계 없으면 「—」 ·
|
||||
고친 날 = 날짜만 · 찾기 = 보이는 칸 AND · 쪽 30 줄 · 넘친 쪽은 끝 쪽.
|
||||
② 서버 `items` — `수정일`(도면 · 면적산출 · 일위대가 파일) · `labor`(번호 · 담은 로직 수 ·
|
||||
합계 = 계산값.반영합.계) · 일위대가 파일이 없으면 null · 깨진 파일 · 수 아닌 합계도 목록은 뜸.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from resources.tester import test_m02_tree as base
|
||||
from resources.tester.test_m02_tree import Tree, _as
|
||||
|
||||
world = base.world # 같은 임시 storage 판
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts() -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_item_list.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_종류마다_칸(ts):
|
||||
assert ts["heads"]["structure"] == ["도번", "이름", "폴더", "단위", "고친 날"]
|
||||
assert ts["heads"]["area"] == ["면적번", "이름", "폴더", "단위", "고친 날"]
|
||||
assert ts["heads"]["labor"] == [
|
||||
"일위대가번",
|
||||
"이름",
|
||||
"폴더",
|
||||
"단위",
|
||||
"담은 로직 수",
|
||||
"합계",
|
||||
"고친 날",
|
||||
]
|
||||
assert "규격" not in sum(ts["heads"].values(), []) and "층" not in sum(ts["heads"].values(), [])
|
||||
|
||||
|
||||
def test_칸_값_빈_번호_합계_날짜(ts):
|
||||
s, labor = ts["cells"]["structure"], ts["cells"]["labor"]
|
||||
assert s[0] == ["SD-c1", "1.2m", "기슭막이 / 메쌓기", "m", "2026-10-01"]
|
||||
assert s[2][0] == "—" and s[2][2] == "" # 빈 도번 · 맨 위 항목(폴더 없음)
|
||||
assert labor[0][3:] == ["m", "3", "1,234,567", "2026-10-04"] # 합계 원 단위 반올림
|
||||
assert labor[1][5] == "—" and labor[2][0] == labor[2][4] == "—" # 합계 없음 · 일위대가 없음
|
||||
assert ts["cells"]["item"][0][-1] == "2026-10-04" # 항목 통째 = 가장 늦게 고친 날
|
||||
|
||||
|
||||
def test_찾기_쪽(ts):
|
||||
assert ts["find"] == ["c2"] # 낱말 둘 AND(폴더 · 이름 칸)
|
||||
assert ts["findLabor"] == ["c1"] # 번호 칸도 찾음
|
||||
assert ts["findNone"] == {"shown": [], "page": 1, "total": 0}
|
||||
assert ts["page3"] == {"page": 3, "total": 65, "n": 5}
|
||||
assert ts["pageOver"] == 3 and ts["pageEmpty"] == 1
|
||||
|
||||
|
||||
def test_서버_목록_칸_수정일_labor(world):
|
||||
t = Tree(_as(world), "personal")
|
||||
t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m")
|
||||
t.do(op="add-item", 경로=[], 이름="옹벽", 단위="m")
|
||||
root = world["storage"] / "7" / "42" / "templates"
|
||||
ditch, wall = t.item(["배수", "측구"]), t.item(["옹벽"])
|
||||
assert set(ditch["수정일"]) >= {"structure", "area"}
|
||||
assert len(ditch["수정일"]["structure"]) == 19 # 초 단위 ISO
|
||||
assert ditch["labor"] is None # 일위대가 파일 없음
|
||||
(root / "labor").mkdir(exist_ok=True)
|
||||
doc = {
|
||||
"일위대가번": "SU-000001",
|
||||
"담은로직": [{"로직": "GF000219"}, {"로직": "GF000137"}],
|
||||
"계산값": {"반영합": {"계": 12345.6}},
|
||||
}
|
||||
(root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8")
|
||||
(root / "labor" / f"{wall['열']}.json").write_text("{깨짐", encoding="utf-8")
|
||||
rows = {r["열"]: r for r in t.client.get(t.url).json()["items"]}
|
||||
assert rows[ditch["열"]]["labor"] == {"번호": "SU-000001", "로직수": 2, "합계": 12345.6}
|
||||
assert "labor" in rows[ditch["열"]]["수정일"]
|
||||
assert rows[wall["열"]]["labor"] == {"번호": None, "로직수": 0, "합계": None}
|
||||
doc["계산값"] = {"반영합": {"계": "글"}}
|
||||
(root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8")
|
||||
again = {r["열"]: r for r in t.client.get(t.url).json()["items"]}
|
||||
assert again[ditch["열"]]["labor"]["합계"] is None
|
||||
@@ -1,4 +1,4 @@
|
||||
"""일위대가 조합 다이어그램 값 만들기(`M02_MasterTemplete_Labor_Diagram.ts` 의 `buildDiagramData`) — 미리 보기 줄 → 로직 카드 출력.
|
||||
"""일위대가 다이어그램 값 만들기(`Labor_Diagram.ts` 의 `buildLaborDiagram` · 39-3) — calc 줄 → 카드 출력.
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS 는 이어 트랜스파일 · 화면 부품(@ui)은 빈 흉내 ·
|
||||
금액 모양(Logic_Money)도 빈 흉내(이 시험은 값 만들기만 봄). 견본 줄은 이 시험 안에 둠.
|
||||
@@ -39,7 +39,7 @@ function load(f) {
|
||||
}
|
||||
const m = load(file);
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
process.stdout.write(JSON.stringify(jobs.map((j) => m.buildDiagramData(j))));
|
||||
process.stdout.write(JSON.stringify(jobs.map((j) => m.buildLaborDiagram(j))));
|
||||
"""
|
||||
|
||||
|
||||
@@ -57,54 +57,76 @@ def run(jobs: list[dict]) -> list:
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
ROWS = [
|
||||
LOGICS = [
|
||||
{
|
||||
"key": "GC000001",
|
||||
"name": "돌쌓기",
|
||||
"unit": "원/㎡",
|
||||
"inputs": [{"name": "높이", "unit": "m"}, {"name": "지역", "unit": ""}],
|
||||
"로직": "GC000001",
|
||||
"이름": "돌쌓기",
|
||||
"결과단위": "원/㎡",
|
||||
"입력": [{"이름": "높이", "단위": "m"}, {"이름": "지역", "자동값": "서울"}],
|
||||
},
|
||||
{
|
||||
"key": "GF000002",
|
||||
"name": "작업량",
|
||||
"unit": "㎥/시간",
|
||||
"inputs": [{"name": "지역", "unit": ""}],
|
||||
},
|
||||
{"key": "GC000003", "name": "막힌 로직", "unit": "원/m", "inputs": []},
|
||||
{"로직": "GF000002", "이름": "작업량", "결과단위": "㎥/시간", "입력": [{"이름": "지역"}]},
|
||||
{"로직": "GC000003", "이름": "막힌 로직", "결과단위": "원/m", "입력": []},
|
||||
]
|
||||
ROWS = [
|
||||
{"로직": "GC000001", "입력값": {"높이": 4}},
|
||||
{"로직": "GF000002", "입력값": {"지역": "부산"}},
|
||||
{"로직": "GC000003", "입력값": {}},
|
||||
]
|
||||
MONEY = {"노무비": 100, "재료비": 50, "경비": 0, "계": 150}
|
||||
APPLIED = {"노무비": 300, "재료비": 150, "경비": 0, "계": 450}
|
||||
|
||||
|
||||
def test_돈_값_멈춤_세_갈래():
|
||||
lines = [
|
||||
{"ok": True, "갈래": "돈", "노무비": 100, "재료비": 50, "경비": 0, "계": 150},
|
||||
{"ok": True, "갈래": "결과", "결과": 8.946},
|
||||
{"ok": False, "까닭": "표에 맞는 줄이 없음"},
|
||||
]
|
||||
(got,) = run([{"rows": ROWS, "values": {"GC000001": {"높이": "4"}}, "lines": lines}])
|
||||
def _line(key: str, **more) -> dict:
|
||||
return {"로직": key, "기준": None, "반영": None, "까닭": None, **more}
|
||||
|
||||
|
||||
def test_돈_값_멈춤_세_갈래_반영이_있으면_반영():
|
||||
calc = {
|
||||
"줄": [
|
||||
_line("GC000001", 기준=MONEY, 반영=APPLIED),
|
||||
_line("GF000002", 결과=8.946),
|
||||
_line("GC000003", 까닭="표에 맞는 줄이 없음"),
|
||||
],
|
||||
"자재": [],
|
||||
}
|
||||
(got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": calc, "mats": 0}])
|
||||
outs = [x["out"] for x in got["logics"]]
|
||||
assert outs[0] == {"노무비": 100, "재료비": 50, "경비": 0, "계": 150}
|
||||
assert outs[0] == APPLIED and got["logics"][0]["basis"] == "반영"
|
||||
assert outs[1] == {"값": 8.946}
|
||||
assert outs[2] == {"멈춤": "표에 맞는 줄이 없음"}
|
||||
|
||||
|
||||
def test_넣은_값은_로직별_빈_값은_빈_글():
|
||||
(got,) = run(
|
||||
[
|
||||
{
|
||||
"rows": ROWS,
|
||||
"values": {"GC000001": {"높이": "4"}, "GF000002": {"지역": "서울"}},
|
||||
"lines": None,
|
||||
}
|
||||
]
|
||||
)
|
||||
def test_수량_없으면_기준():
|
||||
calc = {"줄": [_line("GC000001", 기준=MONEY)], "자재": []}
|
||||
(got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}])
|
||||
assert got["logics"][0]["out"] == MONEY and got["logics"][0]["basis"] == "기준"
|
||||
|
||||
|
||||
def test_넣은_값은_로직별_빈_값은_자동값_또는_빈_글():
|
||||
(got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}])
|
||||
first, second, third = got["logics"]
|
||||
assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "")]
|
||||
assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "서울")]
|
||||
assert first["inputs"][1].get("auto") is True and "auto" not in first["inputs"][0]
|
||||
assert first["inputs"][0]["unit"] == "m"
|
||||
assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "서울")]
|
||||
assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "부산")]
|
||||
assert third["inputs"] == []
|
||||
|
||||
|
||||
def test_아직_안_셌으면_출력_없음():
|
||||
(got,) = run([{"rows": ROWS, "values": {}, "lines": None}])
|
||||
(got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}])
|
||||
assert [x["out"] for x in got["logics"]] == [None, None, None]
|
||||
assert [x["key"] for x in got["logics"]] == ["GC000001", "GF000002", "GC000003"]
|
||||
|
||||
|
||||
def test_자재목록은_카드_하나_재료비로():
|
||||
calc = {
|
||||
"줄": [_line("GC000001", 기준=MONEY)],
|
||||
"자재": [{"기준단가": 1000, "반영": 5000}, {"기준단가": 200, "반영": None}],
|
||||
}
|
||||
(got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 2}])
|
||||
card = got["logics"][-1]
|
||||
assert card["material"] is True and card["inputs"] == []
|
||||
assert card["out"] == {"노무비": 0, "재료비": 5200, "경비": 0, "계": 5200}
|
||||
assert card["basis"] == "기준" # 반영 없는 자재가 있음
|
||||
(none,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}])
|
||||
assert not any(x.get("material") for x in none["logics"])
|
||||
|
||||
@@ -1,124 +1,51 @@
|
||||
"""일위대가 라이브러리 나무 자료(PLAN 27-2b · `Labor_Tree.ts`) — Node 로 TS 를 돌려 판정.
|
||||
"""좌측 패널 일위대가(PLAN 39-2) — 맨 위 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움 ·
|
||||
일위대가는 구조물 조합 하위(구조물 도면 → 일위대가 → 면적산출). 글 검사(화면은 ORCA).
|
||||
|
||||
못박는 것
|
||||
- 조합 → 나무 항목: 경로 = [구분, 상세구분, 이름](빈 칸 건너뜀) · 열 = 키.
|
||||
- 나무 고침 알림: 새 · 바뀐 · 옮긴 폴더 경로 · 조합 고침은 그 조합(열)을 고른 채 ·
|
||||
지우기는 고름 없음.
|
||||
옛 일위대가 라이브러리 나무 시험(PLAN 27-2b · 30-3 · 30-5)은 나무를 지우며 이 검사로 갈음.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = r"""
|
||||
const fs = require("fs");
|
||||
const ts = require(process.argv[1] + "/config/node_modules/typescript");
|
||||
const file = "/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts";
|
||||
const src = fs.readFileSync(process.argv[1] + file, "utf8");
|
||||
const js = ts.transpileModule(src, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const m = box.exports;
|
||||
const row = (키, 이름, 구분, 상세구분) => ({ 키, 이름, 구분, 상세구분, 단위: "m" });
|
||||
const list = {
|
||||
판: "v1",
|
||||
폴더: [["석축"], ["석축", "찰쌓기"], ["빈"]],
|
||||
줄: [row("UP1-000001", "가", "석축", "찰쌓기"), row("UP1-000002", "나", "석축", null),
|
||||
row("UP1-000003", "다", null, "무시")],
|
||||
};
|
||||
const view = m.toView(list);
|
||||
const notes = [
|
||||
{ op: "add-folder", 경로: ["석축"], 이름: "메쌓기" },
|
||||
{ op: "rename", 경로: ["석축", "찰쌓기"], 이름: "찰" },
|
||||
{ op: "move", 경로: ["석축", "찰쌓기"], 새경로: [] },
|
||||
{ op: "move", 경로: ["석축"], 키: "UP1-000002", 새경로: ["빈"] },
|
||||
{ op: "rename", 경로: ["석축"], 키: "UP1-000002", 이름: "나나" },
|
||||
{ op: "delete", 경로: ["석축"], 키: "UP1-000002" },
|
||||
{ op: "delete", 경로: ["빈"] },
|
||||
].map((e) => m.noteOf(e, e.op === "delete" ? ["UP1-000002"] : []));
|
||||
console.log(JSON.stringify({ view, notes }));
|
||||
"""
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got():
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
proc = subprocess.run(
|
||||
["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
def _text(name: str) -> str:
|
||||
return (M02 / name).read_text("utf-8")
|
||||
|
||||
|
||||
def test_조합이_나무_항목으로(got):
|
||||
items = got["view"]["items"]
|
||||
assert [i["경로"] for i in items] == [["석축", "찰쌓기", "가"], ["석축", "나"], ["다"]]
|
||||
assert [i["열"] for i in items] == ["UP1-000001", "UP1-000002", "UP1-000003"]
|
||||
assert got["view"]["폴더"] == [["석축"], ["석축", "찰쌓기"], ["빈"]]
|
||||
assert "알림" not in got["view"]
|
||||
def test_일위대가_나무_파일과_이음이_없음():
|
||||
assert not (M02 / "M02_MasterTemplete_Labor.ts").exists()
|
||||
assert not (M02 / "M02_MasterTemplete_Labor_Tree.ts").exists()
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
assert "buildLaborSection" not in page and 'layerBar("일위대가"' not in page
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
assert "LaborSection" not in side and "labor?." not in side
|
||||
|
||||
|
||||
def test_나무_고침_알림(got):
|
||||
add, rename, move, move_item, rename_item, delete_item, delete_folder = got["notes"]
|
||||
assert add["경로"] == ["석축", "메쌓기"] and "열" not in add
|
||||
assert rename["경로"] == ["석축", "찰"]
|
||||
assert move["경로"] == ["찰쌓기"]
|
||||
assert move_item["열"] == "UP1-000002" and move_item["경로"][:-1] == ["빈"]
|
||||
assert rename_item["경로"] == ["석축", "나나"]
|
||||
assert "열" not in delete_item and delete_item["지운항목"] == ["UP1-000002"]
|
||||
assert "경로" not in delete_folder
|
||||
def test_나무_부품에_일위대가_전용_칸_없음():
|
||||
panel = _text("M02_MasterTemplete_TreePanel.ts")
|
||||
for gone in ("maxDepth", "opt.item", "opt.clipKind", "clipKind?:"):
|
||||
assert gone not in panel, gone
|
||||
|
||||
|
||||
def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤():
|
||||
"""브레인 검증 흠(27-2b) · 29-2 — 층 단추 두 벌(일위대가 라이브러리 · 구조물 조합 줄 아래) ·
|
||||
일위대가 단추로 층을 바꾸면 일위대가 화면 그대로 · 좌측 「일위대가 라이브러리」를 열면 해시
|
||||
`open=일위대가`(다시 그리지 않고 주소만)."""
|
||||
page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8")
|
||||
assert "go: (next, nextOwner) => void go(next, nextOwner, open)," in page # 두 벌 같은 길
|
||||
assert (
|
||||
'bar: (add, back) => layerBar("일위대가", add, back),' in page
|
||||
and 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
)
|
||||
labor = page.split("buildLaborSection({")[1].split("make:")[0]
|
||||
assert 'mark("일위대가")' in labor # 여는 순간 해시 open
|
||||
assert "history.replaceState" in page # hashchange 없이(화면 다시 안 그림)
|
||||
assert 'if (main.current() && view === "일위대가") mark("구조물조합")' in page
|
||||
def test_하위_차례_전체_항목_이름():
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
kinds = side.split("const COLUMN_KINDS")[1].split("];")[0]
|
||||
order = [kinds.index(f'kind: "{k}"') for k in ("structure", "labor", "area")]
|
||||
assert order == sorted(order) # 구조물 도면 → 일위대가 → 면적산출
|
||||
assert 'label: () => L("M02_KindLabor"), all: true' in kinds
|
||||
assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음
|
||||
assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side
|
||||
assert 'return void selectAll("labor");' in side # 해시 open=일위대가
|
||||
locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8")
|
||||
assert 'M02_KindLabor: ["일위대가"' in locale and 'M02_ItemsAll: ["전체 항목"' in locale
|
||||
assert 'B01_Dashboard_CardUnitCombo: ["일위대가",' in locale
|
||||
css = _text("M02_MasterTemplete_UI_Style.css")
|
||||
assert ".m02-side__rule {" in css and "border-top: 1px solid var(--color-border);" in css
|
||||
|
||||
|
||||
def test_일위대가_나무에_여러_개_복사_붙여넣기_연결():
|
||||
"""PLAN 30-3 — 공용 나무 부품(30-2) 콜백을 일위대가 서버 길에 이음 ·
|
||||
붙임 종류는 따로(구조물과 안 섞임)."""
|
||||
labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8")
|
||||
assert 'clipKind: "일위대가"' in labor and "copyTo: opt.copyTo" in labor
|
||||
assert "editComboTreeMany(ops, 판, scope)" in labor # 여러 개 옮기기 · 지우기
|
||||
assert "pasteCombos(clip.from as LaborScope, pick, dest, list.판, to)" in labor # 대상 층
|
||||
page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8")
|
||||
assert "canEdit: isAdmin, reveal, copyTo })" in page # 구조물 나무와 같은 「〇〇로 복사」 목록
|
||||
|
||||
|
||||
def test_새_조합_단추는_제목_줄_오른쪽_목록은_공용_표():
|
||||
"""PLAN 30-5 — 「새 조합」 = 제목 줄 오른쪽 끝(읽기 층 없음) · 목록 = 공용 표(줄 선).
|
||||
머리 줄도 공용 표 모양."""
|
||||
edit = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts").read_text("utf-8")
|
||||
assert (
|
||||
"newButton: edit" in edit and 'className: "ui-general-block__table m02-labor__list"' in edit
|
||||
)
|
||||
assert (
|
||||
'tc("New"), variant: "filled", onClick: () => startNew([]) })]' not in edit
|
||||
) # 목록 안 옛 자리
|
||||
labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8")
|
||||
assert 'library.bind(opt.show(tc("Library"), library.root, library.tools))' in labor
|
||||
main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
|
||||
assert (
|
||||
'slot.replaceChildren(el("span", { className: "m02-main__tools", children: [tools] }))'
|
||||
in main
|
||||
)
|
||||
css = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Style.css").read_text("utf-8")
|
||||
assert ".m02-main__tools {\n display: inline-flex;\n margin-left: auto;" in css
|
||||
def test_층_단추는_구조물_조합_한_벌():
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""일위대가 넣을 값(39-3) — 입력 목록 길 `GET /api/m02/logic-lib/inputs` · 값 셈(TS 모델).
|
||||
|
||||
못박는 것
|
||||
- 입력 목록 = 키 차례 · 겹친 키 한 번 · 입력마다 자동값 · 없는 키는 「빠진로직」.
|
||||
- 옛 선택 입력 길(`M02_MasterTemplete_Inputs.inputs`)과 몸 한 벌(`M02_Labor_Inputs`).
|
||||
- 값 셈 = 칸 글 → 저장 값(빈 글 = 자동값 · 범위 = 수 · 고르기 = 원래 모양) · 범위 밖 ·
|
||||
모르는 이름 버림(사라진 값 알림) · 줄 아래 작은 글 · 빈 값 수(자동값도 없는 입력만).
|
||||
화면 TS 는 그 자리에서 트랜스파일해 node 로 돌림 · 정본은 읽기만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos_router
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
A, B = "GF000222", "GF000221" # 찰쌓기 인력 · 장비
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(combos_router.router)
|
||||
app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": "USER"}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_입력_목록은_키_차례_겹침_한_번_자동값_빠진_로직(client: TestClient) -> None:
|
||||
res = client.get("/api/m02/logic-lib/inputs", params={"keys": f"{B},{A},{B},GF999999,"})
|
||||
assert res.status_code == 200, res.text
|
||||
got = res.json()
|
||||
assert [x["로직"] for x in got["로직"]] == [B, A]
|
||||
assert got["빠진로직"] == ["GF999999"]
|
||||
one = got["로직"][1]
|
||||
assert one["이름"] and one["결과단위"]
|
||||
assert one["입력"] and all("자동값" in i and "이름" in i for i in one["입력"])
|
||||
# 옛 선택 입력 목록과 같은 몸
|
||||
assert got["로직"] == labor_inputs.logic_inputs([B, A])["로직"]
|
||||
|
||||
|
||||
def test_빈_키는_빈_목록(client: TestClient) -> None:
|
||||
got = client.get("/api/m02/logic-lib/inputs").json()
|
||||
assert got == {"로직": [], "빠진로직": []}
|
||||
|
||||
|
||||
SCRIPT = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript"));
|
||||
const file = path.join(process.argv[1], "M02_MasterTemplete", "M02_Labor_Values_Model.ts");
|
||||
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, require);
|
||||
const m = box.exports;
|
||||
const height = { 이름: "높이", 단위: "m", 범위: [0.5, 5], 자동값: 1.2 };
|
||||
const kind = { 이름: "종류", 고르기: ["막돌", "견치돌"] };
|
||||
const size = { 이름: "규격", 고르기: [25, 40] };
|
||||
const area = { 이름: "지역" };
|
||||
const inputs = [height, kind, size, area];
|
||||
console.log(JSON.stringify({
|
||||
stored: [
|
||||
m.storedOf(height, ""), m.storedOf(height, "2.5"), m.storedOf(kind, "막돌"),
|
||||
m.storedOf(size, "40"), m.storedOf(area, "서울"),
|
||||
],
|
||||
outside: [
|
||||
m.isOutside(height, "9"), m.isOutside(height, "0.5"), m.isOutside(height, ""),
|
||||
m.isOutside(height, "abc"), m.isOutside(area, "9"),
|
||||
],
|
||||
known: m.keepKnown(inputs, { 높이: 2, 종류: "", 없앤것: 3 }),
|
||||
line: m.valueLine(inputs, { 높이: 2, 규격: 40 }),
|
||||
lineEmpty: m.valueLine(inputs, {}),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def test_값_셈() -> None:
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node 없음")
|
||||
out = subprocess.run(
|
||||
[node, "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert out.returncode == 0, out.stderr
|
||||
got = json.loads(out.stdout)
|
||||
assert got["stored"] == [None, 2.5, "막돌", 40, "서울"]
|
||||
assert got["outside"] == [True, False, False, True, False]
|
||||
assert got["known"] == {"kept": {"높이": 2}, "lost": ["없앤것"]}
|
||||
assert got["line"] == {"text": "높이 2m · 규격 40", "empty": 2} # 종류 · 지역 = 자동값 없음
|
||||
assert got["lineEmpty"] == {"text": "", "empty": 3} # 높이는 자동값이 있어 빈 값 아님
|
||||
@@ -16,6 +16,7 @@ from fastapi import HTTPException
|
||||
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
@@ -55,16 +56,16 @@ def test_권한_표():
|
||||
}
|
||||
assert got == {
|
||||
"일반": ["읽기", "읽기", "403", "수정", "403", "403"],
|
||||
"회사관리": ["읽기", "수정", "403", "수정", "읽기", "403"],
|
||||
"마스터": ["읽기", "수정", "403", "수정", "읽기", "403"],
|
||||
"시스템": ["수정", "수정", "읽기", "수정", "읽기", "읽기"],
|
||||
"회사관리": ["읽기", "수정", "403", "수정", "수정", "403"], # 맡은 회사(39-6)
|
||||
"마스터": ["읽기", "수정", "403", "수정", "수정", "403"],
|
||||
"시스템": ["수정", "수정", "수정", "수정", "수정", "수정"], # 어느 회사나 맡음
|
||||
}
|
||||
# 자기 층 owner 를 적어도 같음 · 모양 틀림 400 · 회사 없음 409
|
||||
assert access.grant(USER, "personal", "7/42").write is True
|
||||
assert _answer(SYSADMIN, "personal", "9") == "400"
|
||||
assert _answer({**USER, "company_id": None}, "company", None) == "409"
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
access.require(ADMIN, "personal", "7/43", write=True)
|
||||
access.require(USER, "company", None, write=True)
|
||||
assert caught.value.status_code == 403
|
||||
|
||||
|
||||
@@ -89,8 +90,19 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
|
||||
monkeypatch.setattr(layers_router, "_company_users", company_users)
|
||||
monkeypatch.setattr(layers_router, "_companies", companies)
|
||||
notes: list[tuple[int, str, str | None]] = []
|
||||
|
||||
async def member(owner: str) -> bool:
|
||||
company, _, user = owner.partition("/")
|
||||
return users.get(int(user)) == int(company) if user else int(company) in (7, 8, 9)
|
||||
|
||||
async def note(session: dict[str, Any], got: Any) -> None:
|
||||
notes.append((session["user_id"], got.layer, got.owner))
|
||||
|
||||
monkeypatch.setattr(items_router, "_member", member)
|
||||
monkeypatch.setattr(items_router, "_note", note)
|
||||
session: dict[str, Any] = dict(USER)
|
||||
return {"client": _app(session), "session": session, "storage": storage}
|
||||
return {"client": _app(session), "session": session, "storage": storage, "notes": notes}
|
||||
|
||||
|
||||
def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]):
|
||||
@@ -111,14 +123,44 @@ def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]):
|
||||
assert client.get(tree).status_code == 200
|
||||
assert client.get(table).status_code == 200 # 양식 길도 같은 규칙
|
||||
body = {"op": "add-folder", "경로": [], "이름": "새", "판": client.get(tree).json()["판"]}
|
||||
assert client.post(tree, json=body).status_code == 403 # 읽기만
|
||||
assert client.post(tree, json=body).status_code == 200 # 맡은 회사 = 고침(39-6)
|
||||
assert world["notes"] == [(42, "personal", "7/43")] # 남의 층 쓰기 = 시스템 로그 한 줄
|
||||
assert client.get("/api/m02/items?layer=personal&owner=7/43").status_code == 200
|
||||
session.update(USER)
|
||||
assert client.get(tree).status_code == 403
|
||||
assert client.get(table).status_code == 403
|
||||
assert (
|
||||
client.post(tree, json={**body, "이름": "몰래"}).status_code == 403
|
||||
) # 일반 = 남 개인 막힘
|
||||
session.update(SYSADMIN)
|
||||
assert client.get(tree).status_code == 200
|
||||
assert client.post(tree, json=body).status_code == 403 # 시스템 관리자도 남의 층은 읽기만
|
||||
body = {"op": "add-folder", "경로": [], "이름": "AS", "판": client.get(tree).json()["판"]}
|
||||
assert client.post(tree, json=body).status_code == 200 # 시스템 관리자 = 어느 회사나
|
||||
|
||||
|
||||
def test_남의_층_첫_쓰기는_소속_확인_뒤_뼈대(world: dict[str, Any]):
|
||||
"""39-6 — 폴더 없는 남의 층에 쓰면 주인이 있을 때만 뼈대 · 없는 사람 · 남의 회사 사람은 403 ·
|
||||
읽기는 여전히 뼈대를 안 만듦 · 일위대가 키는 그 층 머리(UC · UP)."""
|
||||
client, session, storage = world["client"], world["session"], world["storage"]
|
||||
session.update(ADMIN)
|
||||
add = {"op": "add-folder", "경로": [], "이름": "새", "판": ""}
|
||||
assert client.get("/api/m02/layers/personal/tree?owner=7/44").status_code == 404
|
||||
assert not (storage / "7/44").exists()
|
||||
assert client.post("/api/m02/layers/personal/tree?owner=7/99", json=add).status_code == 403
|
||||
assert not (storage / "7/99").exists() # 없는 사람 폴더를 안 만듦
|
||||
assert client.post("/api/m02/layers/personal/tree?owner=9/90", json=add).status_code == 403
|
||||
first = client.post("/api/m02/layers/personal/tree?owner=7/44", json=add)
|
||||
assert first.status_code == 409 # 뼈대는 섰고 판이 달라 409 — 다시 읽고 고침
|
||||
assert (storage / "7/44/templates/table/구조물집계표.json").is_file()
|
||||
add["판"] = client.get("/api/m02/layers/personal/tree?owner=7/44").json()["판"]
|
||||
assert client.post("/api/m02/layers/personal/tree?owner=7/44", json=add).status_code == 200
|
||||
session.update(SYSADMIN)
|
||||
assert client.post("/api/m02/layers/company/tree?owner=5", json=add).status_code == 403
|
||||
assert not (storage / "5").exists() # 없는 회사
|
||||
add["판"] = ""
|
||||
assert client.post("/api/m02/layers/company/tree?owner=9", json=add).status_code == 409
|
||||
assert (storage / "9/templates/table/구조물집계표.json").is_file()
|
||||
assert [n[2] for n in world["notes"]] == ["7/44", "7/44", "9"]
|
||||
|
||||
|
||||
def test_남의_층_목록(world: dict[str, Any]):
|
||||
@@ -129,14 +171,21 @@ def test_남의_층_목록(world: dict[str, Any]):
|
||||
people = "/api/m02/layers/personal/owners"
|
||||
assert client.get(people).status_code == 403 # 일반 사용자
|
||||
assert client.get("/api/m02/layers/company/owners").status_code == 403
|
||||
(storage / "7/43/templates/table").mkdir(parents=True)
|
||||
(storage / "7/43/templates/table/구조물집계표.json").write_text("{}", encoding="utf-8")
|
||||
session.update(ADMIN)
|
||||
assert client.get(people).json() == [
|
||||
{"owner": "7/43", "name": "u43"}
|
||||
] # 본인 · 44(자리 없음) 뺌
|
||||
assert client.get(people).json() == [ # 39-6 — 회사 사람 전부 · 나 맨 위 · 빈 사람 표시
|
||||
{"owner": "7/42", "name": "u42", "비어있음": True},
|
||||
{"owner": "7/43", "name": "u43", "비어있음": False},
|
||||
{"owner": "7/44", "name": "u44", "비어있음": True},
|
||||
]
|
||||
assert not (storage / "7/44").exists() # 목록이 폴더를 안 만듦
|
||||
assert client.get(f"{people}?company=9").status_code == 403 # 남의 회사 사람
|
||||
session.update(SYSADMIN)
|
||||
assert client.get("/api/m02/layers/company/owners").json() == [
|
||||
{"owner": "7", "name": "c7"}, # 자기 회사는 자리 없어도
|
||||
{"owner": "9", "name": "c9"},
|
||||
]
|
||||
assert client.get(f"{people}?company=9").json() == [{"owner": "9/90", "name": "u90"}]
|
||||
assert client.get(f"{people}?company=9").json() == [
|
||||
{"owner": "9/90", "name": "u90", "비어있음": True}
|
||||
]
|
||||
|
||||
@@ -26,11 +26,12 @@ def _text(path: Path) -> str:
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_층별_고칠_권한():
|
||||
"""서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c) — 남의 층(owner)은 읽기만."""
|
||||
"""서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c · 39-6) — 남의 층(owner) = 맡은 회사면 고침."""
|
||||
text = _text(LAYER)
|
||||
own = re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M)
|
||||
found = re.search(r"^export function accessFor[\s\S]*?^}", text, re.M)
|
||||
assert own and found, "ownOwner · accessFor 를 못 찾음"
|
||||
manages = re.search(r"^export function manages[\s\S]*?^}", text, re.M)
|
||||
assert own and found and manages, "ownOwner · accessFor · manages 를 못 찾음"
|
||||
script = (
|
||||
'type ManageLayer = "system" | "company" | "personal";\n'
|
||||
"interface SessionUser { id: string; role?: string; company_id?: number | null;"
|
||||
@@ -38,6 +39,8 @@ def test_층별_고칠_권한():
|
||||
"interface LayerAccess { canWrite: boolean; needCompany: boolean }\n"
|
||||
+ own.group(0).replace("export ", "")
|
||||
+ found.group(0).replace("export ", "")
|
||||
+ "\n"
|
||||
+ manages.group(0).replace("export ", "")
|
||||
+ "\nconst cases = JSON.parse(process.argv[1]);"
|
||||
+ "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1], c[2]))));"
|
||||
)
|
||||
@@ -54,7 +57,8 @@ def test_층별_고칠_권한():
|
||||
["personal", None, user], ["personal", None, admin], ["personal", None, orphan],
|
||||
["personal", None, sysadmin],
|
||||
["personal", "3/7", user], ["personal", "3/7", admin], ["company", "9", sysadmin],
|
||||
["personal", "9/90", sysadmin],
|
||||
["personal", "9/90", sysadmin], ["personal", "3/5", user], ["personal", "9/90", admin],
|
||||
["company", "9", admin],
|
||||
] # fmt: skip
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
@@ -69,7 +73,8 @@ def test_층별_고칠_권한():
|
||||
ok, ro, ro, # 시스템 = SYSTEM_ADMIN 만
|
||||
ok, ok, ro, no_company, ok, # 회사 = ADMIN · is_master · SYSTEM_ADMIN · 일반 읽기만
|
||||
ok, ok, no_company, no_company, # 개인 = 본인 · 회사 없으면 안내
|
||||
ok, ro, ro, ro, # 자기 층 owner 는 자기 층 · 남의 층은 읽기만
|
||||
ok, ok, ok, ok, # 자기 층 owner 는 자기 층 · 맡은 회사(39-6)의 남 층 = 고침
|
||||
ro, ro, ro, # 일반의 남 개인 · 회사 관리자의 남의 회사 = 못 고침(읽기는 서버가 403)
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
@@ -87,6 +92,55 @@ def test_주소_인자를_라우트에서_뗌():
|
||||
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_가져올_곳_탭():
|
||||
"""39-6 · 답 2 — 탭 = 마스터 · 볼 회사 · 내 개인 · 다른 사람(맡은 회사) · 실무문서(시스템 관리자 ·
|
||||
일위대가) · 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터)."""
|
||||
text = _text(LAYER)
|
||||
parts = [
|
||||
re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M),
|
||||
re.search(r"^export function manages[\s\S]*?^}", text, re.M),
|
||||
re.search(r"^export interface Source[\s\S]*?^}", text, re.M),
|
||||
re.search(r"^export function readableSources[\s\S]*?^}", text, re.M),
|
||||
]
|
||||
assert all(parts), "ownOwner · manages · Source · readableSources 를 못 찾음"
|
||||
script = (
|
||||
'type ManageLayer = "system" | "company" | "personal";\n'
|
||||
"interface SessionUser { id: string; role?: string; company_id?: number | null;"
|
||||
" is_master?: boolean }\n"
|
||||
"const L = (key: string): string => key;\n"
|
||||
+ "\n".join(p.group(0).replace("export ", "") for p in parts if p)
|
||||
+ "\nconst cases = JSON.parse(process.argv[1]);"
|
||||
+ "\nconsole.log(JSON.stringify(cases.map(([u, c, practice]) => {"
|
||||
+ "\n const got = readableSources(u, c, { practice });"
|
||||
+ "\n return [got.first, got.tabs.map((t) => t.id + (t.owner ? ':' + t.owner : ''))];"
|
||||
+ "\n})));"
|
||||
)
|
||||
sys1 = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": 1}
|
||||
admin = {"id": "5", "role": "ADMIN", "company_id": 3}
|
||||
master = {"id": "6", "role": "USER", "company_id": 3, "is_master": True}
|
||||
user = {"id": "7", "role": "USER", "company_id": 3}
|
||||
orphan = {"id": "8", "role": "USER", "company_id": None}
|
||||
cases = [
|
||||
[sys1, "3", True], [sys1, "1", False], [admin, "3", True], [master, "3", False],
|
||||
[user, "3", True], [orphan, None, False],
|
||||
] # fmt: skip
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script, json.dumps(cases)],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [
|
||||
["master", ["master", "company:3", "personal", "people", "practice"]], # 고객 회사 봄
|
||||
["master", ["master", "company", "personal", "people"]], # 자기 회사 · 일위대가 아님
|
||||
["personal", ["master", "company", "personal", "people"]], # 회사 관리자 · 실무문서 없음
|
||||
["personal", ["master", "company", "personal", "people"]], # is_master = 회사 관리자
|
||||
["personal", ["master", "company", "personal"]], # 일반 = 다른 사람 없음
|
||||
["master", ["master"]], # 회사 없음
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_볼_회사는_시스템_관리자만_고름():
|
||||
"""29-2 — 시스템 관리자 = 해시 company · sessionStorage aislo.adminCompany · 자기 회사 차례 ·
|
||||
@@ -132,11 +186,13 @@ def test_층_단추_셋_남의_회사_사람_나무():
|
||||
assert 'el("select"' not in roots and "createSelect" not in roots # 사람 고르기 없앰
|
||||
assert 'fetchOwners("personal", opt.company)' in roots # 사람 나무
|
||||
assert 'button("personal", null, true)' in roots # 남의 회사 「개인」 = 읽기
|
||||
assert 'user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user)' in roots
|
||||
assert "manages(company, user);" in roots.split("export const seesOthers")[1] # 맡은 회사(39-6)
|
||||
assert "const readOnly = !people && !accessFor(" in roots # 사람 나무 = 고침
|
||||
assert 'L("M02_PeopleEmpty")' in roots
|
||||
assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page
|
||||
assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page
|
||||
assert 'peopleTree("구조물조합", tree.root)' in page
|
||||
assert 'wrap: (panel) => peopleTree("일위대가", panel)' in page
|
||||
assert 'peopleTree("일위대가"' not in page # 일위대가 사람 나무 벌은 39-2 에서 지움
|
||||
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert (
|
||||
"api.fetch(opt.layer, owner)" in panel and "api.edit(opt.layer, body_, 판, owner)" in panel
|
||||
@@ -219,17 +275,20 @@ def test_읽기만_표는_단추_고치기_모두_막음():
|
||||
|
||||
|
||||
def test_켜진_층_단추는_목록으로_복사_끌기():
|
||||
"""30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 ·
|
||||
저장 안 한 고침은 물음) · 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) ·
|
||||
끌어다 옮기기는 쓸 수 있는 층만."""
|
||||
"""30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) ·
|
||||
30-2 · 39-6 「〇〇로 복사…」 = 쓸 수 있는 층(지금 층 뺌 · 폴더 고름) · 끌어다 옮기기는 쓸 수 있는 층만."""
|
||||
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
|
||||
assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page
|
||||
assert 'bar: (add, back) => layerBar("일위대가", add, back)' in page
|
||||
assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page
|
||||
labor = _text(M02 / "M02_MasterTemplete_Labor.ts")
|
||||
assert 'if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;' in labor
|
||||
# 39-6 — 쓸 수 있는 층 전부(볼 회사 주인까지 · 지금 층 뺌) · 대상 폴더 고르는 창
|
||||
assert 'const toOwner = one === "company" ? companyOwner(company, user) : null;' in page
|
||||
assert "(to === layer && toOwner === here)) return [];" in page
|
||||
copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts")
|
||||
assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to
|
||||
assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text(
|
||||
M02 / "M02_MasterTemplete_TreePanel.ts"
|
||||
)
|
||||
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert "if (opt.canEdit) drag.arm(button, one.id);" in panel
|
||||
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
|
||||
@@ -259,10 +318,9 @@ def test_구조물_조합_제목_줄_새_항목():
|
||||
|
||||
|
||||
def test_들어오면_두_컨테이너_펼침_끌기_뒤_강조_지움():
|
||||
"""31-5 — 좌측 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채 · 끌어 놓은 뒤 강조(나무 칸
|
||||
"""31-5 — 좌측 「구조물 조합」 펼친 채(일위대가는 그 하위 · 39-2) · 끌어 놓은 뒤 강조(나무 칸
|
||||
자신 = 맨 위 자리 포함)를 모두 지움 · 칸 밖으로 나가면 지움(칸 안 자식 위로는 그대로)."""
|
||||
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
|
||||
assert 'labor?.body.closest("section")?.classList.remove("is-collapsed");' in side
|
||||
assert 'summary.classList.remove("is-collapsed");' in side
|
||||
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
|
||||
mark = drag.split("const dropMark = ")[1].split("};")[0]
|
||||
|
||||
@@ -77,7 +77,13 @@ def test_시스템_층은_그대로_항목_목록은_SS_줄(world):
|
||||
rows = client.get("/api/m02/items").json()
|
||||
ss = store.read_combos()["줄"]
|
||||
assert [r["key"] for r in rows] == [r["키"] for r in ss] and rows[0]["key"] == "SS000001"
|
||||
assert set(rows[0]) == {"key", "names", *items_router.items_store.ITEM_FIELDS}
|
||||
assert set(rows[0]) == {
|
||||
"key",
|
||||
"names",
|
||||
"수정일", # 목록 칸(39-5)
|
||||
"labor",
|
||||
*items_router.items_store.ITEM_FIELDS,
|
||||
}
|
||||
assert set(rows[0]["names"]) == {"일위대가", "자재조합", "도번", "면적번", "재료번"}
|
||||
assert client.get("/api/m02/layers/system/structure-combos").json() == store.read_combos()
|
||||
# 시스템 층 쓰기는 SYSTEM_ADMIN 만
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""실무문서(PLAN 39-2) — 옛 마스터 일위대가 조합 표 통째 · 시스템 관리자만 통째 지우기.
|
||||
|
||||
못박는 것
|
||||
- `DELETE /api/m02/practice?판=` = 시스템 관리자만(그 밖 403) · 판 다르면 409 ·
|
||||
구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 지우면 줄 0 · 마스터 틀 검사 그대로.
|
||||
- 화면(`Practice.ts`) = 마스터 층 · 시스템 관리자만 · 물음 두 번 · 상세 읽기만.
|
||||
정본(마스터)은 읽기만 · 쓰기는 임시 사본.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
from resources.tester.test_m02_combo_layers import _as, _list, master_copy, world # noqa: F401
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
|
||||
|
||||
def _drop(client, 판: str, want: int) -> dict:
|
||||
res = client.delete("/api/m02/practice", params={"판": 판})
|
||||
assert res.status_code == want, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def test_통째_지우기_시스템_관리자만_판_쓰는_곳(world, master_copy, monkeypatch): # noqa: F811
|
||||
admin = _as(world, "SYSTEM_ADMIN")
|
||||
before = _list(admin, "system")
|
||||
assert before["줄"] # 사본에 옛 조합이 있음
|
||||
for role in ("USER", "ADMIN"):
|
||||
_drop(_as(world, role), before["판"], 403)
|
||||
admin = _as(world, "SYSTEM_ADMIN")
|
||||
_drop(admin, "옛판", 409)
|
||||
used = before["줄"][0]["키"]
|
||||
monkeypatch.setattr(combo_store, "combo_users", lambda k: ["항목 하나"] if k == used else [])
|
||||
got = _drop(admin, before["판"], 409)
|
||||
assert "항목 하나" in json.dumps(got, ensure_ascii=False)
|
||||
assert len(_list(admin, "system")["줄"]) == len(before["줄"]) # 아무것도 안 지움
|
||||
monkeypatch.setattr(combo_store, "combo_users", lambda k: [])
|
||||
got = _drop(admin, before["판"], 200)
|
||||
assert got["줄"] == [] and len(got["지운조합"]) == len(before["줄"])
|
||||
doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8"))
|
||||
assert doc["줄"] == [] and m01.cm.check_form(combos.FILE, doc) == []
|
||||
again = _list(admin, "system")
|
||||
assert _drop(admin, again["판"], 200)["지운조합"] == [] # 빈 표 = 그대로
|
||||
|
||||
|
||||
def test_화면_시스템_관리자_마스터만_물음_두_번_읽기만():
|
||||
side = (M02 / "M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
|
||||
assert 'layer === "system" && opt.isAdmin' in side
|
||||
order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0]
|
||||
assert order.index("extras.도면") < order.index("practice") # 도면 양식 아래
|
||||
view = (M02 / "M02_MasterTemplete_Practice.ts").read_text("utf-8")
|
||||
assert view.count("showConfirmDialog(") == 2
|
||||
assert "dropPractice(" in view and "createInputField" not in view # 고칠 칸 없음
|
||||
@@ -120,17 +120,14 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에
|
||||
assert side.index("옵션: section(options.title") < side.index(
|
||||
'도면: section(L("M02_KindDrawing")'
|
||||
)
|
||||
# 차례 = 일위대가 조합(27-2b) → 구조물 조합 → 옵션 → 도면 양식
|
||||
order = [
|
||||
"...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())]"
|
||||
" : []),",
|
||||
" summary,",
|
||||
" ...(extras ? [extras.옵션, extras.도면] : []),",
|
||||
]
|
||||
assert "\n".join(order) in side
|
||||
# 자재 조합을 열면 이 줄 · 일위대가 고름 지움 / 이 줄을 열면 자재 조합 · 일위대가 고름 지움
|
||||
assert "onOpen: () => (options.clear(), labor?.clear(), paint())" in side
|
||||
assert "onOpen: () => (matset.clear(), labor?.clear(), paint())" in side
|
||||
# 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 → 실무문서
|
||||
order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0]
|
||||
assert (
|
||||
order.index("summary") < order.index("extras.옵션, extras.도면") < order.index("practice")
|
||||
)
|
||||
# 자재 조합을 열면 이 줄 · 실무문서 · 「전체 항목」 고름 지움 / 이 줄을 열면 자재 조합 · … 지움
|
||||
assert "onOpen: () => (options.clear(), practice?.clear(), (allPicked = null), paint())" in side
|
||||
assert "onOpen: () => (matset.clear(), practice?.clear(), (allPicked = null), paint())" in side
|
||||
paint = side.split("function paint(): void {")[1].split("\n }\n")[0]
|
||||
assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움
|
||||
api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8")
|
||||
|
||||
@@ -154,9 +154,16 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우
|
||||
assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만
|
||||
assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만
|
||||
assert "i.이름 === STRUCTURE_TABLE" in side # 표는 구조물집계표 한 줄만
|
||||
# 구조물 도면 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘)
|
||||
# 구조물 도면 · 일위대가 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘) ·
|
||||
# 층 단추 · 나무와 하위 사이 수평선(39-2)
|
||||
summary = side.split("const summary = section(")[1].split(");")[0]
|
||||
assert "COLUMN_KINDS.map" in summary and "matsetSection" in summary
|
||||
assert (
|
||||
summary.index("opt.tree")
|
||||
< summary.index("m02-side__rule")
|
||||
< summary.index("...columnSections")
|
||||
)
|
||||
assert "matsetSection" in summary
|
||||
assert "const columnSections = COLUMN_KINDS.map(" in side
|
||||
assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a)
|
||||
ROOT / "ui_template" / "ui_template_locale_m2.ts"
|
||||
)
|
||||
@@ -184,7 +191,11 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐():
|
||||
"""카드 [도면 수정] 등 무엇으로 열든 편집 칸이 좌측 `paint` 를 부름 — 목록 · 열 줄을 함께 그림."""
|
||||
side = _text(SIDE)
|
||||
assert "picked" not in side # 따로 든 고름 상태 없음 — 편집 칸의 지금 열린 것만 봄
|
||||
assert re.search(r"function paint\(\): void \{\s*paintList\(\);\s*paintColumns\(\);", side)
|
||||
assert re.search(
|
||||
r"function paint\(\): void \{\s*if \(opt\.getOpen\(\)\) allPicked = null;\s*"
|
||||
r"paintList\(\);\s*paintColumns\(\);",
|
||||
side,
|
||||
) # 양식이 열리면 「전체 항목」 고름도 지움(39-2)
|
||||
for fn in ("async function select(", "async function selectColumn("):
|
||||
body = side.split(fn)[1].split("\n }\n")[0]
|
||||
assert "paint" not in body, fn # 여기서 한쪽만 그리면 두 줄이 남음
|
||||
@@ -192,8 +203,8 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐():
|
||||
# 자재 조합 상세도 같은 한 곳 — 열면 양식 · 열 줄 고름 지움 · 양식을 열면 자재 조합 고름 지움
|
||||
assert re.search(r"sel = null;\s*onOpened\?\.\(\);", _text(MAIN))
|
||||
assert "if (opt.getOpen()) matset.clear();" in side.split("function paint(): void {")[1]
|
||||
# 편집 칸이 열 때마다 좌측 `paint`(27-2b 뒤 = 일위대가 화면 해시 맞춤도 같은 자리)
|
||||
assert re.search(r"\(\) => \{\s*side\.paint\(\);", _text(M02 / "M02_MasterTemplete_UI_Page.ts"))
|
||||
# 편집 칸이 열 때마다 좌측 `paint`(일위대가 화면 해시 맞춤은 39-2 에서 지움)
|
||||
assert "() => side.paint()," in _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
|
||||
|
||||
def test_리본_접힘_모드에서_탭으로_편_것은_밖을_누르면_다시_접힘():
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi.testclient import TestClient
|
||||
from common_util import common_util_storage
|
||||
from common_util.common_util_auth import verify_session
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as router_module
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.helper_m02_master_sample import sample_table
|
||||
@@ -135,15 +136,23 @@ def test_회사_공식_저장은_관리자만_적용은_작업본만(world: dict
|
||||
assert layers.read_manifest(root2 / "templates")["table/구조물집계표"]["층"] == "company"
|
||||
|
||||
|
||||
def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(world: dict[str, Any]) -> None:
|
||||
def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(
|
||||
world: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
client, session = world["client"], world["session"]
|
||||
notes: list[str] = []
|
||||
|
||||
async def note(_session: dict[str, Any], got: Any) -> None:
|
||||
notes.append(got.owner)
|
||||
|
||||
monkeypatch.setattr(items_router, "_note", note)
|
||||
body = {"to": "personal", "종류": "table", "이름": "구조물집계표"}
|
||||
assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 200
|
||||
mine = f"/api/m02/layers/personal/templates/table/구조물집계표?project_id={P1}"
|
||||
assert client.get(mine).status_code == 200
|
||||
session.update(user_id=43)
|
||||
theirs = mine + "&user_id=42"
|
||||
# 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만 읽기 · 쓰기는 본인만
|
||||
# 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만(맡은 회사 · 39-6)
|
||||
assert client.get(theirs).status_code == 403
|
||||
session["role"] = "ADMIN"
|
||||
assert client.get(theirs).status_code == 200
|
||||
@@ -152,8 +161,9 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor
|
||||
f"/api/m02/layers/personal/templates/table/내표?project_id={P1}&user_id=42",
|
||||
json={"판": None, "문서": {"열": []}},
|
||||
)
|
||||
assert put.status_code == 403
|
||||
assert not (world["storage"] / "7/42/templates/table/내표.json").exists()
|
||||
assert put.status_code == 200 # 회사 관리자 = 같은 회사 개인 고침 · 시스템 로그
|
||||
assert (world["storage"] / "7/42/templates/table/내표.json").is_file()
|
||||
assert notes == ["7/42"]
|
||||
session["role"] = "USER"
|
||||
# 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀
|
||||
put = client.put(
|
||||
@@ -165,7 +175,12 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor
|
||||
got = client.post(
|
||||
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42}
|
||||
)
|
||||
assert got.status_code == 200
|
||||
assert got.status_code == 403 # 일반 = 남 개인 못 가져옴(39-6)
|
||||
session["role"] = "ADMIN"
|
||||
got = client.post(
|
||||
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42}
|
||||
)
|
||||
assert got.status_code == 200 # 회사 관리자 = 같은 회사 개인 가져옴
|
||||
assert client.get(mine.replace(P1, P_OTHER)).status_code == 403
|
||||
far = client.post(
|
||||
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 90}
|
||||
@@ -186,6 +201,15 @@ def test_가져오기는_같은_회사_프로젝트만_목록도(world: dict[str
|
||||
sources = client.get(f"/api/m02/projects/{P1}/sources").json()
|
||||
assert [row["project_id"] for row in sources["project"]] == [P2]
|
||||
assert {row["이름"] for row in sources["system"]} == {"구조물집계표", "A1_도각"}
|
||||
for user in (42, 43): # 개인 층 둘 — 일반은 내 것만 · 회사 관리자는 회사 사람 전부(39-6)
|
||||
layers.write_template(layers.personal_dir(7, user), "table", "표", {"열": []})
|
||||
assert [row["user_id"] for row in sources_of(client)] == [42]
|
||||
world["session"]["role"] = "ADMIN"
|
||||
assert [row["user_id"] for row in sources_of(client)] == [42, 43]
|
||||
|
||||
|
||||
def sources_of(client: TestClient) -> list[dict[str, Any]]:
|
||||
return client.get(f"/api/m02/projects/{P1}/sources").json()["personal"]
|
||||
|
||||
|
||||
def test_시스템_층은_읽기만_이름은_막음(world: dict[str, Any]) -> None:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""스프레드시트 손잡이 `setLink` — 알약 키를 칸에 잇기 · 풀기(PLAN 39-4 D4).
|
||||
|
||||
순수 길(`spreadsheet_link_set.ts` · DOM 모름)을 vite `--ssr` 로 번들해 Node 로 돌림 —
|
||||
새 연결 · 옮김 · 칸 하나 = 키 하나 · 시트 넘어 옮김 · 풀기 · 되돌리기 · 바뀔 것 없음 · 틀린 주소.
|
||||
손잡이 몸(`spreadsheet.ts`)은 글로 확인 — 화면 조작은 ORCA.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_link_set_entry.ts"
|
||||
VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_setLink_순수_길():
|
||||
with tempfile.TemporaryDirectory(prefix="spreadsheet_link_set_") as outdir:
|
||||
entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/")
|
||||
build = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
str(VITE),
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
entry_rel,
|
||||
"--outDir",
|
||||
outdir,
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=120,
|
||||
)
|
||||
assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}"
|
||||
run = subprocess.run(
|
||||
["node", str(Path(outdir) / "spreadsheet_link_set_entry.js")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=60,
|
||||
)
|
||||
assert run.stdout.strip(), run.stderr
|
||||
result = json.loads(run.stdout)
|
||||
assert result["fail"] == 0, result["failures"]
|
||||
assert result["pass"] == 13
|
||||
|
||||
|
||||
def test_손잡이_setLink_몸():
|
||||
src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8")
|
||||
assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src
|
||||
body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0]
|
||||
assert "if (readOnly) return false;" in body
|
||||
assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body
|
||||
assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님)
|
||||
assert "loadLinks();" in body
|
||||
Reference in New Issue
Block a user