feat(m02): 일위대가 옵션 줄 = 구분 · 이름 · 별칭 병합 칸 · 나머지 칸 두 줄 병합(113-1)

- 옵션 글 있는 로직만 위 줄 차례 · 수량산출 · 단위 · 금액 넷 · 순서 칸 rowspan 2 · 옵션 줄 colspan 3
- CSS 아래선 · hover · 고름 띠 · 끌기 아래선 맞춤 · 좁은 칸 그대로
- ui_checks 44-6 기대 colspan 3 · 45-7 rowspan 셈 · card_shell 시험 하나

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
This commit is contained in:
2026-10-08 18:22:52 +09:00
co-authored by Claude Opus 5.5
parent 5f64633df3
commit 4defeb529b
5 changed files with 59 additions and 26 deletions
+15
View File
@@ -163,3 +163,18 @@ def test_순서칸_2행_참조줄_옵션_수정():
assert "saveTemplate(layer, kind, opt.ref.키, null, 판, body)" in ref
assert 'mode: "precombo",' in ref and "showConfirmDialog" in ref
assert 'Labor_Edit: ["수정"' in _text("M02_MasterTemplete_Labor_Text.ts")
def test_옵션_줄_구분_이름_별칭_병합():
"""113-1 — 옵션 글 = 구분 · 이름 · 별칭 병합 칸(colspan 3) · 나머지 칸은 위 줄에서 rowspan 2 ·
옵션 없는 로직은 한 줄 · 아래선 · hover · 고름 띠 · 끌기 아래선 맞춤."""
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
assert "const SPAN = 3;" in rows
assert "if (!note) return [tr, ...subs];" in rows
assert '".m02-labrow__kind, .m02-labrow__name, .m02-labrow__alias"' in rows
assert 'td.setAttribute("rowspan", "2");' in rows
css = _text("M02_MasterTemplete_Labor.css")
assert ".m02-labrow__row:has(+ .m02-labrow__optrow) td:not([rowspan])" in css
assert ".m02-labrow__row.is-open:has(+ .m02-labrow__optrow:hover)" in css
assert "tr:has(+ tr.m02-labrow__optrow.is-dragunder) > td[rowspan]" in css
assert ".m02-labrow__optrow.is-selected > td:first-child" not in css
@@ -4,7 +4,7 @@
* 첫 로직 줄(GF000219 돌쌓기)에 고르기 입력이 있음 · 저장은 안 함(끝에 [다시 열기] → 확인 → 버림).
* 기대 수치:
* heads[2] = "별칭" · alias.inputs = 줄 수 · foot.totalBorder ≥ 1.5(2px · 화면 배율) · foot.totalBg ≠ 다른 금액 칸 배경
* opt.row = true · opt.colspan = 8(이름 ~ 합계) · opt.nowrap = "nowrap" · opt.ellipsis = "ellipsis" ·
* opt.row = true · opt.colspan = 3(구분 ~ 별칭 · 나머지 칸은 위 줄 rowspan 2 · 113-1) · opt.nowrap = "nowrap" · opt.ellipsis = "ellipsis" ·
* opt.height < 30(한 줄) · opt.mainGrow ≤ 2(옵션을 넣어도 위 줄 높이 그대로) · opt.title = 옵션 글 전체
* alias.noModal = true(칸 누름이 입력 모달을 안 엶) · alias.dirty = true · alias.kept = "시험별칭"(다시 그려도 남음)
* discarded = true(버린 뒤 별칭 빈 칸 · 옵션 줄 처음과 같음)
@@ -5,8 +5,8 @@
* 하는 일: item1 카드 표를 잼 → 일위대가 카드 [수정]으로 상세를 열어 잼 → item1 을 다시 눌러 카드 화면으로 돌아감(값 안 바꿈)
* 기대 수치(카드 · 상세 각각):
* - nameLines = 1(이름 칸 · 코드 · 규격 칸 글마다 줄 수 최대) · headLines = 1(머리 칸 글 줄 수 최대)
* - spanBad = 0(각 줄 칸 수 = 머리 칸 수 · colspan 셈)
* - broken = 0(칸 위·아래가 줄 위·아래와 어긋남 · 한 줄 안 아래 테두리 굵기가 다름 · 칸 사이 틈)
* - spanBad = 0(각 줄 칸 수 = 머리 칸 수 · colspan 셈 · 위 줄에서 내려온 rowspan 칸 포함 · 113-1)
* - broken = 0(칸 위·아래가 걸친 줄 위·아래와 어긋남 · 같은 줄에서 끝나는 칸끼리 아래 테두리 굵기가 다름 · 칸 사이 틈)
* - ellipsis = 이름 글에 말줄임 꼴(text-overflow ellipsis) · titled = 이름 글마다 title
* - 카드는 가로 스크롤 그대로(cardScroll = true · 표 폭 > 감싼 칸 폭)
*/
@@ -26,10 +26,20 @@
const tops = [...r.getClientRects()].filter((q) => q.width > 0).map((q) => Math.round(q.top));
return new Set(tops).size;
};
const span = (tr) => [...tr.cells].reduce((a, c) => a + (c.colSpan || 1), 0);
const span = (cells) => cells.reduce((a, c) => a + (c.colSpan || 1), 0);
const measure = (t) => {
const head = span(t.tHead.rows[0]);
const head = span([...t.tHead.rows[0].cells]);
const body = [...t.querySelectorAll("tbody tr, tfoot tr")];
// 줄마다 걸친 칸(위 줄에서 내려온 rowspan 칸 포함) · 칸마다 시작 줄
const cover = body.map(() => []);
const from = new Map();
body.forEach((tr, j) => {
for (const c of tr.cells) {
from.set(c, j);
for (let k = 0; k < (c.rowSpan || 1) && j + k < body.length; k++) cover[j + k].push(c);
}
});
const last = (c) => from.get(c) + (c.rowSpan || 1) - 1;
const texts = [
...t.querySelectorAll(
".m02-labrow__name > *, tbody .m02-labrow__code, tbody .m02-labrow__spec",
@@ -39,13 +49,17 @@
const kids = [...t.querySelectorAll(".m02-labrow__name > *")];
let broken = 0;
const why = [];
for (const tr of body) {
const R = tr.getBoundingClientRect();
const cells = [...tr.cells].filter((c) => c.getClientRects().length);
const widths = new Set(cells.map((c) => getComputedStyle(c).borderBottomWidth));
body.forEach((tr, j) => {
const cells = cover[j]
.filter((c) => c.getClientRects().length)
.sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left);
const ends = cells.filter((c) => last(c) === j);
const widths = new Set(ends.map((c) => getComputedStyle(c).borderBottomWidth));
if (widths.size > 1) (broken++, why.push(`굵기 ${tr.className} ${[...widths]}`));
cells.forEach((c, i) => {
const r = c.getBoundingClientRect();
const top = body[from.get(c)].getBoundingClientRect().top;
const R = { top, bottom: body[last(c)].getBoundingClientRect().bottom };
if (Math.abs(r.top - R.top) > 1 || Math.abs(r.bottom - R.bottom) > 1)
(broken++,
why.push(`높이 ${tr.className} ${c.className} ${Math.round(r.bottom - R.bottom)}`));
@@ -53,12 +67,12 @@
if (n && Math.abs(n.getBoundingClientRect().left - r.right) > 1)
(broken++, why.push(`틈 ${tr.className} ${c.className}`));
});
}
});
const wrap = t.parentElement;
return {
head,
rows: body.length,
spanBad: body.filter((tr) => span(tr) !== head).length,
spanBad: cover.filter((cells) => span(cells) !== head).length,
nameLines: Math.max(0, ...texts.map(lines)),
headLines: Math.max(0, ...[...t.tHead.rows[0].cells].map(lines)),
broken,