diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index e40258c2b..6963942c4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -90,10 +90,10 @@ white-space: nowrap; } -/* 순서(85-3) — 손잡이 + ▲▼ 작은 단추 · 끌어 놓을 줄 위 선 */ +/* 순서(85-3 · 102-1) — ▲ · ▼ 작은 단추 위아래 · 끌어 놓을 줄 위(위로) · 아래(아래로) 선 */ .m02-labrow__order { display: inline-flex; - align-items: center; + flex-direction: column; gap: 2px; } @@ -116,14 +116,37 @@ box-shadow: inset 0 2px 0 var(--color-primary); } -/* 순서 칸 2행(51-14) — 위 손잡이 ▲▼ / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */ +/* 줄 통째 끌기(102-2) — 끄는 줄 무리 흐리게 · 아래로 놓을 자리 = 그 무리 맨 아래 선 */ +.m02-labrow tr.is-dragging { + opacity: 0.5; +} + +.m02-labrow tr.is-dragunder td { + box-shadow: inset 0 -2px 0 var(--color-primary); +} + +/* 손잡이(102-1) — 차례 칸 앞 */ +.m02-labrow__no { + white-space: nowrap; +} + +/* 단추 칸(51-14 · 102-1) — [▲ · ▼ 위아래][옵션|수정][삭제] · 옵션 · 삭제는 두 단추 높이(2행 병합 꼴) */ .m02-labrow__tools { display: inline-flex; - flex-direction: column; - align-items: flex-start; + align-items: stretch; gap: var(--btn-group-gap); } +.m02-labrow__tools > .ui-btn-group { + align-items: stretch; +} + +/* 구분 칸 품셈 책(102-3) — 단가산출 줄만 · 아래 작은 글 */ +.m02-labrow__book { + display: block; + font-size: var(--text-caption); +} + /* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */ .m02-labrow .m02-labrow__optrow td { padding-top: 0; @@ -396,6 +419,20 @@ grid-column: 1 / -1; max-width: none; } + + /* 끌기 선(102-2) — 칸마다 말고 줄 카드 하나에 */ + .m02-labrow tr.is-dragover td, + .m02-labrow tr.is-dragunder td { + box-shadow: none; + } + + .m02-labrow tr.is-dragover { + box-shadow: inset 0 2px 0 var(--color-primary); + } + + .m02-labrow tr.is-dragunder { + box-shadow: inset 0 -2px 0 var(--color-primary); + } } /* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index cf2d81160..a832a6c60 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -57,6 +57,7 @@ import { import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input"; import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal"; import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit"; +import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; @@ -180,22 +181,25 @@ export function mountLaborDetail( }; dgBox.addEventListener("toggle", drawDg); + // 줄 끄는 동안(102-2)엔 다시 그리지 않음 — 끝난 뒤 한 번 const drawLogic = (): void => - logicHost.replaceChildren( - logicTable( - { rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked }, - // 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠) - edit && !promote - ? { - open: pick, - option: openRow, - ref: openRef, - qty: openQty, - move: reorder, - remove: removeRow, - alias: setAlias, - } - : { open: pick, option: openRow, ref: openRef }, + afterRowDrag(logicHost, () => + logicHost.replaceChildren( + logicTable( + { rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked }, + // 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠) + edit && !promote + ? { + open: pick, + option: openRow, + ref: openRef, + qty: openQty, + move: reorder, + remove: removeRow, + alias: setAlias, + } + : { open: pick, option: openRow, ref: openRef }, + ), ), ); const draw = (): void => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowDrag.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowDrag.ts new file mode 100644 index 000000000..f2061978d --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowDrag.ts @@ -0,0 +1,126 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_RowDrag.ts + * 일위대가 담은 로직 줄 통째 끌기(102-2) — 줄 · 옵션 값 줄 · 하위 줄 어디서나(손잡이도 같은 길). + * 6px 움직여야 끌기(B05 `Profile_Wheel` 선례) · 그 아래는 누름(열고 닫기) · 끈 뒤 누름 한 번 삼킴(열림 · 닫힘 그대로) · + * 단추 · 입력칸 · 수량 여는 자리에서는 시작 안 함(별칭 글 고르기 85-3) · 놓은 줄 자리로 `move` · + * 끄는 동안 표를 다시 그리지 않음(`afterRowDrag` — 끝난 뒤 한 번). + * ========================================================================== */ + +/** 이만큼 움직여야 끌기 — 그 아래는 누름(B05 와 같음 · 4px 은 손떨림에 넘어감) */ +export const DRAG_THRESHOLD_PX = 6; +const BLOCK = "button, input, select, textarea, a, [data-labor-qty-open]"; +const HEAD = "tr[data-labor-logic]"; + +let active = false; +const queued = new Map void>(); + +/** 다시 그림 — 끄는 동안이면 끝난 뒤로 미룸(자리마다 마지막 것만) */ +export function afterRowDrag(host: Element, draw: () => void): void { + if (active) return void queued.set(host, draw); + queued.delete(host); + draw(); +} + +/** 문턱 — 가로 · 세로 어느 쪽이든 6px 이면 끌기 */ +export const pastThreshold = (dx: number, dy: number): boolean => + Math.abs(dx) >= DRAG_THRESHOLD_PX || Math.abs(dy) >= DRAG_THRESHOLD_PX; + +/** 그 줄이 속한 로직 줄(옵션 값 줄 · 하위 줄은 위로 올라가 찾음) */ +const headOf = (tr: Element | null | undefined): Element | null => { + let t = tr ?? null; + while (t && !t.matches(HEAD)) t = t.previousElementSibling; + return t; +}; + +/** 로직 줄 무리 — 로직 줄 + 옵션 값 줄 + 하위 줄 */ +const groupOf = (head: Element): Element[] => { + const out = [head]; + for (let t = head.nextElementSibling; t && !t.matches(HEAD); t = t.nextElementSibling) + out.push(t); + return out; +}; + +/** `root` = 표 감싼 칸 · 줄 차례 = tbody 안 로직 줄 차례(같은 로직 두 줄이어도 자리로 셈) */ +export function attachRowDrag(root: HTMLElement, move: (from: number, to: number) => void): void { + let id: number | null = null; + let x = 0; + let y = 0; + let moved = false; + let from = -1; + let to = -1; + let marks: Element[] = []; + const heads = (): Element[] => [...root.querySelectorAll(`tbody > ${HEAD}`)]; + const unmark = (): void => { + for (const m of marks) m.classList.remove("is-dragging", "is-dragover", "is-dragunder"); + marks = []; + }; + const mark = (els: Element[], cls: string): void => { + for (const e of els) e.classList.add(cls); + marks.push(...els); + }; + + const end = (drop: boolean): void => { + const pid = id; + id = null; + if (pid === null || !moved) return; + moved = false; + if (root.hasPointerCapture(pid)) root.releasePointerCapture(pid); + unmark(); + root.style.userSelect = ""; + root.style.cursor = ""; + active = false; + // 끈 뒤 누름 한 번 삼킴(캡처 · 다음 프레임에 걷음) — 열림 · 닫힘 그대로 + const swallow = (e: MouseEvent): void => { + e.stopPropagation(); + e.preventDefault(); + }; + window.addEventListener("click", swallow, { capture: true, once: true }); + requestAnimationFrame(() => window.removeEventListener("click", swallow, { capture: true })); + if (drop && to >= 0 && to !== from) move(from, to); + const later = [...queued.values()]; + queued.clear(); + for (const draw of later) draw(); + }; + + root.addEventListener("pointerdown", (e) => { + if (e.button !== 0 || moved) return; + const t = e.target as Element; + if (t.closest(BLOCK)) return; + const head = headOf(t.closest("tbody > tr")); + from = head ? heads().indexOf(head) : -1; + if (from < 0) return; + id = e.pointerId; + x = e.clientX; + y = e.clientY; + to = -1; + }); + + root.addEventListener("pointermove", (e) => { + if (id !== e.pointerId) return; + if (!moved) { + if (!(e.buttons & 1)) return void (id = null); // 누름 없이 들어옴 — 끌기 아님 + if (!pastThreshold(e.clientX - x, e.clientY - y)) return; + moved = true; + active = true; + // 이제부터 끌기 — 표 밖으로 나가도 이어지게 포인터를 잡음 · 글자 고름 막음 + root.setPointerCapture(e.pointerId); + root.style.userSelect = "none"; + root.style.cursor = "grabbing"; + window.getSelection()?.removeAllRanges(); + } + e.preventDefault(); + unmark(); + const list = heads(); + if (list[from]) mark(groupOf(list[from]), "is-dragging"); + const over = headOf(document.elementFromPoint(e.clientX, e.clientY)?.closest("tbody > tr")); + to = over ? list.indexOf(over) : -1; + if (!over || to < 0 || to === from) return; + // 위로 = 놓을 줄 위 선 · 아래로 = 놓을 줄 무리 아래 선(그 뒤로 감) + if (to < from) mark([over], "is-dragover"); + else mark(groupOf(over).slice(-1), "is-dragunder"); + }); + + root.addEventListener("pointerup", () => end(true)); + root.addEventListener("pointercancel", () => end(false)); + root.addEventListener("lostpointercapture", () => end(false)); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 262c577cd..8f52869bb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -23,6 +23,7 @@ import type { LaborRow, } from "./M02_MasterTemplete_Labor_Api"; import { lineId } from "./M02_MasterTemplete_Labor_Calc"; +import { attachRowDrag } from "./M02_MasterTemplete_Labor_RowDrag"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "@ui/ui_template_general_layout.css"; import "./M02_MasterTemplete_Labor.css"; @@ -47,6 +48,9 @@ export const kindOf = (row: Pick): string => { ); }; +/** 품셈 책(102-3) — 출처 첫 낱말 */ +const BOOK = /^(건설|산림)품셈/; + const num = (v: number): string => Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); @@ -148,9 +152,6 @@ function moneyCell( } /** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */ -/** 끌고 있는 줄 차례(85-3) — 손잡이 dragstart ~ drop */ -let dragFrom: number | null = null; - const titled = (td: HTMLElement): HTMLElement => { for (const c of td.children) c.setAttribute("title", c.textContent ?? ""); return td; @@ -182,7 +183,7 @@ export interface RowHandlers { ref?: (i: number) => void; /** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */ qty?: (i: number) => void; - /** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 손잡이 끌기 = 놓은 줄 자리) */ + /** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 줄 끌기 102-2 = 놓은 줄 자리) */ move?: (from: number, to: number) => void; remove?: (i: number) => void; /** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */ @@ -315,6 +316,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : ""; const note = [values.text, layer].filter(Boolean).join(" · "); const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); + const book = row.참조 ? undefined : BOOK.exec(logic?.출처 ?? "")?.[0]; const small = ( label: string, run: () => void, @@ -328,7 +330,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement run(); }, }); - // 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) + 손잡이 끌기(줄 전체는 안 끎 · 별칭 칸 글 고르기) + // 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) · 손잡이 = 차례 칸 앞(102-1) · 끌기는 줄 통째(102-2 · `Labor_RowDrag`) const arrow = (mark: string, label: string, to: number): HTMLElement => { const b = small(mark, () => move?.(i, to)); b.classList.add("m02-labrow__arrow"); @@ -341,17 +343,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const g = el("span", { className: "m02-labrow__grip", text: "⠿", - attrs: { draggable: "true", title: tc("Labor_Drag"), "data-labor-grip": String(i) }, - }); - g.addEventListener("click", (e) => e.stopPropagation()); - g.addEventListener("dragstart", (ev) => { - dragFrom = i; - ev.dataTransfer?.setData("text/plain", String(i)); - if (ev.dataTransfer) ev.dataTransfer.effectAllowed = "move"; - }); - g.addEventListener("dragend", () => { - dragFrom = null; + attrs: { title: tc("Labor_Drag"), "data-labor-grip": String(i) }, }); + g.addEventListener("click", (e) => e.stopPropagation()); // 손잡이 누름은 열고 닫지 않음 return g; }; const sel = data.selected === id ? " is-selected" : ""; @@ -359,8 +353,18 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel, attrs: { "data-labor-logic": id }, children: [ - el("td", { className: "m02-labrow__no", text: String(i + 1) }), - el("td", { className: "m02-labrow__kind", text: kindOf(row) }), + el("td", { + className: "m02-labrow__no", + children: move ? [grip(), String(i + 1)] : [String(i + 1)], + }), + el("td", { + className: "m02-labrow__kind", + children: [ + kindOf(row), + // 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말) + ...(book ? [el("small", { className: "m02-labrow__book", text: book })] : []), + ], + }), titled( el("td", { className: "m02-labrow__name", @@ -406,7 +410,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement el("div", { className: "m02-labrow__order", children: [ - grip(), arrow("▲", tc("Up"), i - 1), arrow("▼", tc("Down"), i + 1), ], @@ -437,21 +440,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement ], }); if (on) tr.addEventListener("click", () => on.open(i)); - if (move) { - tr.addEventListener("dragover", (ev) => { - if (dragFrom === null || dragFrom === i) return; - ev.preventDefault(); - tr.classList.add("is-dragover"); - }); - tr.addEventListener("dragleave", () => tr.classList.remove("is-dragover")); - tr.addEventListener("drop", (ev) => { - ev.preventDefault(); - tr.classList.remove("is-dragover"); - const from = dragFrom; - dragFrom = null; - if (from !== null && from !== i) move(from, i); - }); - } const subs = sel ? subRows(line, wait, tools) : []; if (!note) return [tr, ...subs]; // 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐 @@ -509,9 +497,10 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement ...COSTS.map((c) => tc(HEAD[c])), ...(tools ? [tc("Col_Move")] : []), ]; - return data.rows.length - ? table(heads, trs, foot) - : el("p", { className: "m02-labrow__muted", text: tc("NoLogic") }); + if (!data.rows.length) return el("p", { className: "m02-labrow__muted", text: tc("NoLogic") }); + const out = table(heads, trs, foot); + if (move) attachRowDrag(out, move); + return out; } export interface MatTableData { diff --git a/resources/tester/helper_m02_labor_rowdrag.cjs b/resources/tester/helper_m02_labor_rowdrag.cjs new file mode 100644 index 000000000..70706a7ea --- /dev/null +++ b/resources/tester/helper_m02_labor_rowdrag.cjs @@ -0,0 +1,210 @@ +/* M02 일위대가 줄 통째 끌기(`M02_MasterTemplete_Labor_RowDrag.ts` · 102-2) 단위검증 헬퍼 — + * 트랜스파일해 Node 로 돌림 · 작은 가짜 DOM(줄 높이 20px · 셀렉터는 그 파일이 쓰는 꼴만) · JSON 으로 냄. */ +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_Labor_RowDrag.ts", +); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; + +/* ---- 가짜 DOM ---- */ +const one = (node, sel) => { + const m = /^(\w+)?(?:\[([\w-]+)\])?$/.exec(sel.trim()); + if (!m) throw new Error(`셀렉터 모름: ${sel}`); + return (!m[1] || node.tag === m[1]) && (!m[2] || m[2] in node.attrs); +}; +const matches = (node, sel) => + sel.split(",").some((part) => { + const [up, me] = part.includes(">") ? part.split(">") : [null, part]; + return one(node, me) && (!up || (node.parent && one(node.parent, up))); + }); +class Node { + constructor(tag, attrs = {}, parent = null) { + this.tag = tag; + this.attrs = attrs; + this.parent = parent; + this.kids = []; + this.on = {}; + this.style = {}; + const set = new Set(); + this.classList = { + add: (...c) => c.forEach((x) => set.add(x)), + remove: (...c) => c.forEach((x) => set.delete(x)), + contains: (c) => set.has(c), + list: () => [...set], + }; + if (parent) parent.kids.push(this); + } + matches(sel) { + return matches(this, sel); + } + closest(sel) { + for (let n = this; n; n = n.parent) if (n.matches(sel)) return n; + return null; + } + get previousElementSibling() { + const k = this.parent.kids; + return k[k.indexOf(this) - 1] ?? null; + } + get nextElementSibling() { + const k = this.parent.kids; + return k[k.indexOf(this) + 1] ?? null; + } + querySelectorAll(sel) { + const out = []; + const walk = (n) => n.kids.forEach((c) => (c.matches(sel) && out.push(c), walk(c))); + walk(this); + return out; + } + addEventListener(type, fn) { + (this.on[type] ??= []).push(fn); + } +} + +/* 표 — 줄 0 · 1(옵션 값 줄 · 하위 줄 둘 = 열린 줄) · 2 · 3 */ +const root = new Node("div"); +const tbody = new Node("tbody", {}, new Node("table", {}, root)); +const tr = (attrs) => new Node("tr", attrs, tbody); +const rows = [ + tr({ "data-labor-logic": "A" }), + tr({ "data-labor-logic": "B" }), + tr({ "data-labor-opt": "B" }), + tr({ "data-labor-sub": "" }), + tr({ "data-labor-sub": "" }), + tr({ "data-labor-logic": "C" }), + tr({ "data-labor-logic": "D" }), +]; +const cell = (r, tag = "td", attrs = {}) => new Node(tag, attrs, rows[r]); +const td = rows.map((_, r) => cell(r)); +const btn = new Node("button", {}, cell(1)); +const input = new Node("input", { "data-labor-alias": "" }, cell(1)); +const qty = new Node("span", {}, new Node("button", { "data-labor-qty-open": "" }, cell(1))); +const grip = new Node("span", { "data-labor-grip": "0" }, td[0]); +const Y = (r) => r * 20 + 10; + +let capture = null; +root.hasPointerCapture = (id) => capture === id; +root.setPointerCapture = (id) => (capture = id); +root.releasePointerCapture = () => (capture = null); + +const winClick = []; +let frames = []; +global.window = { + addEventListener: (t, fn) => winClick.push(fn), + removeEventListener: (t, fn) => { + const i = winClick.indexOf(fn); + if (i >= 0) winClick.splice(i, 1); + }, + getSelection: () => ({ removeAllRanges() {} }), +}; +global.requestAnimationFrame = (fn) => frames.push(fn); +global.document = { elementFromPoint: (x, y) => td[Math.floor(y / 20)] ?? null }; + +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; + +/* ---- 부르기 ---- */ +const moves = []; +m.attachRowDrag(root, (from, to) => moves.push([from, to])); +const fire = (type, e) => root.on[type]?.forEach((fn) => fn({ preventDefault() {}, ...e })); +const down = (target, y) => + fire("pointerdown", { button: 0, pointerId: 1, clientX: 50, clientY: y, target }); +const mv = (y, dx = 0) => + fire("pointermove", { pointerId: 1, buttons: 1, clientX: 50 + dx, clientY: y }); +/** 누름(click) — 창 캡처 삼킴을 먼저 거침 · 남으면 줄 열고 닫기 */ +const click = () => { + let stopped = false; + [...winClick].forEach((fn) => + fn({ stopPropagation: () => (stopped = true), preventDefault() {} }), + ); + winClick.length = 0; // once + return !stopped; +}; +const frame = () => { + const f = frames; + frames = []; + f.forEach((fn) => fn()); +}; +const classes = () => rows.map((r) => r.classList.list().sort().join(" ")); + +const out = {}; +out.threshold = [ + [5, 0], + [6, 0], + [0, -6], + [5, 5], + [-7, 2], +].map(([dx, dy]) => m.pastThreshold(dx, dy)); + +// 1) 5px 만 움직임 = 누름 — 옮김 없음 · 누름 지나감(열고 닫기) +down(td[0], Y(0)); +mv(Y(0) + 5); +fire("pointerup", {}); +out.small = { moves: moves.length, clickPasses: click(), capture }; + +// 2) 줄 A(0) → 줄 C(차례 2) 로 끌기 — 끄는 동안 표시 · 다시 그림 미룸 · 놓으면 move(0,2) · 누름 삼킴 +let drawn = 0; +const host = {}; +down(td[0], Y(0)); +mv(Y(0) + 7); +m.afterRowDrag(host, () => drawn++); +m.afterRowDrag(host, () => drawn++); // 마지막 것만 +mv(Y(5)); +out.during = { drawn, capture, classes: classes(), select: root.style.userSelect }; +fire("pointerup", {}); +out.drop = { moves: moves.splice(0), drawn, clickPasses: click(), classes: classes() }; +frame(); +out.afterFrame = { listeners: winClick.length }; + +// 3) 열린 줄 B 의 하위 줄에서 시작 → 위 줄 A 로 = move(1,0) · 표시 = A 위 선 · B 무리 흐림 +down(td[4], Y(4)); +mv(Y(0)); +out.fromSub = { classes: classes() }; +fire("pointerup", {}); +out.fromSub.moves = moves.splice(0); +click(); +frame(); + +// 4) 줄 A → 열린 줄 B(아래로) — 표시 = B 무리 맨 아래(하위 줄 둘째) 선 +down(td[0], Y(0)); +mv(Y(2)); +out.downMark = classes(); +fire("pointerup", {}); +out.downMark.push(JSON.stringify(moves.splice(0))); +click(); +frame(); + +// 5) 단추 · 입력칸 · 수량 여는 자리에서는 시작 안 함 +for (const [name, t] of [ + ["button", btn], + ["input", input], + ["qty", qty], +]) { + down(t, Y(1)); + mv(Y(6)); + fire("pointerup", {}); + out[`block_${name}`] = { moves: moves.splice(0).length, capture, clickPasses: click() }; +} + +// 6) 손잡이도 같은 길 · 제자리에 놓음 = 옮김 없음 · 누름은 삼킴(열림 그대로) +down(grip, Y(0)); +mv(Y(0) + 8); +fire("pointerup", {}); +out.gripSelf = { moves: moves.splice(0).length, clickPasses: click() }; +frame(); + +// 7) 끄는 중이 아니면 바로 그림 +let now = 0; +m.afterRowDrag(host, () => now++); +out.idleDraw = now; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_labor_rowdrag.py b/resources/tester/test_m02_labor_rowdrag.py new file mode 100644 index 000000000..684f97ed1 --- /dev/null +++ b/resources/tester/test_m02_labor_rowdrag.py @@ -0,0 +1,85 @@ +"""102 — M02 일위대가 상세 줄 배치 · 줄 통째 끌기 · 구분 열 품셈 글. + +끌기(102-2) = Node 헬퍼(`helper_m02_labor_rowdrag.cjs`)가 `M02_MasterTemplete_Labor_RowDrag.ts` 를 가짜 DOM 에서 돌림 · +6px 문턱 · 끈 뒤 누름 삼킴 · 열린 줄 하위 줄에서 시작 · 단추 · 입력칸 · 수량 칸 막음 · 끄는 동안 다시 그림 미룸. +배치(102-1) · 품셈 글(102-3) = 소스 꼴. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +M02 = Path(HERE).parents[1] / "M02_MasterTemplete" + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_rowdrag.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_문턱_6px(r): + assert r["threshold"] == [False, True, True, False, True] + + +def test_작게_움직이면_누름(r): + assert r["small"] == {"moves": 0, "clickPasses": True, "capture": None} + + +def test_끌어_놓으면_순서만_누름_삼킴(r): + assert r["during"]["drawn"] == 0 # 끄는 동안 다시 그리지 않음 + assert r["during"]["capture"] == 1 and r["during"]["select"] == "none" + assert r["during"]["classes"][0] == "is-dragging" + assert r["during"]["classes"][5] == "is-dragunder" + assert r["drop"]["moves"] == [[0, 2]] + assert r["drop"]["drawn"] == 1 # 끝난 뒤 마지막 것 한 번 + assert r["drop"]["clickPasses"] is False # 열림 · 닫힘 그대로 + assert set(r["drop"]["classes"]) == {""} + assert r["afterFrame"]["listeners"] == 0 + assert r["idleDraw"] == 1 + + +def test_열린_줄_하위_줄째(r): + assert r["fromSub"]["moves"] == [[1, 0]] + assert r["fromSub"]["classes"][:5] == ["is-dragover"] + ["is-dragging"] * 4 + assert r["downMark"][4] == "is-dragunder" and r["downMark"][-1] == "[[0,1]]" + + +@pytest.mark.parametrize("name", ["button", "input", "qty"]) +def test_단추_입력칸에선_시작_안_함(r, name): + assert r[f"block_{name}"] == {"moves": 0, "capture": None, "clickPasses": True} + + +def test_손잡이_제자리(r): + assert r["gripSelf"] == {"moves": 0, "clickPasses": False} + + +def test_배치_손잡이_차례칸_앞_단추칸(): + rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text(encoding="utf-8") + assert "children: move ? [grip(), String(i + 1)] : [String(i + 1)]," in rows + assert 'draggable: "true"' not in rows and "dragFrom" not in rows + assert "if (move) attachRowDrag(out, move);" in rows + css = (M02 / "M02_MasterTemplete_Labor.css").read_text(encoding="utf-8") + order = css[css.index(".m02-labrow__order {") :] + assert "flex-direction: column;" in order[: order.index("}")] + assert ".m02-labrow__tools > .ui-btn-group {\n align-items: stretch;" in css + detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text(encoding="utf-8") + assert "afterRowDrag(logicHost, () =>" in detail + + +def test_구분열_품셈_글(): + rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text(encoding="utf-8") + assert "const BOOK = /^(건설|산림)품셈/;" in rows + assert 'const book = row.참조 ? undefined : BOOK.exec(logic?.출처 ?? "")?.[0];' in rows + assert 'className: "m02-labrow__book"' in rows