Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-10-05 19:00:33 +09:00
14 changed files with 517 additions and 18 deletions
+17 -1
View File
@@ -6,7 +6,10 @@ import { createSpreadsheet } from "../../../A00_Common/spreadsheet/spreadsheet";
import type { LinkMaterial, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet";
const w = window as unknown as Record<string, unknown>;
const mode = new URLSearchParams(location.search).get("mode") === "자재" ? "자재" : "입력";
const q = new URLSearchParams(location.search);
const mode = q.get("mode") === "자재" ? "자재" : "입력";
// ?ghost=1 — 목록 확정 + 목록에 없는 연결 알약 X(B1) · 목록 알약 L(B2) / ?range=1 — 도면 보낸 범위 A1:B2
const ghost = q.get("ghost") === "1";
const book: Workbook = {
종류: "통합문서",
@@ -29,6 +32,11 @@ const book: Workbook = {
],
활성: "s1",
};
if (ghost)
book.시트[0].연결 = [
{ 키: "X", 칸: "B1", 이름: "지운로직", 단위: "m" },
{ 키: "L", 칸: "B2", 이름: "길이", 단위: "m" },
];
const MATERIALS: LinkMaterial[] = [
{ 코드: "M001", 이름: "레미콘", 규격: "25-21-12", 단위: "m³" },
@@ -59,6 +67,14 @@ w.ss = createSpreadsheet(document.getElementById("host")!, book, {
{ 키: "H", 이름: "높이", 단위: "m" },
{ 키: "A", 이름: "면적", 단위: "m²" },
],
...(ghost ? { 확정: true } : {}),
...(q.get("range") === "1"
? {
표시범위: [
{ 시트: "s1", 범위: "A1:B2", 이름: "T1 도면", 설명: "도면 표 T1 로 보낸 범위" },
],
}
: {}),
onItemsChange: (목록) => (w.ssItems as unknown[]).push(목록),
자재찾기: async (글) => {
asked.push(글);
+43
View File
@@ -61,3 +61,46 @@ def test_tables_split_and_br() -> None:
tables = [b["table"] for b in one["blocks"] if "table" in b]
assert [len(t[0]) for t in tables] == [7, 2]
assert tables[0][2][0] == "인력운반\n타설"
def test_markdown_stripped() -> None:
"""역슬래시 · 인용 `>` 머리 · 글 안 주석을 벗김(50-2) — 모든 정본 출처 원문에 0."""
bad = 0
for path in store.FOLDER.glob("로직_*품셈_*.json"):
for row in json.loads(path.read_text(encoding="utf-8"))["줄"]:
for one in make.source(row.get("출처", ""))["found"]:
for b in one["blocks"]:
t = b.get("p") or b.get("text") or ""
bad += "\\" in t or "<!--" in t or t.startswith(">")
assert bad == 0
one = make.source("산림품셈 3-4-2 1")["found"][0]
assert any(b.get("p") == "Q = 3,600 × q × K × f × E / Cm" for b in one["blocks"])
def test_item_marked() -> None:
"""출처 항 · 주 번호 → 그 조각만 hit(절은 통째) · 못 찾으면 item 없음."""
def hits(src: str) -> list[str]:
one = make.source(src)["found"][0]
return [(b.get("p") or b.get("text") or "table")[:2] for b in one["blocks"] if b.get("hit")]
one = make.source("산림품셈 3-4-2 1")["found"][0]
assert one["item"] == "1" and one["blocks"][0].get("hit") is None
assert not any(b.get("hit") for b in one["blocks"] if b.get("p", "").startswith("2."))
assert hits("산림품셈 3-4-2 1")[0] == "1."
assert hits("산림품셈 9-7-1 주⑥") == ["⑥ "]
assert make.source("산림품셈 13-10-2 2")["found"][0]["item"] == "2"
assert "item" not in make.source("산림품셈 3-4-2")["found"][0]
def test_item_all_marked() -> None:
"""항 · 주 번호가 달린 정본 출처는 전부 표시가 붙음(전수)."""
total = missing = 0
for path in store.FOLDER.glob("로직_*품셈_*.json"):
for row in json.loads(path.read_text(encoding="utf-8"))["줄"]:
src = row.get("출처", "")
if not any(i for _n, i in make._NUMBER.findall(src)):
continue
total += 1
missing += not any(f.get("item") for f in make.source(src)["found"])
assert total >= 30 and missing == 0, (total, missing)
+35
View File
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
"""50-1 면적산출 알약 · 보낸 범위 표시 — 원문 규칙 점검(화면 수치는 ui_checks/50-1_*.js 를 ORCA 로)."""
import os
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, "..", "..")
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as f:
return f.read()
def test_orphan_mark_only_when_list_confirmed():
# ⚠ 는 호스트가 목록을 확정했을 때만 — 알약 받기 실패(확정 없음)는 표시 안 함
links = _read("A00_Common", "spreadsheet", "spreadsheet_links.ts")
assert 'opts.모드 === "입력" && opts.확정 === true' in links
structure = _read("M02_MasterTemplete", "M02_MasterTemplete_Structure.ts")
assert "확정: true" in structure
assert structure.count("확정: true") == 1 # 실패 길(none)은 확정 없음
def test_cell_pick_highlights_pill_and_range_is_unfilled():
links = _read("A00_Common", "spreadsheet", "spreadsheet_links.ts")
assert "is-here" in links and "scrollIntoView" in links
css = _read("A00_Common", "spreadsheet", "spreadsheet_links.css")
block = css.split(".ss-link-range {", 1)[1].split("}", 1)[0]
assert "dashed" in block and "background" not in block # 채움 없음 = 알약 색과 안 겹침
def test_sent_ranges_from_same_column_drawing():
src = _read("M02_MasterTemplete", "M02_MasterTemplete_AreaToDrawing.ts")
assert 'readTemplate(at.layer, "structure", at.name, at.projectId)' in src
assert '"{id} 도면"' in src
@@ -0,0 +1,42 @@
/* 50-1 면적 시트 「도면으로 보낸 범위」 읽기(`sentRanges`) — orca eval 한 번(서버 쓰기 없음 · 가짜 응답).
* 시작 화면: 로그인한 M02(#/m02-master-template?open=구조물조합&root=master) 아무 화면.
* 하는 일: 도면 읽기 길만 가짜 연결표(T1 · T3)로 바꿔 부름 → 그 답 · 실패 때(500) 빈 목록 → fetch 되돌림.
* 기대(out): rows = 2 · tag = ["T1 도면","T3 도면"] · tip0 = 「도면 표 T1 로 보낸 범위 A1:B2 · 글자 2.5」 ·
* sheet0 = 가짜 시트 id · failed = [] .
*/
(async () => {
const mod =
await import("/@fs/C:/Aislo_wt/sub_laptop_3/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts");
const real = window.fetch;
const at = { layer: "system", projectId: null, kind: "area", name: "item1", label: "item1" };
const out = {};
try {
window.fetch = async (url, init) => {
if (!String(url).includes("/templates/structure/item1")) return real(url, init);
return new Response(
JSON.stringify({
종류: "structure",
이름: "item1",
판: "1",
문서: {
연결표: [
{ 연결: "T1", 시트: "s9", 범위: "A1:B2", 글자크기: 2.5, 값: {} },
{ 연결: "T3", 시트: "s9", 범위: "C3:D5", 글자크기: 3, 값: {} },
],
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
};
const rows = await mod.sentRanges(at);
out.rows = rows.length;
out.tag = rows.map((r) => r.이름);
out.tip0 = rows[0]?.설명;
out.sheet0 = rows[0]?.시트;
window.fetch = async () => new Response("{}", { status: 500 });
out.failed = await mod.sentRanges(at);
} finally {
window.fetch = real;
}
return JSON.stringify(out);
})();
@@ -0,0 +1,52 @@
/* 50-1 면적산출 알약 · 칸 · 보낸 범위 표시 — orca eval 한 번에 도는 시나리오(저장 · 서버 없음).
* 시작 화면: /@fs/C:/Aislo_wt/sub_laptop_3/resources/tester/spreadsheet/harness_links.html?ghost=1&range=1 (알약 L · H · A · 연결 X=B1 · L=B2 · 범위 A1:B2).
* 하는 일: ① 알약 줄 ② 보낸 범위 상자 ③ 칸 B1 · B2 를 눌러 그 칸 알약 강조 ④ X 의 [×] 로 연결 풀기
* 기대(out): orphan = X 만 ⚠ · 목록 알약 L 은 아님 · rangeTag "T1 도면" · rangeFill 투명 · rangeDashed true ·
* hereB1 = ["X"] · hereB2 = ["L"] · popOnce true(칸이 바뀐 첫 그림만) · xGone true.
*/
(async () => {
const W = (t) => new Promise((r) => setTimeout(r, t));
const q = (s) => document.querySelector(s);
const all = (s) => [...document.querySelectorAll(s)];
const click = (el) => {
const r = el.getBoundingClientRect();
const at = {
bubbles: true,
button: 0,
clientX: r.left + r.width / 2,
clientY: r.top + r.height / 2,
};
const t = document.elementFromPoint(at.clientX, at.clientY) ?? el;
for (const type of ["mousedown", "mouseup", "click"]) t.dispatchEvent(new MouseEvent(type, at));
};
await W(800);
const out = {};
const pill = (k) => q(`.ss-link[data-key="${k}"]`);
out.orphan = all(".ss-link.is-orphan").map((b) => b.dataset.key);
out.xText = pill("X")?.textContent;
out.lOrphan = pill("L")?.classList.contains("is-orphan");
const range = q(".ss-link-range");
const cs = range && getComputedStyle(range);
out.rangeTag = range?.querySelector(".ss-link-range__tag")?.textContent;
out.rangeFill = cs?.backgroundColor;
out.rangeDashed = cs?.borderTopStyle === "dashed";
out.rangeSize = range && [Math.round(range.offsetWidth), Math.round(range.offsetHeight)];
const byKey = (k) => all(".ss-link-cell").find((t) => t.dataset.key === k); // 칸 표시는 고를 때마다 새로 얹힘
out.cellOrphan = byKey("X")?.classList.contains("is-orphan");
const here = () => all(".ss-link.is-here").map((b) => b.dataset.key);
click(byKey("X")); // B1
await W(300);
out.hereB1 = here();
out.popOnce = all(".ss-link.is-pop").length === 1;
click(byKey("L")); // B2
await W(300);
out.hereB2 = here();
const doc0 = window.ss.getDoc();
const n0 = doc0.시트[0].연결.length;
click(pill("X").querySelector(".ss-link__x"));
await W(300);
const n1 = window.ss.getDoc().시트[0].연결.length;
out.xGone = n1 === n0 - 1 && !pill("X");
out.xCell = !all(".ss-link-cell").some((t) => t.dataset.key === "X");
return out;
})();
@@ -0,0 +1,71 @@
/* 50-2 원문 패널 — 마크다운 기호 벗김 · 출처 항 · 주 강조 · `orca eval --expression "$(cat 이 파일)"` 한 번에 돎 · JSON 돌려줌.
*
* 시작 화면: M01 마스터 데이터(#/m01-master-data · 시스템 관리자 로그인) · 아무 화면이나.
* 로직 GF000003(산림품셈 3-4-2 1) · GF000094(산림품셈 9-7-1 주⑥)을 엶 · 고치지 않음 · 패널 취향을 처음 값으로 되돌림.
* 기대 수치:
* item1 = {head:"3-4-2. …", file:"1항", hit:12, first:"1. 단가산출 방법은 …", nextNotHit:true, bad:0(역슬래시 · 「<!--」 · 「>」 머리 글 수), inView:true}
* note6 = {file:"주⑥", hit:1, first:"⑥ 굴착기의 작업능력 …", nextNotHit:true, bad:0, inView:true}
*/
(async () => {
const W = (ms) => new Promise((r) => setTimeout(r, ms));
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
const until = async (f, ms = 15000) => {
for (let t = 0; t < ms; t += 200) {
const v = f();
if (v) return v;
await W(200);
}
return null;
};
const PREF = "aislo:pref:m01-logic-side";
const first = localStorage.getItem(PREF);
const out = {};
const openLogic = async (key) => {
sessionStorage.setItem("m01.logic.pending", key);
const tab = $$(".ui-collapsible__title").find((h) => h.innerText.trim() === "단가산출 로직");
if (!tab) return false;
if (!tab.closest(".ui-collapsible").classList.contains("is-collapsed")) tab.click();
tab.click();
return until(() => {
const t = $(".m02-dfp__title")?.innerText ?? "";
return !$(".m01lab-side")?.hidden && $(".m01lab-side .m02-dfp") && t;
});
};
const shown = (n) => !!n && n.getBoundingClientRect().width > 0;
const panel = (id) => $(`.m01lab-side .ui-side-panels__panel[data-panel="${id}"]`);
const strip = (title) =>
$$(".m01lab-side .ui-side-panels__handles > *").find(
(s) => s.innerText.replace(/\s/g, "") === title,
);
const inView = (n) => {
const r = n.getBoundingClientRect();
return r.top >= 0 && r.bottom <= innerHeight && r.height > 0;
};
const read = async (key, wantFile) => {
if (!(await openLogic(key))) return { error: "로직 못 엶" };
await W(600);
if (!shown(panel("source"))) strip("원문")?.click();
await until(() => $$(".m01lab-src__file").some((f) => f.innerText.includes(wantFile)), 6000);
await W(600);
const ps = $$(".m01lab-src__body > *");
const hits = $$(".m01lab-src__hit");
const texts = $$(".m01lab-src__p, .m01lab-src__head").map((n) => n.innerText);
return {
head: $(".m01lab-src__head")?.innerText,
file: $(".m01lab-src__file")?.innerText.split(" · ").pop(),
hit: hits.length,
first: hits[0]?.innerText.slice(0, 20),
nextNotHit: !hits.includes(ps[ps.indexOf(hits[hits.length - 1]) + 1]),
bad: texts.filter((t) => /\|<!--|^>/.test(t)).length,
inView: hits.length > 0 && inView(hits[0]),
};
};
if (!(await openLogic("GF000003"))) return JSON.stringify({ error: "로직 못 엶" });
out.item1 = await read("GF000003", "1항");
out.note6 = await read("GF000094", "주⑥");
// 되돌림 — 처음 취향
if (first === null) localStorage.removeItem(PREF);
else localStorage.setItem(PREF, first);
return JSON.stringify(out);
})();