fix(M02): 면적 오른쪽 패널 빈 칸 = 수량 없음 — 서버 area_values 와 같은 규칙(식 칸 = 계산 수 · 그 밖 = 칸 값 · 빈 · 글 빠짐) · 「수량 A1 = 빈 칸」 · 반영 — (PLAN 39-8 ②)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
This commit is contained in:
@@ -76,7 +76,7 @@ export interface SpreadsheetHandle {
|
||||
setDoc(book: Workbook): void;
|
||||
/** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */
|
||||
recalc(): CalcValues;
|
||||
/** 알약 키 → 연결 칸 값(빈 칸 0) */
|
||||
/** 알약 키 → 연결 칸 값(빈 칸 = 값 · 식 없는 칸은 빠짐 — 「수량 없음」) */
|
||||
getLinkedValues(): Record<string, CalcValue>;
|
||||
/** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 ·
|
||||
* 주소가 틀리거나 읽기만이면 false */
|
||||
@@ -289,7 +289,9 @@ export function createSpreadsheet(
|
||||
for (const s of ctx.book.시트)
|
||||
for (const l of s.연결 ?? []) {
|
||||
const p = parseA1(l.칸);
|
||||
if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
|
||||
const cell = s.칸[l.칸];
|
||||
const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");
|
||||
if (p && !empty) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ const TEXT = {
|
||||
close: "닫기",
|
||||
now: "지금 연결",
|
||||
none: "연결 없음",
|
||||
empty: "빈 칸",
|
||||
picked: "지금 고른 칸으로 ({value})",
|
||||
address: "칸 주소",
|
||||
hint: "예 B6",
|
||||
@@ -51,6 +52,7 @@ function linkedText(opts: AreaLinkOptions): string | null {
|
||||
.find((l) => l.키 === opts.key);
|
||||
if (!link) return null;
|
||||
const value = opts.sheet.getLinkedValues()[opts.key];
|
||||
if (!value) return `${link.칸} = ${TEXT.empty}`;
|
||||
return [link.칸, "=", cellText(value), opts.info.단위 ?? ""].join(" ").trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -47,13 +47,27 @@ export interface AreaPanel {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/** 시트 알약 값 → 수만(`{키: 수}`) — 수 아닌 칸은 빠짐(「수량 없음」) */
|
||||
/** 칸 값 → 수(쉼표 뗌) — 빈 글 · 글 · 참거짓은 null(서버 `M02_Labor_Doc._number` 와 같음) */
|
||||
function toNumber(raw: unknown): number | null {
|
||||
if (raw === null || raw === undefined || typeof raw === "boolean") return null;
|
||||
const text = String(raw).replace(/,/g, "").trim();
|
||||
const n = Number(text);
|
||||
return text !== "" && Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** 시트 알약 값 → `{키: 수}` — 서버 `M02_Labor_Doc.area_values` 와 같은 규칙(짝): 식 칸 = 계산 값의 수 ·
|
||||
* 그 밖 = 칸 값 · 빈 칸 · 글은 빠짐(「수량 없음」 · 반영 「—」) */
|
||||
function quantities(sheet: SpreadsheetHandle | null): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const [key, v] of Object.entries(sheet?.getLinkedValues() ?? {})) {
|
||||
const n = "수" in v ? Number(v.수) : NaN;
|
||||
if (Number.isFinite(n)) out[key] = n;
|
||||
}
|
||||
if (!sheet) return out;
|
||||
const values = sheet.getLinkedValues();
|
||||
for (const s of sheet.getDoc().시트)
|
||||
for (const l of s.연결 ?? []) {
|
||||
const cell = s.칸[l.칸];
|
||||
const v = values[l.키];
|
||||
const n = toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값);
|
||||
if (n !== null) out[l.키] = n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,13 +33,18 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
|
||||
const num = (v: number): string =>
|
||||
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · 없으면 빈 글 */
|
||||
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) ·
|
||||
* 이은 칸이 비었으면 「수량 A1 = 빈 칸」(수량 없음 · 서버와 같음) · 아니면 빈 글 */
|
||||
export function qtyText(
|
||||
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
|
||||
unit: string,
|
||||
cells: ReadonlyMap<string, string> = new Map(),
|
||||
): string {
|
||||
if (!line || line.수량 === null || line.수량 === undefined) return "";
|
||||
if (!line) return "";
|
||||
if (line.수량 === null || line.수량 === undefined) {
|
||||
const at = line.알약 ? cells.get(line.알약) : undefined;
|
||||
return at ? `${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}` : "";
|
||||
}
|
||||
const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`;
|
||||
if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`;
|
||||
const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell");
|
||||
|
||||
@@ -75,6 +75,7 @@ const TEXT = {
|
||||
In_Memo: ["메모", "Memo"],
|
||||
In_Qty: ["수량", "Quantity"],
|
||||
In_QtyCell: ["면적산출 칸", "Area sheet cell"],
|
||||
In_QtyEmpty: ["빈 칸", "empty cell"],
|
||||
In_QtyHand: ["손 입력", "Manual"],
|
||||
In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"],
|
||||
In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"],
|
||||
|
||||
@@ -47,3 +47,44 @@ def test_칸_지정_모달은_setLink_만():
|
||||
assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로
|
||||
assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link
|
||||
assert "saveTemplate" not in link # 저장은 [저장]
|
||||
|
||||
|
||||
def test_빈_칸은_수량_없음_서버와_같음():
|
||||
"""39-8 ② — 빈 칸에 이은 알약 = 수량 없음(반영 「—」 · 「수량 A1 = 빈 칸」) · 0 칸 = 0 · 값 칸 = 값."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
doc = {
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"칸": {"A2": {"값": 0}, "A3": {"값": "1,250"}, "A4": {"값": "글"}},
|
||||
"연결": [
|
||||
{"키": k, "칸": c}
|
||||
for k, c in (("빈", "A1"), ("영", "A2"), ("값", "A3"), ("글", "A4"))
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
assert {k: float(v) for k, v in labor.area_values(doc).items()} == {"영": 0, "값": 1250}
|
||||
panel = _text("M02_MasterTemplete_AreaPanel.ts")
|
||||
body = panel.split("function quantities")[1].split("\n}\n")[0]
|
||||
assert 'toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값)' in body
|
||||
assert 'String(raw).replace(/,/g, "").trim()' in panel and 'typeof raw === "boolean"' in panel
|
||||
sheet = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8")
|
||||
assert 'const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");' in sheet
|
||||
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
|
||||
assert '${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}' in rows
|
||||
assert "if (!value) return `${link.칸} = ${TEXT.empty}`;" in _text(
|
||||
"M02_MasterTemplete_AreaLink.ts"
|
||||
)
|
||||
|
||||
|
||||
def test_카드_알약_취향은_계정_받은_뒤_올림():
|
||||
"""39-8 ③ — 화면이 뜰 때 브라우저 옛 값이 계정 값을 덮지 않게 · 카드는 열 때마다 취향을 다시 읽음."""
|
||||
state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
|
||||
push = state.split("function pushUiPrefs")[1].split("export function syncUiPrefs")[0]
|
||||
assert "void (prefsPull ?? Promise.resolve())" in push and "prefs: localPrefs()" in push
|
||||
assert "prefsPull = pullUiPrefs();" in state
|
||||
cards = _text("M02_MasterTemplete_ItemCards.ts")
|
||||
load = cards.split("async function load(")[1]
|
||||
assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in load
|
||||
|
||||
@@ -131,6 +131,7 @@ console.log(JSON.stringify([
|
||||
m.qtyText({ 수량: 20, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡"),
|
||||
m.qtyText({ 수량: 3, 수량출처: "손", 알약: "돌쌓기|㎡" }, "㎡", cells),
|
||||
m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡", cells),
|
||||
m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡"),
|
||||
]));
|
||||
"""
|
||||
|
||||
@@ -148,5 +149,6 @@ def test_수량_글은_이은_칸_주소를_보임() -> None:
|
||||
"수량 B6 = 20 ㎡",
|
||||
"수량 면적산출 칸 = 20 ㎡",
|
||||
"수량 손 입력 3 ㎡",
|
||||
"수량 B6 = 빈 칸", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음)
|
||||
"",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user