Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-16 21:29:20 +09:00
11 changed files with 278 additions and 36 deletions
+25 -3
View File
@@ -11,6 +11,8 @@
장부 짝이 규격 다름(품명|단위 느슨 열쇠)이면 ⑤ 아님 — 꼬리표 「별칭 · 규격 다름」 · 짝은 후보
끝 덤만.
자재 · 별도계상 = 이름 · 규격 같음 하나면 맞음(체크) · 그 밖 후보.
로직 줄 후보 묶음 표시(358-1) = 이름후보(옛 324 다섯 `Match.name_cands`) · 후보마다 뜻겹침 · 출처같음 ·
책 — 차례 · 무게 · 상태는 그대로.
입력값 출처(칸마다) = 별칭 > 블록(옵션 찾기 값이 규격 읽기와 다름) > 규격(`spec_inputs`) > 자동값.
체크 = 처음 제안일 뿐 — 들어감은 [확인하고 사전조합 만들기](다 창)만. 저장 없음 · 셈은 서버 단독.
장부 = `M02_Hopyo_Ledger`(다 창) · 확인된 사전조합 = `M02_Hopyo_Build.confirmed`(다 창).
@@ -214,15 +216,35 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A
if alias and alias.get("갈래") in SKIP:
return {**out, "상태": "뺌", "뺌": alias["갈래"], "별칭": _alias_info(alias, False)}
if alias and alias.get("갈래") == "짝" and alias.get("짝"):
return {**out, **_aliased(line, ctx, alias, entry)}
if kind in hm.MATERIAL_KINDS:
got = _aliased(line, ctx, alias, entry)
elif kind in hm.MATERIAL_KINDS:
got = _material(line, ctx, full)
elif entry and entry["판정"]["글"] not in (fit.NO_NUMBER, "우리 로직 준비 중"):
got = _numeric(line, ctx, entry) if full else _brief(entry)
else:
why = "셈 중" if not ctx.calc["끝"] else fit.NO_NUMBER
got = _names_only(line, ctx, why, full)
return {**out, **(_loose(got, loose, line, ctx) if loose and full else got)}
got = _loose(got, loose, line, ctx) if loose and full else got
return {**out, **(_grouped(got, line, ctx) if full and kind not in hm.MATERIAL_KINDS else got)}
def _grouped(got: dict, line: dict, ctx: Ctx) -> dict:
"""묶음 표시(358-1) — 후보 · 이름후보(옛 324 다섯)마다 뜻겹침 · 출처같음(조항 ≥ 0.9) · 책 ·
차례 · 무게 · 상태 그대로(아무것도 숨기지 않음 · 묶음은 화면 몫)."""
mine = hm.words(line.get("이름"))
theirs = hm.fix_typo(((line.get("근거") or {}).get("출처")) or [])
table = hopyo_basis.ours()
def mark(c: dict) -> dict:
one, our = ctx.names.get(c.get("로직")), table.get(c.get("로직"))
if one is None:
return c
same = bool(theirs and our) and hm.clause(theirs, our)[0] >= 0.9
book = (our or {}).get("책", "")
return {**c, "뜻겹침": hm.near(mine, one["words"]), "출처같음": same, "책": book}
named = [mark(c) for c in hm.name_cands(line, ctx.got)]
return {**got, "후보": [mark(c) for c in got.get("후보") or []], "이름후보": named}
def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict:
+21 -3
View File
@@ -292,10 +292,14 @@ def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[st
def candidates(
line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5
line: dict[str, Any],
pool: str,
got: dict[str, Any] | None = None,
limit: int | None = 5,
fold: bool = False,
) -> list[dict[str, Any]]:
"""③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`
(None = 모두)."""
(None = 모두) · `fold` = 로직도 (이름, 규격, 단위)로 접음(옛 324 꼴)."""
me = tidy(line.get("이름"))
grams, unit = _grams(me), line.get("단위")
text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}")
@@ -313,7 +317,8 @@ def candidates(
scored.sort(key=lambda x: -x[0])
out = []
for score, one in scored:
label = one.get("로직") or (one["이름"], one.get("규격"), one["단위"]) # 로직은 접지 않음
label = (one["이름"], one.get("규격"), one["단위"])
label = label if fold else one.get("로직") or label # 로직은 접지 않음(fold 아니면)
if label in seen:
continue
seen.add(label)
@@ -323,6 +328,19 @@ def candidates(
return out
def name_cands(line: dict[str, Any], got: dict[str, Any] | None = None) -> list[dict[str, Any]]:
"""이름 닮은 로직 다섯(358-1 · 옛 324 데스크탑 판 그대로) — 이름 같음 · 씨앗(점수 9) 먼저 +
이름 점수 후보(로직도 이름 · 규격 · 단위로 접음) · 숫자 판정 차례와 따로(표시 묶음만)."""
got = got or targets()
same = [_public(x) for x in _exact(line, "로직", got)]
rest = [
c
for c in candidates(line, "로직", got, 5, fold=True)
if {k: v for k, v in c.items() if k != "점수"} not in same
]
return ([{**x, "점수": 9} for x in same] + rest)[:5]
# ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ─────────────────────
_OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)")
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4
@@ -227,6 +227,17 @@
gap: var(--spacing-4);
}
/* 고르기 창 후보 묶음(358-1) — 뜻 낱말 안 겹치는 「후보」 = 회색 · 숫자만 닮음 = 접힘 */
.m02-hopyo__cand.is-weak {
color: var(--color-text-muted);
}
.m02-hopyo__group > summary {
cursor: pointer;
color: var(--color-text-secondary);
margin-bottom: var(--spacing-4);
}
/* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */
.m02-hopyo-ledger__row.is-upper {
color: var(--color-text-muted);
@@ -92,6 +92,10 @@ export interface HopyoTarget {
/** 후보 판정(338) — 맞음 · 다른 점 · 후보 */
판정?: "맞음" | "다른 점" | "후보";
글?: string;
/** 묶음 표시(358-1 · 로직 후보만) — 품명 뜻 낱말 겹침 · 엑셀 출처 조항 같음(≥ 0.9) · 책(산림품셈 · 건설품셈) */
뜻겹침?: boolean;
출처같음?: boolean;
책?: string;
}
/** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */
@@ -124,6 +128,8 @@ export interface HopyoMatch {
체크: boolean;
대상: HopyoPart[];
후보?: HopyoTarget[];
/** 이름 닮은 로직 다섯(358-1 · 옛 324 판 그대로 · 숫자 판정 차례와 따로 · 로직 줄만) */
이름후보?: HopyoTarget[];
글?: string;
꼬리표?: string[];
품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record<string, boolean> };
@@ -1,10 +1,11 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Choose.ts
* 일위대가 실무자료 고르기 창(PLAN 338-3 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미.
* · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기](단가산출 그 행)
* · 후보 다섯 = 이름 · 키 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 글 · 근거 꼬리표(근거 맞음 초록) · 금액 비 없음
* · 고름 = [고름](한 로직) · 또는 체크 + 몫 칸 → [고른 n개 함께](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
* · [다른 로직 찾기] = `openLogicPicker` 에 찾기 글 미리 넣음(엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말)
* 일위대가 실무자료 고르기 창(PLAN 338-3 · 358 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미.
* · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀 보기](단가산출 그 행)
* · 후보 묶음(358-1 · 숨김 없음) = 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 324 다섯) / 숫자만 닮음(접힘 ·
* 뜻 낱말 안 겹치는 「후보」는 회색 끝) · 같은 이름이면 책(산림 · 건설) · 판정 꼬리표 · 근거 꼬리표(근거 맞음 초록)
* · 고름 = [후보](한 로직) · 또는 체크 + 몫 칸 → [일괄 적용(n)](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
* · [다른 로직 검색] = `openLogicPicker` 에 품명 첫 뜻 낱말 · [다른 자재 검색] = 자재 줄 · 재료비 있는 줄(358-2)
* 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감.
* ========================================================================== */
@@ -15,6 +16,7 @@ import type {
HopyoBasisHit,
HopyoLine,
HopyoLineBasis,
HopyoMatch,
HopyoQty,
HopyoTarget,
} from "./M02_MasterTemplete_Hopyo_Api";
@@ -95,13 +97,71 @@ export const qtyText = (q: HopyoQty | undefined): string =>
].join(" · ")
: "";
/** [다른 로직 찾기] 미리 넣을 글 — 엑셀 인용 조항 번호 → 없으면 품명 괄호 밖 첫 뜻 낱말 */
export function searchHint(line: Pick<HopyoLine, "이름" | "근거">): string {
const clause = line.근거?.출처?.find((x) => x[1])?.[1];
if (clause) return clause;
/** [다른 로직 검색] 미리 넣을 글 — 품명 괄호 밖 첫 뜻 낱말(358-1 · 조항 번호는 모든 낱말 맞춤이라 0건 위험) */
export function searchHint(line: Pick<HopyoLine, "이름">): string {
return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? "";
}
/** 후보 묶음 하나 — 제목 · 접힘(숫자만 닮음) · 후보 */
export interface CandGroup {
제목: string;
접힘: boolean;
후보: HopyoTarget[];
}
/** 약한 후보 — 판정 「후보」 + 품명 뜻 낱말 안 겹침(회색 · 묶음 끝) */
export const weakCand = (c: HopyoTarget): boolean => c.판정 === "후보" && c.뜻겹침 === false;
/** 후보 묶음(358-1 · 숨김 없음 · 차례는 서버 그대로) — 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 판 다섯) /
* 숫자만 닮음(접힘 · 약한 후보 뒤로) · 한 후보는 한 묶음에만 · 자재 줄 = 1위 / 그 밖 후보 */
export function candGroups(
m: Pick<HopyoMatch, "후보" | "이름후보">,
material = false,
): CandGroup[] {
const all = m.후보 ?? [];
const seen = new Set<string>();
const take = (list: HopyoTarget[]): HopyoTarget[] =>
list.filter((c) => {
const id = c.로직 ?? c.자재 ?? c.이름 ?? "";
if (seen.has(id)) return false;
seen.add(id);
return true;
});
const top = take(all.slice(0, 1));
if (material)
return [
{ 제목: "1위", 접힘: false, 후보: top },
{ 제목: "그 밖 후보", 접힘: false, 후보: take(all.slice(1)) },
].filter((g) => g.후보.length);
const named = m.이름후보 ?? [];
const source = take([...all.slice(1), ...named].filter((c) => c.출처같음));
const name = take(named);
const rest = take(all.slice(1));
rest.sort((a, b) => Number(weakCand(a)) - Number(weakCand(b)));
return [
{ 제목: "1위", 접힘: false, 후보: top },
{ 제목: "출처 같음", 접힘: false, 후보: source },
{ 제목: "이름 닮은 로직", 접힘: false, 후보: name },
{ 제목: "숫자만 닮음", 접힘: true, 후보: rest },
].filter((g) => g.후보.length);
}
/** 같은 이름 후보 — 책(산림 · 건설)을 붙여 가를 이름 */
export function sameNames(groups: CandGroup[]): Set<string> {
const count = new Map<string, number>();
for (const c of groups.flatMap((g) => g.후보))
count.set(c.이름 ?? "", (count.get(c.이름 ?? "") ?? 0) + 1);
return new Set([...count].filter(([name, n]) => name && n > 1).map(([name]) => name));
}
/** 후보 단추 글 — 「이름 [단위] · 키」 · 같은 이름이면 책 두 글자(산림 · 건설) */
export const candText = (c: HopyoTarget, twins: Set<string>): string =>
`${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`;
/** 자재 고름 — 자재 · 별도계상 줄 · 재료비 있는 단가산출 줄(358-2 · P.V.C파이프설치 등) */
export const canPickMaterial = (line: Pick<HopyoLine, "갈래" | "단가">): boolean =>
MATERIAL.has(line.갈래) || !!line.단가?.재료비;
/** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */
export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] =>
picked.map(({ c, 몫 }) => ({
@@ -113,12 +173,12 @@ export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]):
export interface ChooseOptions {
line: HopyoLine;
/** [엑셀에서 보기] — 단가산출 그 행 */
/** [엑셀 보기] — 단가산출 그 행 */
openSheet?: (행: [number, number]) => void;
done: (targets: BuildTarget[]) => void;
}
/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 다섯(하나 또는 여럿 · 몫) · 다른 로직 · 자재 찾기 */
/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 묶음(하나 또는 여럿 · 몫) · 다른 로직 · 자재 검색 */
export function openHopyoChoose(opt: ChooseOptions): void {
const { line } = opt;
const material = MATERIAL.has(line.갈래);
@@ -132,9 +192,9 @@ export function openHopyoChoose(opt: ChooseOptions): void {
opt.done(targets);
};
const picked = new Map<HopyoTarget, HTMLInputElement>();
const together = createButton({ label: "고른 0개 함께", size: "sm", disabled: true });
const together = createButton({ label: "일괄 적용(0)", size: "sm", disabled: true });
const paint = (): void => {
together.querySelector(".ui-btn__label")!.textContent = `고른 ${picked.size}개 함께`;
together.querySelector(".ui-btn__label")!.textContent = `일괄 적용(${picked.size})`;
together.disabled = picked.size < 2;
};
together.addEventListener("click", () =>
@@ -144,7 +204,9 @@ export function openHopyoChoose(opt: ChooseOptions): void {
),
),
);
const list = (line.맞춤.후보 ?? []).map((c) => {
const groups = candGroups(line.맞춤, material);
const twins = sameNames(groups);
const row = (c: HopyoTarget): HTMLElement => {
const share = el("input", {
className: "ui-input ui-input--sm m02-hopyo__times",
attrs: { type: "number", step: "0.05", min: "0", max: "1", placeholder: "몫" },
@@ -157,12 +219,12 @@ export function openHopyoChoose(opt: ChooseOptions): void {
});
const tag = basisTag(c.근거);
return el("div", {
className: "m02-hopyo__cand",
className: `m02-hopyo__cand${weakCand(c) ? " is-weak" : ""}`,
attrs: { "data-cand": c.로직 ?? c.자재 ?? "" },
children: [
box,
createButton({
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
label: candText(c, twins),
variant: "ghost",
size: "sm",
onClick: () => finish(pickedTargets([{ c, 몫: null }])),
@@ -176,6 +238,7 @@ export function openHopyoChoose(opt: ChooseOptions): void {
]
: []),
...(c.글 ? [note(c.글)] : []),
...(weakCand(c) ? [note("이름 뜻 안 겹침")] : []),
...(tag && c.근거
? [
el("span", {
@@ -188,6 +251,18 @@ export function openHopyoChoose(opt: ChooseOptions): void {
share,
],
});
};
const sections = groups.map((g) => {
const list = el("div", { className: "m02-hopyo__cands", children: g.후보.map(row) });
const title = `${g.제목} (${g.후보.length})`;
if (!g.접힘)
return el("div", { attrs: { "data-group": g.제목 }, children: [note(title), list] });
const fold = el("details", {
className: "m02-hopyo__group",
attrs: { "data-group": g.제목 },
children: [el("summary", { text: `${title} — 이름 · 출처 안 닮음 · 숫자 지문만` }), list],
});
return fold;
});
const excel = qtyText(line.맞춤.품?.엑셀);
const basis = line.근거;
@@ -199,7 +274,7 @@ export function openHopyoChoose(opt: ChooseOptions): void {
...(basis && opt.openSheet
? [
createButton({
label: "엑셀에서 보기",
label: "엑셀 보기",
variant: "ghost",
size: "sm",
onClick: () => opt.openSheet?.(basis.행),
@@ -208,23 +283,37 @@ export function openHopyoChoose(opt: ChooseOptions): void {
: []),
],
});
const more = createButton({
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
const findMaterial = createButton({
label: "다른 자재 검색…",
size: "sm",
onClick: () => {
close();
if (material) pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }]));
else
openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, {
query: searchHint(line),
});
pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }]));
},
});
const findLogic = createButton({
label: "다른 로직 검색…",
size: "sm",
onClick: () => {
close();
openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, {
query: searchHint(line),
});
},
});
const more = [
...(material ? [] : [findLogic]),
...(canPickMaterial(line) ? [findMaterial] : []),
];
body.append(
...(head.childElementCount ? [head] : []),
note(list.length ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [함께]" : "가까운 것 없음"),
el("div", { className: "m02-hopyo__cands", children: list }),
el("div", { className: "m02-hopyo__bar", children: [together, more] }),
note(
groups.length
? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [일괄 적용]"
: "가까운 것 없음 — 아래 검색",
),
...sections,
el("div", { className: "m02-hopyo__bar", children: [together, ...more] }),
);
},
});
@@ -171,6 +171,42 @@ let out0 = null;
word: [choose.builtWord({ 다시씀: false }), choose.builtWord({ 다시씀: true })],
qty: choose.qtyText(lines[1].맞춤.품.엑셀),
target: choose.targetText({ 호표: 46, 단위: "㎡" }),
// 358-1 후보 묶음 — 1위 / 출처 같음 / 이름 닮은 로직 / 숫자만 닮음(약한 후보 끝) · 같은 이름 책
groups: (() => {
const m = {
후보: [
{ 로직: "A", 이름: "되메우기", 판정: "맞음", 뜻겹침: true, 책: "산림품셈" },
{ 로직: "B", 이름: "야면석 채집", 판정: "후보", 뜻겹침: false },
{
로직: "C",
이름: "되메우기",
판정: "후보",
뜻겹침: true,
출처같음: true,
책: "건설품셈",
},
{ 로직: "D", 이름: "표토펴기", 판정: "다른 점", 뜻겹침: false },
],
이름후보: [
{ 로직: "A", 이름: "되메우기" },
{ 로직: "E", 이름: "되메우기(노상재)" },
],
};
const g = choose.candGroups(m);
const twins = choose.sameNames(g);
return {
groups: g.map((x) => [x.제목, x.접힘, x.후보.map((c) => c.로직)]),
text: g.flatMap((x) => x.후보).map((c) => choose.candText(c, twins)),
material: choose
.candGroups({ 후보: [{ 자재: "M1" }, { 자재: "M2" }] }, true)
.map((x) => x.제목),
pick: [
choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: 10 } }),
choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: null } }),
choose.canPickMaterial({ 갈래: "별도계상", 단가: {} }),
],
};
})(),
},
ledger: (() => {
const rows = input.ledger;
@@ -0,0 +1,13 @@
"""359-3 덤(요구 235-27) — B05 배수유역 버튼 글 명사형."""
from pathlib import Path
LOCALE = Path(__file__).resolve().parents[2] / "ui_template" / "ui_template_locale_b2.ts"
def test_drainage_buttons_are_nouns():
text = LOCALE.read_text(encoding="utf-8")
assert 'B05_Drainage_Btn_Analyze: ["유역 재분할",' in text
assert 'B05_Drainage_Btn_DeleteSelected: ["관 삭제",' in text
assert "유역 다시 나누기" not in text
assert "선택한 관 삭제" not in text
+15
View File
@@ -80,3 +80,18 @@ def test_씨앗_끔_1순위(no_seeds):
def test_원본_그대로_셈_무게(run):
assert master_hash() == run["원본"]
assert run["결과"]["시간"]["북부청임도"] <= 300 # 북부청 임도 맞춤 셈 ≤ 5 분
def test_이름후보_묶음_덧(run):
"""358-1 — 이름후보(옛 324 다섯) = 묶음 덧 · 숫자 차례 그대로 → 다섯 안 ∪ 이름후보 ≥ 다섯 안."""
have = [
r for r in run["결과"]["줄"].values() if r["lab"]["무리"] == "사람" and r["lab"]["정답"]
]
got = hm.targets()
five = [set(r["셈"]["차례"][:5]) for r in have]
named = [{c["로직"] for c in hm.name_cands(r["lab"], got)} for r in have]
near = sum(bool(f & set(r["lab"]["정답"])) for f, r in zip(five, have, strict=True))
both = sum(
bool((f | n) & set(r["lab"]["정답"])) for f, n, r in zip(five, named, have, strict=True)
)
assert both >= near >= 122
+23
View File
@@ -245,3 +245,26 @@ def test_뜻_낱말이_안_겹치면_후보_아님():
assert got["상태"] == "없음" and got["top"] == []
fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"}
assert hm.candidates(fill, "로직")[0]["이름"] == "되메우기" # 한쪽 낱말에 듦 = 겹침
# ── 이름 닮은 로직(358-1 · 옛 324 데스크탑 다섯 그대로) ─────────────────────
def test_이름후보_옛_데스크탑_다섯():
fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"}
got = [c["로직"] for c in hm.name_cands(fill)]
assert got == ["GF000113", "GF000194", "GC000108", "GC000109", "GC000451"] # 되메우기 다섯
dowel = {"이름": "다웰바설치", "규격": "Φ32×1000", "단위": "EA", "갈래": "단가산출"}
assert [c["로직"] for c in hm.name_cands(dowel)][:3] == ["GC000226", "GF000201", "GF000202"]
soil = {"이름": "잔토처리(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"}
assert hm.name_cands(soil) == [] # 뜻 낱말 안 겹치면 후보 아님 그대로
same = {"이름": "되메우기", "규격": "", "단위": "㎥", "갈래": "단가산출"}
assert hm.name_cands(same)[0] == {**hm.name_cands(same)[0], "로직": "GF000113", "점수": 9}
def test_이름후보_뜻겹침_출처_표시(client):
_ready(client)
dig = _hopyo(client, 3)["구조물터파기(인력100%)"]
assert dig["이름후보"] and all({"뜻겹침", "출처같음", "책"} <= set(c) for c in dig["이름후보"])
assert dig["후보"][0]["출처같음"] is True and dig["후보"][0]["책"] == "건설품셈" # 3-2-1
assert [c["로직"] for c in dig["후보"]][0] == "GC000083" # 차례 · 판정 그대로(표시만)
mat = _hopyo(client, 1)["레미콘(25-21-120)"]
assert "이름후보" not in mat # 자재 줄은 로직 이름후보 없음
+10 -1
View File
@@ -136,7 +136,16 @@ def test_확인_창_체크한_줄만_몸():
def test_고르기_창_도우미():
got = _check()["choose"]
assert got["hint"] == ["6-1-1", "고임돌"] # 인용 조항 번호 → 없으면 품명 첫 뜻 낱말
assert got["hint"] == ["레미콘타설", "고임돌"] # 품명 첫 뜻 낱말(358-1 · 조항 번호 아님)
assert got["groups"]["groups"] == [
["1위", False, ["A"]],
["출처 같음", False, ["C"]],
["이름 닮은 로직", False, ["E"]],
["숫자만 닮음", True, ["D", "B"]], # 뜻 낱말 안 겹치는 「후보」 = 끝
]
assert got["groups"]["text"][:2] == ["되메우기 · A · 산림", "되메우기 · C · 건설"]
assert got["groups"]["material"] == ["1위", "그 밖 후보"]
assert got["groups"]["pick"] == [True, False, True] # 재료비 있는 단가산출 줄도 자재 검색
assert got["picked"] == [
{"로직": "GC000206", "입력값": {}, "배": 1, "몫": None},
{"로직": "GF000211", "입력값": {}, "배": 0.1, "몫": 0.1},
+2 -2
View File
@@ -32,12 +32,12 @@ export const ui_locales_b2 = {
],
// 버튼 이름만으로 무엇이 바뀌는지 알기 어려워 동작을 이름·툴팁에 함께 적는다
// (2026-08-19 사용자 지시 4).
B05_Drainage_Btn_Analyze: ["유역 다시 나누기", "Recompute sub-basins"],
B05_Drainage_Btn_Analyze: ["유역 재분할", "Recompute sub-basins"],
B05_Drainage_Btn_Analyze_Tip: [
"B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관이 부족한 구간은 자동으로 보충합니다. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.",
"Reloads the B04 drainage analysis and re-splits sub-basins around the current culverts, adding culverts where spacing requires. Run the analysis in B04 first if none exists.",
],
B05_Drainage_Btn_DeleteSelected: ["선택한 관 삭제", "Delete selected culvert"],
B05_Drainage_Btn_DeleteSelected: ["관 삭제", "Delete selected culvert"],
B05_Drainage_Btn_DeleteSelected_Tip: [
"지도에서 고른 배관 한 개를 지웁니다. 관을 먼저 눌러 고른 뒤에 쓸 수 있습니다.",
"Removes the culvert selected on the map. Select a culvert marker first.",