fix(cupang): 센터 분배 드롭 동작·수량 대화상자·센터 선택
드래그가 먹지 않던 원인: 드롭 대상이 점선 상자(.cpg-dist-drop) 안쪽으로 한정돼 센터 카드 여백에 놓으면 dragover 에서 preventDefault 가 걸리지 않았다 → 드롭 대상을 센터 패널 전체로 넓혔다. 카드를 클릭해도 같은 대화상자가 열리는 대체 경로도 추가. - ③ 은 처음에 비어 있고, 셀렉트에서 고른 센터만 패널로 추가된다(✕ 로 제거, 제거하면 담긴 박스는 잔여로 복귀) - 드롭/클릭 시 대화상자에서 박스 수량을 입력한다. "잔여 전부 담기" 버튼으로 남은 수량을 한 번에 담을 수 있고, Enter 로 확정 / Esc 로 취소 - 센터에 담긴 줄과 센터 합계에 박스 수와 상품 수(박스×입수량)를 함께 표시 - 요약 카드에도 잔여 개수(잔여 박스 × 입수량)를 표시 tests/js: jsdom 으로 계산→드래그→대화상자→수량 수정→제거까지 36개 항목을 검증하는 UI 테스트 추가(README 에 실행법). 전체 통과 확인. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# 박스 계산 화면 UI 테스트 (jsdom)
|
||||
|
||||
`app/modules/cupang/templates/cupang/box_calc.html` 의 드래그 분배·수량 대화상자·
|
||||
잔여 계산을 브라우저 없이 검증한다. 서버 API 응답은 고정값으로 대체한다.
|
||||
|
||||
```bash
|
||||
# 1) 템플릿을 HTML 로 렌더 (프로젝트 루트에서)
|
||||
python tests/js/render_box_calc.py # → tests/js/.out/box_calc_render.html
|
||||
|
||||
# 2) jsdom 설치 후 실행
|
||||
npm install jsdom
|
||||
node tests/js/box_calc_ui.test.js
|
||||
```
|
||||
|
||||
성공하면 마지막 줄에 `ALL PASS`, 실패하면 실패한 항목명이 출력되고 종료코드 1.
|
||||
@@ -0,0 +1,139 @@
|
||||
const fs = require("fs");
|
||||
const { JSDOM } = require("jsdom");
|
||||
|
||||
const path = require("path");
|
||||
const RENDER = path.join(__dirname, ".out", "box_calc_render.html");
|
||||
const html = fs.readFileSync(RENDER, "utf8"); // 먼저 render_box_calc.py 실행
|
||||
|
||||
// 서버 응답 고정: 미라네 1호 18개(2박스+자투리2), 미라네 4호 12개(1박스+자투리4)
|
||||
const apiResponse = {
|
||||
results: [
|
||||
{product_code:"MS-1001", product_name:"미라네 1호 세트", box_name:"쿠팡박스", units_per_box:8,
|
||||
quantity:18, configured:true, full_boxes:2, remainder_units:2, required_boxes:3},
|
||||
{product_code:"MS-1004", product_name:"미라네 4호 세트", box_name:"쿠팡박스", units_per_box:8,
|
||||
quantity:12, configured:true, full_boxes:1, remainder_units:4, required_boxes:2},
|
||||
],
|
||||
totals: [],
|
||||
mixes: [{box_name:"쿠팡박스", box_count:1, leftover_units:6, boxes:[
|
||||
{items:[{product_code:"MS-1004",product_name:"미라네 4호 세트",quantity:4},
|
||||
{product_code:"MS-1001",product_name:"미라네 1호 세트",quantity:2}], fill_percent:75.0}]}],
|
||||
grand_total_boxes: 4,
|
||||
};
|
||||
|
||||
const dom = new JSDOM(html, { runScripts: "dangerously", pretendToBeVisual: true });
|
||||
const w = dom.window, d = w.document;
|
||||
w.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve(apiResponse) });
|
||||
|
||||
const fails = [];
|
||||
function check(name, cond, extra) {
|
||||
if (cond) console.log("PASS " + name);
|
||||
else { console.log("FAIL " + name + (extra ? " → " + extra : "")); fails.push(name); }
|
||||
}
|
||||
const $ = (s) => d.querySelector(s);
|
||||
const $$ = (s) => Array.from(d.querySelectorAll(s));
|
||||
const text = (s) => ($(s) ? $(s).textContent.replace(/\s+/g, " ").trim() : "<none>");
|
||||
|
||||
function fire(el, type, init) { el.dispatchEvent(new w.Event(type, Object.assign({bubbles:true}, init))); }
|
||||
|
||||
(async () => {
|
||||
// ── ① 입력 → 계산 ──────────────────────────────────
|
||||
const row1 = $(".cpg-calc-row");
|
||||
row1.querySelector(".cpg-calc-name").value = "MS-1001";
|
||||
row1.querySelector(".cpg-calc-qty").value = "18";
|
||||
|
||||
// Tab 으로 다음 행 생성 + 포커스 이동
|
||||
const ke = new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
|
||||
row1.querySelector(".cpg-calc-qty").dispatchEvent(ke);
|
||||
check("Tab 이 새 행을 만든다", $$(".cpg-calc-row").length === 2, "행수=" + $$(".cpg-calc-row").length);
|
||||
check("Tab 후 포커스가 새 행 수량", d.activeElement === $$(".cpg-calc-row")[1].querySelector(".cpg-calc-qty"));
|
||||
|
||||
const row2 = $$(".cpg-calc-row")[1];
|
||||
row2.querySelector(".cpg-calc-name").value = "MS-1004";
|
||||
row2.querySelector(".cpg-calc-qty").value = "12";
|
||||
|
||||
$("#cpg-calc-run").click();
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
|
||||
check("요약이 표시된다", $("#cpg-sum-body").hidden === false);
|
||||
check("제품 카드 2장", $$(".cpg-sum-prod").length === 2, String($$(".cpg-sum-prod").length));
|
||||
check("혼합 박스 1장", $$(".cpg-box-card").length === 1);
|
||||
check("제품별 잔여 = 2박스(16개)", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("2박스16개"), text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||
check("KPI 총 박스 4", text(".cpg-kpi .cpg-kpi-value") === "4박스", text(".cpg-kpi .cpg-kpi-value"));
|
||||
|
||||
// ── ③ 센터는 추가한 것만 표시 ─────────────────────
|
||||
check("초기 센터 패널 없음", $$(".cpg-dist-center").length === 0);
|
||||
$("#cpg-center-pick").value = "1";
|
||||
$("#cpg-center-add").click();
|
||||
check("센터 추가 후 1개 표시", $$(".cpg-dist-center").length === 1, String($$(".cpg-dist-center").length));
|
||||
check("추가한 센터 이름", text(".cpg-dist-center strong") === "대구3", text(".cpg-dist-center strong"));
|
||||
|
||||
// ── 드래그 → 드롭 → 대화상자 ──────────────────────
|
||||
const card = $(".cpg-sum-prod[draggable='true']");
|
||||
const dt = { data: {}, setData(t, v) { this.data[t] = v; }, getData(t) { return this.data[t]; } };
|
||||
const ds = new w.Event("dragstart", { bubbles: true }); ds.dataTransfer = dt;
|
||||
card.dispatchEvent(ds);
|
||||
check("dragstart 가 key 를 싣는다", dt.getData("text/plain") === "p:MS-1001", dt.getData("text/plain"));
|
||||
|
||||
const zone = $(".cpg-dist-center");
|
||||
const dov = new w.Event("dragover", { bubbles: true, cancelable: true }); dov.dataTransfer = dt;
|
||||
zone.dispatchEvent(dov);
|
||||
check("dragover 가 기본동작을 막는다(드롭 허용)", dov.defaultPrevented);
|
||||
|
||||
const drop = new w.Event("drop", { bubbles: true, cancelable: true }); drop.dataTransfer = dt;
|
||||
zone.dispatchEvent(drop);
|
||||
check("드롭하면 대화상자가 열린다", $("#cpg-dlg").hidden === false);
|
||||
check("대화상자에 잔여/개수 표시", text("#cpg-dlg-item").includes("잔여 2박스 (16개)"), text("#cpg-dlg-item"));
|
||||
check("대화상자 센터 = 드롭한 센터", $("#cpg-dlg-center").value === "1");
|
||||
|
||||
// 1박스만 담기
|
||||
$("#cpg-dlg-qty").value = "1";
|
||||
$("#cpg-dlg-ok").click();
|
||||
check("대화상자가 닫힌다", $("#cpg-dlg").hidden === true);
|
||||
check("센터에 항목 1줄", $$(".cpg-dist-item").length === 1);
|
||||
check("센터 항목에 박스·개수 표시", text(".cpg-dist-item .cpg-dist-unit") === "박스 · 8개", text(".cpg-dist-item .cpg-dist-unit"));
|
||||
check("센터 합계 배지 1박스/8개", text(".cpg-dist-sum").startsWith("1박스 8개✕") || text(".cpg-dist-sum").startsWith("1박스8개"), text(".cpg-dist-sum"));
|
||||
check("요약 잔여가 1박스로 줄어든다", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("1박스8개"), text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||
check("KPI 센터 배분 1박스", $$(".cpg-kpi-value")[1].textContent === "1박스", $$(".cpg-kpi-value")[1].textContent);
|
||||
|
||||
// ── 잔여 전부 담기 ────────────────────────────────
|
||||
$$(".cpg-sum-prod")[0].click(); // 클릭 대체 경로
|
||||
check("클릭으로도 대화상자가 열린다", $("#cpg-dlg").hidden === false);
|
||||
$("#cpg-dlg-all").click();
|
||||
check("전량 담기 후 잔여 0", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("0박스0개"), text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||
check("잔여 0 카드는 드래그 불가", $$(".cpg-sum-prod")[0].getAttribute("draggable") === null);
|
||||
check("센터 합계 2박스/16개", text(".cpg-dist-sum").replace(/\s/g,"").startsWith("2박스16개"), text(".cpg-dist-sum"));
|
||||
|
||||
// ── 수량 직접 수정 (상한 = 잔여+자기수량) ─────────
|
||||
const qty = $(".cpg-dist-item .cpg-dist-qty");
|
||||
qty.value = "5"; fire(qty, "input");
|
||||
check("잔여 초과 입력은 최대치로 잘림", qty.value === "2", qty.value);
|
||||
|
||||
qty.value = "1"; fire(qty, "input");
|
||||
check("수량 줄이면 개수 표시 갱신", text(".cpg-dist-item .cpg-dist-unit") === "박스 · 8개", text(".cpg-dist-item .cpg-dist-unit"));
|
||||
check("수량 줄이면 요약 잔여 복귀", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("1박스8개"), text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||
|
||||
// ── 혼합 박스 담기 ────────────────────────────────
|
||||
const mix = $(".cpg-box-card[draggable='true']");
|
||||
const dt2 = { data:{}, setData(t,v){this.data[t]=v;}, getData(t){return this.data[t];} };
|
||||
const ds2 = new w.Event("dragstart", { bubbles: true }); ds2.dataTransfer = dt2;
|
||||
mix.dispatchEvent(ds2);
|
||||
const drop2 = new w.Event("drop", { bubbles: true, cancelable: true }); drop2.dataTransfer = dt2;
|
||||
$(".cpg-dist-center").dispatchEvent(drop2);
|
||||
check("혼합 박스도 대화상자가 열린다", $("#cpg-dlg").hidden === false);
|
||||
check("혼합 박스 잔여 1박스(6개)", text("#cpg-dlg-item").includes("잔여 1박스 (6개)"), text("#cpg-dlg-item"));
|
||||
$("#cpg-dlg-ok").click();
|
||||
check("혼합 박스가 센터에 담긴다", $$(".cpg-dist-item").length === 2);
|
||||
check("혼합 담은 뒤 배분됨 표시", $(".cpg-box-card").className.includes("is-done"));
|
||||
|
||||
// ── 항목 빼기 / 센터 빼기 ─────────────────────────
|
||||
$$(".cpg-dist-del")[1].click();
|
||||
check("항목 빼면 줄이 사라진다", $$(".cpg-dist-item").length === 1);
|
||||
check("항목 빼면 혼합 박스 복귀", !$(".cpg-box-card").className.includes("is-done"));
|
||||
|
||||
$(".cpg-dist-close").click();
|
||||
check("센터 빼기", $$(".cpg-dist-center").length === 0);
|
||||
check("센터 빼면 전량 잔여 복귀", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("2박스16개"), text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||
|
||||
console.log("\n" + (fails.length ? "FAILED: " + fails.join(" | ") : "ALL PASS"));
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
})();
|
||||
@@ -0,0 +1,39 @@
|
||||
"""box_calc.html 을 테스트용 HTML 로 렌더한다 (erp_base.html 은 최소 스텁으로 대체)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
import jinja2
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
OUT_DIR = os.path.join(ROOT, "tests", "js", ".out")
|
||||
|
||||
BASE_STUB = "{% block head_extra %}{% endblock %}<body>{% block content %}{% endblock %}</body>"
|
||||
|
||||
BOX_RULES = [
|
||||
{"product_code": "MS-1001", "product_name_snapshot": "미라네 1호 세트",
|
||||
"box_name": "쿠팡박스", "units_per_box": 8},
|
||||
{"product_code": "MS-1004", "product_name_snapshot": "미라네 4호 세트",
|
||||
"box_name": "쿠팡박스", "units_per_box": 8},
|
||||
]
|
||||
CENTERS = [{"id": 1, "name": "대구3"}, {"id": 2, "name": "인천32"}]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.ChoiceLoader([
|
||||
jinja2.DictLoader({"erp_base.html": BASE_STUB}),
|
||||
jinja2.FileSystemLoader(os.path.join(ROOT, "app", "modules", "cupang", "templates")),
|
||||
])
|
||||
)
|
||||
html = env.get_template("cupang/box_calc.html").render(box_rules=BOX_RULES, centers=CENTERS)
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
path = os.path.join(OUT_DIR, "box_calc_render.html")
|
||||
io.open(path, "w", encoding="utf-8", newline="\n").write(html)
|
||||
print("rendered:", path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user