From d94f3f78c19cb0dbea53633a448d0a9ef5c10655 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 23:49:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui=5Ftemplate):=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EB=AA=A8=EB=8B=AC=20=E2=80=94=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=C2=B7=20=EA=B8=80=EC=9E=90=20=EB=8C=80?= =?UTF-8?q?=EB=B9=84=20=C2=B7=20=EC=83=88=EB=A1=9C=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=AC=20=EB=95=8C=20=EB=84=A3=EC=9D=80=20=EA=B0=92=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=20=C2=B7=20=ED=8E=B8=EC=A7=91=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=86=92=EC=9D=B4=20(PLAN=2025-2d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_item_picker_detail: 복사본 창구(가짜 · 약속 길 · 403 까닭) · 입력 목록 · 카드 순수 함수 · 한 벌 지킴 · 원본 층에 안 씀 - 자동값 · 확인 표시 글자 대비 4.5 이상 · 고르기 값은 원래 모양(숫자)으로 저장 - 새로 가져오기 = 원본에서 온 문서만 덮고 입력값은 남김 - 모달 폭 두 클래스로(공용 모달 폭에 안 밀림) · CAD · 시트 편집 높이 채움 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../M02_MasterTemplete_InputsCard.css | 20 +- .../M02_MasterTemplete_InputsCard.ts | 26 +- .../tester/helper_item_picker_project.cjs | 274 ++++++++++++++++++ resources/tester/test_item_picker_detail.py | 166 +++++++++++ ui_template/ui_template_item_picker.css | 33 ++- .../ui_template_item_picker_project.ts | 12 +- 6 files changed, 504 insertions(+), 27 deletions(-) create mode 100644 resources/tester/helper_item_picker_project.cjs create mode 100644 resources/tester/test_item_picker_detail.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css index f8561d0d..66fb2c30 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css +++ b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.css @@ -20,7 +20,7 @@ .m02-inputs__places, .m02-inputs__missing { margin: 0; - color: var(--color-text-muted); + color: var(--color-text-secondary); font-size: var(--text-caption); } @@ -63,31 +63,35 @@ .m02-inputs__control { flex: 1 1 6rem; min-width: 0; + max-width: 24rem; } -/* 자동값 = 저장값이 아님 — 흐리게 */ +/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ .m02-inputs__value.is-auto, -.m02-inputs__control.is-auto { - color: var(--color-text-muted); +.m02-inputs__control.is-auto, +.m02-inputs__control::placeholder { + color: var(--color-text-secondary); font-style: italic; + opacity: 1; } .m02-inputs__state { - color: var(--color-text-muted); + color: var(--color-text-secondary); font-size: var(--text-caption); white-space: nowrap; } .m02-inputs__state.is-confirmed { - color: var(--color-success, var(--color-text)); + color: var(--color-text); + font-weight: 600; } .m02-inputs__error { flex: 1 0 100%; - color: var(--color-danger, var(--color-text)); + color: var(--color-danger); font-size: var(--text-caption); } .m02-inputs__control[aria-invalid="true"] { - border-color: var(--color-danger, currentcolor); + border-color: var(--color-danger); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts index 9bcbcb51..ab480e5e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_InputsCard.ts @@ -73,7 +73,7 @@ const TEXT = { missing: "없는 로직 {value}", auto: "자동값", unconfirmed: "확인 안 함", - confirmed: "확인함", + confirmed: "✓ 확인함", confirmBox: "확인", outOfRange: "범위 {value} 밖", summary: "선택 {value}", @@ -94,13 +94,16 @@ export function rangeOf(input: LogicInput): { min?: number; max?: number } | nul return null; } -/** 고르기 목록 → 값 · 이름 짝 */ -export function choicesOf(input: LogicInput): { 값: string; 이름: string }[] { - return (input.고르기 ?? []).map((one) => - typeof one === "object" - ? { 값: String(one.값 ?? one.이름 ?? ""), 이름: String(one.이름 ?? one.값 ?? "") } - : { 값: String(one), 이름: String(one) }, - ); +/** 고르기 목록 → 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ +export function choicesOf(input: LogicInput): { 값: string; 이름: string; raw: string | number }[] { + return (input.고르기 ?? []).map((one) => { + const raw = typeof one === "object" ? (one.값 ?? one.이름 ?? "") : one; + return { + 값: String(raw), + 이름: typeof one === "object" ? String(one.이름 ?? raw) : String(one), + raw, + }; + }); } /** 자리가 「선택」 인 줄만 로직 차례로 묶음 — 이 카드가 보이는 입력 */ @@ -277,7 +280,12 @@ export function createInputsCard(opts: InputsCardOptions): InputsCardHandle { ? fill(TEXT.outOfRange, `${range?.min ?? ""}~${range?.max ?? ""}`) : ""; control.classList.toggle("is-auto", raw === ""); - const value: string | number | null = raw === "" ? null : range ? Number(raw) : raw; + const value: string | number | null = + raw === "" + ? null + : range + ? Number(raw) + : (choices.find((one) => one.값 === raw)?.raw ?? raw); // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) store(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad }); box.checked = raw !== "" && !bad; diff --git a/resources/tester/helper_item_picker_project.cjs b/resources/tester/helper_item_picker_project.cjs new file mode 100644 index 00000000..45749bfe --- /dev/null +++ b/resources/tester/helper_item_picker_project.cjs @@ -0,0 +1,274 @@ +/* 항목 고르기 모달 자료 창구 시험 헬퍼(계획서 25-2d) — `ui_template_item_picker_project.ts` 와 + * 선택 입력 카드의 순수 함수를 트랜스파일해 가짜 서버(fetch 대역) 위에서 돌린다. 결과는 JSON 한 벌. + * ① 가짜 복사본(서버 길 없음): 가져오기 · 고침 · 다시 읽기 · 마스터가 바뀜 · 새로 가져오기 · 원본 그대로 + * ② 실서버 모양(약속 길): 목록 · 읽기 · 고침 PUT · 권한 없음(403) 까닭 그대로 · 가져오기 · 새로 가져오기 + * ③ 입력 목록: 약속 길 그대로 · 없으면 M01 길로 짜 맞춤 · 가짜 연결(모두 선택) + * ④ 카드 순수 함수: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음 + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +const STUBS = { + "@config/config_frontend": { API_BASE_URL: "/api" }, + // 카드 순수 함수만 볼 것 — 화면 부품은 안 씀 + "@ui/ui_template_elements": { el: () => ({}), createButton: () => ({}) }, + "./M02_MasterTemplete_ItemCards": { itemCard: () => ({}), itemNote: () => ({}) }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +// ── 브라우저 대역 ───────────────────────────────────────────────────────────── +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => void store.set(k, String(v)), +}; + +/** 원본 층(시스템) — 열 rv 의 도면 · 면적 · 재료 · 판은 바뀔 수 있음 */ +const original = { + version: "v1", + docs: { + structure: { 양식: "구조물도면", 도번: "SD01", 도면: { entities: [1, 2] } }, + area: { 면적번: "SA01", 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, + material: { 재료번: "SM01", 시트: [{ id: "s", 칸: {} }] }, + }, +}; +const originalSnapshot = () => JSON.stringify(original); + +const reply = (status, body) => ({ + status, + ok: status >= 200 && status < 300, + json: async () => body, +}); + +/** 가짜 서버 — mode: "fake"(프로젝트 길 없음) | "real"(약속 길) */ +const state = { mode: "fake", readOnly: false, inputsRoute: false, calls: [], server: {} }; + +globalThis.fetch = async (url, init = {}) => { + const method = init.method ?? "GET"; + const body = init.body ? JSON.parse(init.body) : undefined; + state.calls.push({ method, url, body }); + const m = /^\/api\/m02\/templates\/(structure|area|material)\/(.+)$/.exec(url); + if (m) { + return reply(200, { + 종류: m[1], + 이름: decodeURIComponent(m[2]), + 판: original.version, + 문서: JSON.parse(JSON.stringify(original.docs[m[1]])), + }); + } + const projects = /^\/api\/projects\/([^/]+)\/items(?:\/([^/]+)(?:\/([^/]+))?)?$/.exec(url); + if (projects) { + if (state.mode === "fake") return reply(404, { detail: "Not Found" }); + const [, , folder, part] = projects; + if (!folder && method === "GET") + return reply( + 200, + Object.values(state.server).map((x) => x.info), + ); + if (!folder && method === "POST") { + const key = `${body.layer}_${body.owner ?? "none"}_${body.key}`; + state.server[key] = { + info: { 폴더: key, 원본: body, 원본판: original.version, 가져온날: "2026-09-29" }, + parts: { + 도면: JSON.parse(JSON.stringify(original.docs.structure)), + 면적: JSON.parse(JSON.stringify(original.docs.area)), + 재료: JSON.parse(JSON.stringify(original.docs.material)), + }, + }; + return reply(200, state.server[key].info); + } + const item = state.server[decodeURIComponent(folder)]; + if (!item) return reply(404, { detail: "없는 폴더" }); + if (part === "refresh" && method === "POST") { + item.parts.면적 = JSON.parse(JSON.stringify(original.docs.area)); + item.info.원본판 = original.version; + return reply(200, item.info); + } + if (method === "GET") { + const doc = item.parts[decodeURIComponent(part)]; + return doc === undefined ? reply(404, { detail: "부분 없음" }) : reply(200, doc); + } + if (method === "PUT") { + if (state.readOnly) return reply(403, { detail: "프로젝트 편집 권한이 없음" }); + item.parts[decodeURIComponent(part)] = body; + return reply(200, { ok: true }); + } + } + const inputs = /^\/api\/m02\/layers\/(\w+)\/items\/([^/]+)\/inputs/.exec(url); + if (inputs) { + return state.inputsRoute + ? reply(200, { 일위대가: "UAREAL", 이름: "실서버", 로직: [], 빠진로직: [] }) + : reply(404, { detail: "Not Found" }); + } + if (url.startsWith("/api/m01/combo?key=")) { + return reply(200, { + combo: { 이름: "기슭막이", 담은로직: [{ 로직: "GA1" }, { 로직: "GA2" }, { 로직: "GX" }] }, + }); + } + if (url.startsWith("/api/m01/logic/auto?key=")) { + return reply(200, { 값: { 지역: "전국평균", 소량감률: 0 } }); + } + const logic = /^\/api\/m01\/logic\?key=(\w+)/.exec(url); + if (logic) { + if (logic[1] === "GX") return reply(404, { detail: "없는 로직" }); + return reply(200, { + logic: { + 이름: `로직 ${logic[1]}`, + 결과단위: "원/m", + 입력: [ + { 이름: "지역", 고르기: ["전국평균", "서울"] }, + { 이름: "소량감률", 단위: "%", 범위: [0, 50] }, + ], + }, + }); + } + return reply(404, { detail: "Not Found" }); +}; + +const project = loadTs(path.join(ROOT, "ui_template", "ui_template_item_picker_project.ts")); +const card = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_InputsCard.ts")); + +const row = (over = {}) => ({ + key: "SS000001", + 열: "rv", + layer: "system", + owner: null, + 일위대가: "UA1", + ...over, +}); +const clone = (x) => JSON.parse(JSON.stringify(x)); + +async function main() { + const out = {}; + + // ① 폴더 이름 — 서버 `snapshot_folder_name` 과 같은 글 + out.folders = [ + project.folderOf(row()), + project.folderOf(row({ layer: "company", owner: "3", key: "SS000001" })), + project.folderOf(row({ layer: "personal", owner: "3/12", key: "SS000002" })), + ]; + + // ② 가짜 복사본 — 서버 길이 없을 때 + state.mode = "fake"; + const fake = project.projectItems("P-FAKE"); + const before = originalSnapshot(); + out.fake = { list: await fake.list() }; + out.fake.isFake = fake.isFake(); + const info = await fake.importItem(row()); + out.fake.info = info; + out.fake.readBack = await fake.read(info.폴더, "면적"); + await fake.write(info.폴더, "면적", { + ...out.fake.readBack, + 시트: [{ id: "s", 칸: { H10: { 값: 12345 } } }], + }); + await fake.write(info.폴더, "입력값", { GA1: { 지역: { 값: "서울", 확인: true } } }); + out.fake.afterWrite = await fake.read(info.폴더, "면적"); + out.fake.listAfter = (await fake.list()).map((one) => one.폴더); + out.fake.changedNow = await fake.masterChanged(row(), info); + original.version = "v2"; + out.fake.changedAfterMaster = await fake.masterChanged(row(), info); + await fake.refresh(row(), info); + out.fake.afterRefresh = await fake.read(info.폴더, "면적"); + out.fake.valuesKept = await fake.read(info.폴더, "입력값"); + out.fake.changedAfterRefresh = await fake.masterChanged(row(), (await fake.list())[0]); + out.fake.originalUntouched = before.replace('"v1"', '"v2"') === originalSnapshot(); + out.fake.storageKeys = [...store.keys()]; + await fake.write("없는폴더", "면적", {}).then( + () => (out.fake.writeMissing = "성공"), + (error) => (out.fake.writeMissing = error.message), + ); + original.version = "v1"; + + // ③ 실서버 모양 — 약속 길 + state.mode = "real"; + state.calls = []; + const real = project.projectItems("P-REAL"); + out.real = { list: await real.list(), isFake: real.isFake() }; + const rinfo = await real.importItem(row({ layer: "personal", owner: "1/3" })); + out.real.info = rinfo; + out.real.importCall = state.calls.find((c) => c.method === "POST"); + out.real.read = await real.read(rinfo.폴더, "면적"); + out.real.readMissing = await real.read(rinfo.폴더, "고른자재"); + await real.write(rinfo.폴더, "입력값", { L: { 이름: { 값: 1, 확인: true } } }); + out.real.putCall = state.calls.filter((c) => c.method === "PUT").map((c) => c.url); + state.readOnly = true; + await real.write(rinfo.폴더, "면적", {}).then( + () => (out.real.readOnlyWrite = "성공"), + (error) => (out.real.readOnlyWrite = error.message), + ); + state.readOnly = false; + await real.refresh(row({ layer: "personal", owner: "1/3" }), rinfo); + out.real.refreshCall = state.calls + .filter((c) => /refresh$/.test(c.url)) + .map((c) => `${c.method} ${c.url}`); + out.real.changedFlag = await real.masterChanged(row(), { ...rinfo, 마스터바뀜: true }); + + // ④ 입력 목록 + state.inputsRoute = false; + const madeUp = await project.loadInputs(row({ key: "SS000009" })); + out.inputs = { + fake: madeUp.fake, + logics: madeUp.list.로직.map((l) => [l.로직, l.입력.map((i) => i.이름)]), + missing: madeUp.list.빠진로직, + auto: madeUp.list.로직[0].입력.map((i) => i.자동값), + link: project.fakeLink(madeUp.list).줄.length, + linkPlaces: [...new Set(project.fakeLink(madeUp.list).줄.map((r) => r.자리))], + }; + state.inputsRoute = true; + const realInputs = await project.loadInputs(row({ key: "SS000010" })); + out.inputs.real = { fake: realInputs.fake, name: realInputs.list.이름 }; + + // ⑤ 카드 순수 함수 + out.card = { + range: [ + card.rangeOf({ 이름: "a", 범위: [0, 50] }), + card.rangeOf({ 이름: "b", 범위: { 최소: 1, 최대: 2 } }), + card.rangeOf({ 이름: "c" }), + ], + choices: card.choicesOf({ 이름: "d", 고르기: [15, "16", { 값: 18, 이름: "열여덟" }] }), + keys: [card.inputKey("GA1", "지역"), card.pillKey("GA1", "지역")], + groups: card + .pickedGroups(madeUp.list, { + 판: "x", + 줄: [ + { 로직: "GA1", 입력: "지역", 자리: "선택" }, + { 로직: "GA1", 입력: "소량감률", 자리: "면적" }, + { 로직: "GA2", 입력: "소량감률", 자리: "선택" }, + ], + }) + .map((g) => [g.logic.로직, g.inputs.map((i) => i.이름)]), + noLink: card.pickedGroups(madeUp.list, null).length, + }; + return out; +} + +main().then( + (out) => process.stdout.write(JSON.stringify(out)), + (error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }, +); diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py new file mode 100644 index 00000000..7becafeb --- /dev/null +++ b/resources/tester/test_item_picker_detail.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +"""항목 고르기 모달 상세(계획서 25-2d) — 자료 창구 · 선택 입력 카드 · 한 벌 지킴. + +① 프로젝트 복사본 창구(`ui_template_item_picker_project.ts`)를 Node 로 돌려(가짜 서버 fetch 대역) 본다: + 서버 길이 없을 때의 가짜 복사본(가져오기 · 고침 · 마스터가 바뀜 · 새로 가져오기 · **원본 그대로**) · + 약속 길(목록 · 읽기 · PUT · 403 까닭 그대로 · 가져오기 · 새로 가져오기) · 입력 목록(약속 길 · M01 로 짜 맞춤). +② 모달이 카드 · 편집기를 M02 와 같은 한 벌로 쓰는지, 원본 층에 쓰는 길을 안 부르는지 소스로 지킨다. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +from common_util.common_util_summary_item import snapshot_folder_name + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] +UI = ROOT / "ui_template" +M02 = ROOT / "M02_MasterTemplete" + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_item_picker_project.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_폴더_이름은_서버_snapshot_folder_name_과_같은_글(out): + rows = [ + {"layer": "system", "owner": None, "key": "SS000001"}, + {"layer": "company", "owner": "3", "key": "SS000001"}, + {"layer": "personal", "owner": "3/12", "key": "SS000002"}, + ] + assert out["folders"] == [snapshot_folder_name(row) for row in rows] + + +def test_가짜_복사본_가져와_고치고_다시_읽으면_고친_값(out): + fake = out["fake"] + assert fake["isFake"] is True and fake["list"] == [] # 서버 길이 없음 → 가짜 + assert fake["info"]["폴더"] == "system_none_SS000001" + assert fake["readBack"]["시트"][0]["칸"] == {"A1": {"값": 1}} # 가져온 그대로 + assert fake["afterWrite"]["시트"][0]["칸"] == {"H10": {"값": 12345}} # 고침이 남음 + assert fake["listAfter"] == ["system_none_SS000001"] + + +def test_가짜_복사본을_고쳐도_원본은_그대로(out): + assert out["fake"]["originalUntouched"] is True + assert out["fake"]["storageKeys"] == ["aislo.itemsFake.P-FAKE"] # 브라우저 저장소 한 칸만 + + +def test_마스터가_바뀌면_표시_새로_가져오면_원본으로_덮고_넣은_값은_남김(out): + fake = out["fake"] + assert fake["changedNow"] is False + assert fake["changedAfterMaster"] is True + assert fake["afterRefresh"]["시트"][0]["칸"] == {"A1": {"값": 1}} # 고침이 원본으로 덮임 + assert fake["valuesKept"]["GA1"]["지역"] == { + "값": "서울", + "확인": True, + } # 사용자가 넣은 값은 남음 + assert fake["changedAfterRefresh"] is False + + +def test_안_가져온_항목에_쓰면_까닭을_던짐(out): + assert out["fake"]["writeMissing"] == "프로젝트에 가져오지 않은 항목" + + +def test_약속_길_목록_읽기_고침_가져오기_새로_가져오기(out): + real = out["real"] + assert real["isFake"] is False # 길이 있으면 가짜를 안 탐 + assert real["importCall"] == { + "method": "POST", + "url": "/api/projects/P-REAL/items", + "body": {"layer": "personal", "owner": "1/3", "key": "SS000001"}, + } + assert real["readMissing"] is None # 부분이 없음 = null + assert len(real["putCall"]) == 1 and real["putCall"][0].endswith("/%EC%9E%85%EB%A0%A5%EA%B0%92") + assert real["refreshCall"][0].startswith("POST /api/projects/P-REAL/items/") + assert real["refreshCall"][0].endswith("/refresh") + assert real["changedFlag"] is True # 실서버는 `마스터바뀜` 을 그대로 씀 + + +def test_권한_없음은_서버_까닭_그대로(out): + assert out["real"]["readOnlyWrite"] == "프로젝트 편집 권한이 없음" + + +def test_입력_목록_약속_길이_있으면_그대로_없으면_M01_로_짜_맞춤(out): + inputs = out["inputs"] + assert inputs["real"] == {"fake": False, "name": "실서버"} + assert inputs["fake"] is True + # 로직마다 갈라 — 이름이 같아도 합치지 않음 · 없는 로직은 빠진로직 + assert inputs["logics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역", "소량감률"]]] + assert inputs["missing"] == ["GX"] + assert inputs["auto"] == ["전국평균", 0] + assert inputs["link"] == 4 and inputs["linkPlaces"] == ["선택"] # 가짜 연결 = 모두 선택 + + +def test_선택_입력_카드_순수_함수(out): + card = out["card"] + assert card["range"] == [{"min": 0, "max": 50}, {"min": 1, "max": 2}, None] + assert [c["raw"] for c in card["choices"]] == [15, "16", 18] # 저장은 원래 모양 + assert card["keys"] == ["GA1|지역", "입력|GA1|지역"] # 25-1b 키 모양 + assert card["groups"] == [["GA1", ["지역"]], ["GA2", ["소량감률"]]] # 자리 「선택」 만 + assert card["noLink"] == 0 # 연결이 없으면 아무것도 안 보임 + + +def test_모달은_M02_와_같은_카드_한_벌을_씀(): + cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8") + structure = (M02 / "M02_MasterTemplete_Structure.ts").read_text(encoding="utf-8") + detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8") + assert "createItemCards(" in structure and "mountDrawingTemplate(" not in structure + assert 'import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards")' in detail + assert "createItemCards(" in detail + # 카드를 모달에서 또 그리지 않음 — 미리보기 부품은 카드 파일 한 곳 + for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker.ts"): + text = (UI / name).read_text(encoding="utf-8") + assert "mountDrawingTemplate(" not in text and "createSpreadsheet(" not in text, name + assert cards.count("mountDrawingTemplate(") == 1 + + +def test_모달은_원본_층에_쓰는_길을_안_부름(): + """마스터 · 회사 · 개인 원본은 안 바뀜 — 저장 길은 프로젝트 복사본(`items.write`)뿐.""" + for name in ( + "ui_template_item_picker_detail.ts", + "ui_template_item_picker_edit.ts", + "ui_template_item_picker_project.ts", + ): + text = (UI / name).read_text(encoding="utf-8") + for forbidden in ( + "saveTemplate(", + "deleteTemplate(", + "deleteLayerTemplate(", + "copyToPersonal(", + ): + assert forbidden not in text, f"{name} 이 {forbidden} 를 부름" + edit = (UI / "ui_template_item_picker_edit.ts").read_text(encoding="utf-8") + assert edit.count("items.write(") >= 3 # 도면 · 시트 · 입력값 모두 복사본으로 + + +def test_B05_구조물_폼이_projectId_를_모달에_넘김(): + panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text( + encoding="utf-8" + ) + assert re.search(r"projectId: sectionOptions\.projectId \?\? null", panel) + shared = (ROOT / "A00_Common" / "design" / "design_shared.ts").read_text(encoding="utf-8") + assert "projectId: shared.projectId" in shared + + +def test_소스가_700줄을_넘지_않음(): + for path in ( + *sorted(UI.glob("ui_template_item_picker*.ts")), + M02 / "M02_MasterTemplete_ItemCards.ts", + M02 / "M02_MasterTemplete_ItemSource.ts", + M02 / "M02_MasterTemplete_InputsCard.ts", + ): + assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css index 508023a2..4d8d73dd 100644 --- a/ui_template/ui_template_item_picker.css +++ b/ui_template/ui_template_item_picker.css @@ -1,5 +1,6 @@ /* 항목 고르기 모달 — 왼쪽 나무 · 오른쪽 폴더 모양 · 아래 단추 */ -.ui-item-picker { +/* 공용 모달(.ui-modal)의 폭 · 높이보다 앞서게 — 불러오는 차례에 따라 뒤집히지 않도록 두 클래스 */ +.ui-modal.ui-item-picker { width: min(1320px, 96vw); max-height: 94vh; } @@ -141,22 +142,40 @@ .ui-item-picker__edit-sheet, .ui-item-picker__edit-cad { + display: flex; flex: 1; min-width: 0; min-height: 0; } -.ui-item-picker__edit-inputs { - flex: 1; - min-width: 0; - padding: var(--spacing-8); - overflow: auto; +/* 도면 부품(.m02-drawing)이 남는 높이를 채움 — M02 메인 칸과 같은 자리 규칙 */ +.ui-item-picker__edit-cad > .m02-drawing { + flex: 1 1 0; } -.ui-item-picker__edit-inputs .m02-struct__card { +.ui-item-picker__edit-sheet > .ss { + flex: 1 1 0; + min-width: 0; +} + +.ui-item-picker__edit-inputs { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + padding: var(--spacing-8); +} + +.ui-item-picker__edit-inputs > .m02-struct__card { + flex: 1 1 0; border: 0; } +.ui-item-picker__edit-inputs .m02-inputs__wrap { + min-height: 0; +} + .ui-item-picker__name { margin: 0; font-size: var(--text-body); diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index 4ad1da45..d06fd77f 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -45,7 +45,7 @@ export interface ProjectItemInfo { /** `{층}_{주인 슬러그}_{키}` — 서버 `snapshot_folder_name` 과 같은 글(owner 없음 = none · `/` = `-`) */ export const folderOf = (row: PickerRow): string => - `${layerOf(row)}_${(ownerOf(row) ?? "none").replace("/", "-")}_${row.key}`; + `${layerOf(row)}_${(ownerOf(row) ?? "none").replace(/\//g, "-")}_${row.key}`; interface Reply { status: number; @@ -207,8 +207,14 @@ export function projectItems(projectId: string): ProjectItems { if (reply.status >= 400) throw new Error(reasonOf(reply)); return reply.body as ProjectItemInfo; } - delete fakeLoad(projectId)[info.폴더]; - return client.importItem(row); + // 원본에서 온 문서(도면 · 면적 · 재료)만 새로 덮음 — 사용자가 넣은 값(입력값 · 고른자재)은 그대로 + const kept = fakeLoad(projectId)[info.폴더]?.parts; + const fresh = await client.importItem(row); + const item = fakeLoad(projectId)[fresh.폴더]; + for (const part of ["입력값", "고른자재"] as const) + if (kept?.[part] !== undefined && item) item.parts[part] = kept[part]; + fakeSave(projectId); + return fresh; }, masterChanged: async (row, info) => { if (!fake) return info.마스터바뀜 === true;