feat(cafe24): 옵션값+품목 통합 목록, 드래그 순서, 옵션값 불러오기

- 옵션 1개 상품은 한 행에 썸네일·옵션값 이름·품목코드(복사)·자체코드·
  추가금액·진열·판매를 두고 「저장」 한 번으로 옵션 PUT → 품목 PUT.
- 옵션 없는 상품: 행 추가/삭제 후 저장 → 옵션 생성 → 카페24가 부여한
  품목코드를 재시도 조회(products.wait_for_variants)로 받아 자체코드·
  추가금액·썸네일까지 이어서 반영.
- 순서 드래그는 품목 display_order 로 저장(옵션값 재배열 PUT 은 위치 짝맞춤
  때문에 품목코드↔이름이 뒤바뀔 수 있어 사용하지 않음).
- 「옵션값 불러오기」: 탭/공백 구분 텍스트(이름·판매가·추가금액·자체코드)
  붙여넣기 → 행 자동 채움. 기존 옵션은 이름으로 짝지어 코드/금액만.
- 옵션 2개 이상(조합) 상품은 기존 2열 화면 유지.
- 모달 JS 를 app/static/cafe24-options.js 로 분리. 유닛테스트 90 통과,
  통합 하네스 통과, 헤드리스 크롬 렌더 확인.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:25:52 +09:00
parent be0d44ded6
commit 8125a59366
8 changed files with 1002 additions and 360 deletions
+6 -6
View File
@@ -327,11 +327,11 @@ def options_create(
st.log_audit(actor=actor, action="create_options", product_no=product_no, result="SUCCESS",
detail=f"옵션 '{body['options'][0]['option_name']}' 생성: {', '.join(values)}")
logger.info("카페24 상품 %s 옵션 생성 (%s)", product_no, actor)
try:
variants = products.list_variants(api.client, product_no)
except Cafe24Error as exc: # 옵션은 만들어졌다 — 품목 목록만 비워서 돌려준다.
logger.warning("카페24 상품 %s 옵션 생성 후 품목 조회 실패: %s", product_no, exc)
variants = []
# 카페24가 자동 생성한 품목(코드 부여)을 바로 돌려준다 — 읽기 지연이 있어 짧게 재시도.
# 화면은 이 코드로 자체코드·추가금액을 이어서 PUT 한다.
variants = products.wait_for_variants(api.client, product_no, len(values))
if len(variants) < len(values):
logger.warning("카페24 상품 %s 옵션 생성 후 품목 %s/%s 건만 조회됨", product_no, len(variants), len(values))
return {"ok": True, "option": _options_view(created), "variants": [_variant_view(v) for v in variants]}
@@ -484,7 +484,7 @@ def variants_update(
for r in results:
code = str(r.get("variant_code") or "")
if code in by_code:
for key in ("custom_variant_code", "additional_amount", "display", "selling"):
for key in ("custom_variant_code", "additional_amount", "display", "selling", "display_order"):
if key in r and r[key] is not None:
by_code[code][key] = r[key]
st.save_write_snapshot(product_no, "variants", by_code)
+18 -3
View File
@@ -649,10 +649,16 @@ ADDITIONAL_AMOUNT_MAX = 2_147_483_647
def parse_option_values(raw: object) -> list[str]:
"""'빨강, 파랑\\n노랑' → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지)."""
text = str(raw or "")
"""'빨강, 파랑\\n노랑' 또는 ['빨강','파랑'] → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지).
목록으로 오면 항목 안의 쉼표는 이름의 일부로 본다(화면이 행 단위로 보낼 때).
"""
if isinstance(raw, (list, tuple)):
parts = [str(p) for p in raw]
else:
parts = re.split(r"[,\n]", str(raw or ""))
seen: list[str] = []
for part in re.split(r"[,\n]", text):
for part in parts:
value = part.strip()
if value and value not in seen:
seen.append(value)
@@ -785,6 +791,15 @@ def build_variant_updates(rows: list[dict]) -> list[dict]:
for flag in ("display", "selling"):
if row.get(flag) is not None:
item[flag] = "T" if parse_tristate(row[flag]) else "F"
# 진열 순서(1~300) — 카페24 문서: 조합형 옵션 품목에만. 화면의 드래그 정렬이 보낸다.
if row.get("display_order") is not None:
try:
order = int(row["display_order"])
except (TypeError, ValueError):
raise ValueError("진열 순서는 숫자여야 합니다.") from None
if not 1 <= order <= 300:
raise ValueError("진열 순서는 1~300 사이여야 합니다.")
item["display_order"] = order
if len(item) > 1:
out.append(item)
return out
+10 -349
View File
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260918d" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260918e" />
{% endblock %}
{% block content %}
@@ -125,6 +125,7 @@
{% endblock %}
{% block scripts %}
<script src="/static/cafe24-options.js?v=20260918e"></script>
<script>
(function () {
var pane = document.getElementById("cf24-editor-pane");
@@ -913,355 +914,15 @@
});
})();
// ── 옵션 / 품목 — 「팝업에서 관리」 버튼으로 모달을 열고 그때 불러온다 ──
(function setupOptions() {
var openBtn = root.querySelector("#cf24-options-open");
var body = optModal.body;
var count = root.querySelector("#cf24-options-count");
if (!openBtn || !body) return;
var state = { option: null, variants: [], displayTypes: {} };
openBtn.addEventListener("click", function () {
var nameEl = document.getElementById("cf24-name-text");
optModal.open((nameEl ? nameEl.textContent : "") + " (상품번호 " + no + ")");
load();
// ── 옵션 / 품목 — 모달 로직은 /static/cafe24-options.js 에 있다(크기 때문에 분리).
// 통합 목록(옵션값+품목 한 행) · 드래그 순서 · 옵션값 불러오기 · 저장 한 번에 처리.
if (window.cf24SetupOptions) {
window.cf24SetupOptions({
root: root, productNo: no, modal: optModal,
apiJson: apiJson, escHtml: escHtml, setMsg: setMsg,
trackDirty: trackDirty, commitValues: commitValues
});
function load() {
body.innerHTML = '<p class="cf24-muted">카페24에서 불러오는 중…</p>';
apiJson("GET", "/cafe24/products/" + no + "/options")
.then(function (data) {
state.option = data.option;
state.variants = data.variants || [];
state.displayTypes = data.display_types || {};
render();
})
.catch(function (err) {
body.innerHTML = '<p class="cf24-err">옵션을 불러오지 못했습니다: ' + escHtml(err.message) +
'</p><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-retry">다시 시도</button>';
body.querySelector("#cf24-opt-retry").addEventListener("click", load);
});
}
function typeSelect(current, name) {
var out = '<select name="' + name + '">';
Object.keys(state.displayTypes).forEach(function (k) {
out += '<option value="' + k + '"' + (k === current ? " selected" : "") + ">" + escHtml(state.displayTypes[k]) + "</option>";
});
return out + "</select>";
}
function render() {
var opt = state.option || { has_option: false, options: [] };
var variants = state.variants || [];
if (count) count.textContent = opt.has_option ? "(" + variants.length + "품목)" : "(옵션 없음)";
if (!opt.has_option) {
body.innerHTML =
'<p class="cf24-side-note">이 상품에는 옵션이 없습니다. 옵션명과 옵션값을 넣어 조합형 옵션을 만들면 품목이 자동으로 생성됩니다.</p>' +
'<form class="cf24-side-form cf24-opt-create" id="cf24-opt-create">' +
' <label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" placeholder="예: 색상" /></label>' +
' <label class="cf24-side-field"><span>옵션값 (쉼표 또는 줄바꿈으로 구분)</span><textarea name="values" placeholder="예: 빨강, 파랑, 노랑"></textarea></label>' +
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect("S", "display_type") + "</label>" +
' <div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span>' +
' <button class="erp-btn erp-btn-primary" type="submit">옵션 만들기</button></div>' +
"</form>";
var form = body.querySelector("#cf24-opt-create");
form.addEventListener("submit", function (e) {
e.preventDefault();
var payload = {
option_name: form.querySelector('[name="option_name"]').value.trim(),
values: form.querySelector('[name="values"]').value,
display_type: form.querySelector('[name="display_type"]').value
};
if (!payload.option_name || !payload.values.trim()) {
window.erpAlert("옵션명과 옵션값을 입력하세요.");
return;
}
window.erpConfirm("옵션 「" + payload.option_name + "」을(를) 만들고 품목을 생성합니다.\n카페24에 바로 반영됩니다. 계속할까요?")
.then(function (ok) {
if (!ok) return;
var msg = form.querySelector("#cf24-opt-msg");
setMsg(msg, "생성 중…", "");
apiJson("POST", "/cafe24/products/" + no + "/options", payload)
.then(function (data) { state.option = data.option; state.variants = data.variants || []; render(); })
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
});
});
return;
}
// 옵션값 썸네일은 세 곳 중 있는 것을 보여준다 — 옵션 버튼 이미지(option_image_file)
// → 옵션 연결 이미지(option_link_image) → 그 옵션값을 가진 품목의 image.
// 카페24 관리자에서 "옵션 연결 이미지"로만 등록한 상품은 option_image_file 이
// 비어 있고 품목 image 에만 나타난다(실물 — 버튼 이미지만 보면 "없음"으로 오인).
function variantImageFor(name, value) {
for (var i = 0; i < variants.length; i++) {
var v = variants[i];
if (!v.image) continue;
for (var j = 0; j < v.options.length; j++) {
if (v.options[j].name === name && v.options[j].value === value) return v.image;
}
}
return "";
}
// 옵션 정의 편집 (모달 왼쪽 열)
var html = '<div class="cf24-modal-grid"><section class="cf24-modal-col cf24-modal-col-options">' +
'<h4 class="cf24-side-title">옵션 정의</h4>' +
'<form class="cf24-side-form" id="cf24-opt-form">';
opt.options.forEach(function (o, gi) {
html += '<div class="cf24-opt-group" data-group="' + gi + '">' +
'<div class="cf24-opt-head">' +
' <label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" value="' + escHtml(o.option_name) + '" /></label>' +
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect(o.option_display_type || "S", "option_display_type") + "</label>" +
"</div>";
o.option_value.forEach(function (v, vi) {
var thumbSrc = v.option_image_file || v.option_link_image || variantImageFor(o.option_name, v.option_text);
var thumb = thumbSrc
? '<img class="cf24-opt-thumb" src="' + escHtml(thumbSrc) + '" alt="" title="' +
(v.option_image_file ? "옵션 버튼 이미지" : v.option_link_image ? "옵션 연결 이미지" : "품목 이미지") + '" />'
: '<div class="cf24-opt-thumb-empty">없음</div>';
html += '<div class="cf24-opt-value" data-value="' + vi + '">' +
'<span class="cf24-opt-thumb-slot">' + thumb + "</span>" +
'<input type="text" name="option_text" value="' + escHtml(v.option_text) + '" placeholder="옵션값 이름" />' +
'<input type="hidden" name="option_image_file" value="' + escHtml(v.option_image_file) + '" />' +
'<input type="hidden" name="option_link_image" value="' + escHtml(v.option_link_image) + '" />' +
'<label class="cf24-file-pick"><input type="file" accept="image/jpeg,image/png,image/gif,image/webp" />' +
'<span class="erp-btn erp-btn-outline">썸네일</span></label>' +
"</div>";
});
html += "</div>";
});
html += '<p class="cf24-side-note">옵션값 추가·삭제는 카페24 API 가 지원하지 않습니다(옵션 삭제 후 다시 만들어야 합니다). 썸네일은 표시방식이 「미리보기(이미지)」일 때 쇼핑몰에 보입니다.</p>' +
'<div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span>' +
' <button class="erp-btn erp-btn-primary" type="submit">옵션 저장</button></div>' +
"</form>" +
'<div class="cf24-opt-danger"><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-delete">옵션 전체 삭제</button></div>' +
"</section>";
// 품목 표 (모달 오른쪽 열) — 넓은 창이라 한 줄에 다 들어간다.
html += '<section class="cf24-modal-col cf24-modal-col-variants"><h4 class="cf24-side-title">품목 (' + variants.length + ')</h4>';
if (!variants.length) {
html += '<p class="cf24-muted">품목이 아직 조회되지 않았습니다. 잠시 뒤 창을 닫았다 다시 여세요.</p>';
} else {
html += '<div class="cf24-variants-wrap"><table class="cf24-variants"><thead><tr>' +
'<th class="cf24-v-imgcol"></th><th>옵션</th><th class="cf24-v-syscode">품목코드</th>' +
'<th class="cf24-v-code">자체 품목코드</th><th class="cf24-v-amount">추가금액</th>' +
'<th class="cf24-v-flag">진열</th><th class="cf24-v-flag">판매</th></tr></thead><tbody>';
variants.forEach(function (v) {
var label = v.options.map(function (o) { return o.value; }).join(" / ") || v.variant_code;
var amount = String(parseInt(v.additional_amount || "0", 10) || 0);
html += '<tr class="cf24-v-item" data-code="' + escHtml(v.variant_code) + '">' +
'<td class="cf24-v-imgcol">' + (v.image ? '<img class="cf24-v-img" src="' + escHtml(v.image) + '" alt="" />' : '<span class="cf24-v-img cf24-v-img-empty"></span>') + "</td>" +
'<td class="cf24-v-name" title="' + escHtml(label) + '">' + escHtml(label) + "</td>" +
'<td class="cf24-v-syscode"><button type="button" class="cf24-v-copy" data-copy-text="' + escHtml(v.variant_code) + '" title="클릭하면 품목코드를 복사합니다">' + escHtml(v.variant_code) + "</button></td>" +
'<td class="cf24-v-code"><input type="text" name="custom_variant_code" maxlength="40" value="' + escHtml(v.custom_variant_code) + '" /></td>' +
'<td class="cf24-v-amount"><input type="text" name="additional_amount" inputmode="numeric" value="' + escHtml(amount) + '" /></td>' +
'<td class="cf24-v-flag"><button type="button" class="cf24-v-toggle' + (v.display ? " is-on" : "") + '" data-flag="display" data-on="' + (v.display ? 1 : 0) + '" data-initial="' + (v.display ? 1 : 0) + '">' + (v.display ? "진열" : "미진열") + "</button></td>" +
'<td class="cf24-v-flag"><button type="button" class="cf24-v-toggle' + (v.selling ? " is-on" : "") + '" data-flag="selling" data-on="' + (v.selling ? 1 : 0) + '" data-initial="' + (v.selling ? 1 : 0) + '">' + (v.selling ? "판매" : "중지") + "</button></td>" +
"</tr>";
});
html += "</tbody></table></div>" +
'<div class="cf24-side-actions" style="margin-top:8px;"><span class="cf24-muted" id="cf24-var-msg"></span>' +
' <button type="button" class="erp-btn erp-btn-primary" id="cf24-var-save">품목 저장</button></div>';
}
html += "</section></div>";
body.innerHTML = html;
var optForm = body.querySelector("#cf24-opt-form");
trackDirty(optForm);
bindOptionImages(optForm);
optForm.addEventListener("submit", function (e) { e.preventDefault(); saveOptions(optForm); });
bindVariants();
body.querySelector("#cf24-opt-delete").addEventListener("click", deleteOptions);
}
function bindOptionImages(optForm) {
optForm.querySelectorAll(".cf24-opt-value").forEach(function (row) {
var fileEl = row.querySelector('input[type="file"]');
fileEl.addEventListener("change", function () {
var f = fileEl.files && fileEl.files[0];
if (!f) return;
var slot = row.querySelector(".cf24-opt-thumb-slot");
var msg = optForm.querySelector("#cf24-opt-msg");
setMsg(msg, "썸네일 업로드 중…", "");
var fd = new FormData();
fd.append("file", f);
apiJson("POST", "/cafe24/products/" + no + "/options/image", fd)
.then(function (data) {
row.querySelector('[name="option_image_file"]').value = data.path;
row.querySelector('[name="option_link_image"]').value = data.path;
slot.innerHTML = '<img class="cf24-opt-thumb is-dirty" src="' + escHtml(data.path) + '" alt="" />';
setMsg(msg, "썸네일을 올렸습니다. 「옵션 저장」을 눌러야 카페24에 반영됩니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "썸네일 업로드 실패: " + err.message, "is-err"); })
.then(function () { fileEl.value = ""; });
});
});
}
function saveOptions(optForm) {
var msg = optForm.querySelector("#cf24-opt-msg");
var edited = [];
var changed = false;
optForm.querySelectorAll(".cf24-opt-group[data-group]").forEach(function (g, gi) {
var orig = state.option.options[gi];
var entry = {
option_name: g.querySelector('[name="option_name"]').value.trim(),
option_display_type: g.querySelector('[name="option_display_type"]').value,
option_value: []
};
if (entry.option_name !== orig.option_name || entry.option_display_type !== (orig.option_display_type || "S")) changed = true;
g.querySelectorAll(".cf24-opt-value").forEach(function (row, vi) {
var ov = orig.option_value[vi] || {};
var item = {
option_text: row.querySelector('[name="option_text"]').value.trim(),
option_image_file: row.querySelector('[name="option_image_file"]').value,
option_link_image: row.querySelector('[name="option_link_image"]').value,
option_color: ov.option_color || ""
};
if (item.option_text !== ov.option_text || item.option_image_file !== (ov.option_image_file || "") ||
item.option_link_image !== (ov.option_link_image || "")) changed = true;
entry.option_value.push(item);
});
edited.push(entry);
});
if (!changed) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
window.erpConfirm("옵션명·옵션값을 카페24에 바로 반영합니다. 계속할까요?").then(function (ok) {
if (!ok) return;
setMsg(msg, "저장 중…", "");
apiJson("PUT", "/cafe24/products/" + no + "/options", { options: edited, option_list_type: state.option.option_list_type })
.then(function (data) {
state.option = data.option;
state.variants = data.variants || state.variants;
render();
setMsg(body.querySelector("#cf24-opt-msg"), "카페24에 반영했습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
});
}
function bindVariants() {
var saveBtn = body.querySelector("#cf24-var-save");
if (!saveBtn) return;
var wrap = body.querySelector(".cf24-variants-wrap");
trackDirty(wrap);
// 품목코드 클릭 = 클립보드 복사 (HTTPS 가 아니면 execCommand 로 대체)
wrap.querySelectorAll(".cf24-v-copy").forEach(function (btn) {
btn.addEventListener("click", function () {
var text = btn.dataset.copyText || btn.textContent;
var done = function () {
var old = btn.textContent;
btn.textContent = "복사됨";
btn.classList.add("is-copied");
setTimeout(function () { btn.textContent = old; btn.classList.remove("is-copied"); }, 1200);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done, function () { fallbackCopy(text, done); });
} else {
fallbackCopy(text, done);
}
});
});
function fallbackCopy(text, done) {
var ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { if (document.execCommand("copy")) done(); } catch (e) { /* 복사 불가 — 조용히 무시 */ }
document.body.removeChild(ta);
}
wrap.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
btn.addEventListener("click", function () {
var on = btn.dataset.on !== "1";
btn.dataset.on = on ? "1" : "0";
btn.classList.toggle("is-on", on);
btn.textContent = btn.dataset.flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
btn.classList.toggle("is-dirty", btn.dataset.on !== btn.dataset.initial);
});
});
saveBtn.addEventListener("click", function () {
var msg = body.querySelector("#cf24-var-msg");
var rows = [];
var lines = [];
wrap.querySelectorAll(".cf24-v-item[data-code]").forEach(function (tr) {
var row = { variant_code: tr.dataset.code };
var any = false;
var code = tr.querySelector('[name="custom_variant_code"]');
var amount = tr.querySelector('[name="additional_amount"]');
if (code.value !== code.defaultValue) { row.custom_variant_code = code.value.trim(); any = true; }
if (amount.value !== amount.defaultValue) { row.additional_amount = amount.value.trim() || "0"; any = true; }
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
if (btn.dataset.on !== btn.dataset.initial) { row[btn.dataset.flag] = btn.dataset.on === "1" ? "on" : "off"; any = true; }
});
if (any) {
rows.push(row);
lines.push(tr.querySelector(".cf24-v-name").textContent.trim());
}
});
if (!rows.length) { setMsg(msg, "바뀐 품목이 없습니다.", ""); return; }
window.erpConfirm("품목 " + rows.length + "건을 카페24에 바로 반영합니다. 계속할까요?\n\n" + lines.slice(0, 8).join("\n") + (lines.length > 8 ? "\n…" : ""))
.then(function (ok) {
if (!ok) return;
saveBtn.disabled = true;
setMsg(msg, "저장 중…", "");
apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: rows })
.then(function (data) {
var updated = data.updated || {};
Object.keys(updated).forEach(function (code) {
var u = updated[code];
var tr = wrap.querySelector('.cf24-v-item[data-code="' + code + '"]');
if (!tr) return;
var codeEl = tr.querySelector('[name="custom_variant_code"]');
var amountEl = tr.querySelector('[name="additional_amount"]');
if (u.custom_variant_code !== undefined) codeEl.value = u.custom_variant_code;
if (u.additional_amount !== undefined) amountEl.value = String(parseInt(u.additional_amount, 10) || 0);
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
var flag = btn.dataset.flag;
if (u[flag] === undefined) return;
var on = !!u[flag];
btn.dataset.on = on ? "1" : "0";
btn.dataset.initial = btn.dataset.on;
btn.classList.toggle("is-on", on);
btn.classList.remove("is-dirty");
btn.textContent = flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
});
// 상태 보관 — 접었다 펴도 응답값이 유지되게
state.variants.forEach(function (v) {
if (v.variant_code !== code) return;
if (u.custom_variant_code !== undefined) v.custom_variant_code = u.custom_variant_code;
if (u.additional_amount !== undefined) v.additional_amount = u.additional_amount;
if (u.display !== undefined) v.display = !!u.display;
if (u.selling !== undefined) v.selling = !!u.selling;
});
});
commitValues(wrap);
setMsg(msg, "카페24에 반영했습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); })
.then(function () { saveBtn.disabled = false; });
});
});
}
function deleteOptions() {
window.erpConfirm("옵션을 전부 삭제합니다. 카페24가 이 상품의 **품목도 모두 삭제**하며 되돌릴 수 없습니다.\n정말 삭제할까요?")
.then(function (ok) {
if (!ok) return;
body.innerHTML = '<p class="cf24-muted">삭제 중…</p>';
apiJson("DELETE", "/cafe24/products/" + no + "/options")
.then(function (data) { state.option = data.option; state.variants = []; render(); })
.catch(function (err) {
body.innerHTML = '<p class="cf24-err">삭제 실패: ' + escHtml(err.message) + "</p>";
loaded = false;
});
});
}
})();
}
};
// 공용 확인창(erp-dialog.js)은 비동기다 — 기본 confirm() 과 달리 Promise 를
+27
View File
@@ -1094,6 +1094,33 @@ def test_build_variant_updates():
pass
else:
raise AssertionError("bad code accepted")
# 드래그 정렬 → display_order (1~300)
assert store.build_variant_updates([{"variant_code": "P000000R000A", "display_order": "3"}]) == [
{"variant_code": "P000000R000A", "display_order": 3}
]
for bad in ("0", "301", "x"):
try:
store.build_variant_updates([{"variant_code": "P000000R000A", "display_order": bad}])
except ValueError:
pass
else:
raise AssertionError(bad)
def test_parse_option_values_accepts_list():
assert store.parse_option_values(["빨강, 큰 것", " 파랑 ", "", "빨강, 큰 것"]) == ["빨강, 큰 것", "파랑"]
def test_wait_for_variants_retries_until_expected():
calls = {"n": 0}
class _Client:
def get(self, path, **kw):
calls["n"] += 1
return {"variants": [{"variant_code": "A"}] * (2 if calls["n"] >= 3 else 1)}
out = products.wait_for_variants(_Client(), 7, 2, attempts=5)
assert len(out) == 2 and calls["n"] == 3
class _RouteClient: