auto: 2026-09-30 07:36 (ESD_LAPTOP)
This commit is contained in:
@@ -27,6 +27,7 @@ import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
||||
import {
|
||||
choiceLabel,
|
||||
DEFAULT_WEIGHTS,
|
||||
NO_WEIGHT_LOCKS,
|
||||
initialRouteSnapshot,
|
||||
normalizeRouteInitial,
|
||||
readInitialChoice,
|
||||
@@ -85,6 +86,11 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
let opening: RouteEditSnapshot | null = null;
|
||||
let edited = false;
|
||||
let busy = false;
|
||||
/** 비중 · 고정 — 모달을 닫았다 열어도 이 편집 창이 사는 동안 남는다. */
|
||||
const weightMemory = {
|
||||
weights: null as RouteInitialWeights | null,
|
||||
locks: { ...NO_WEIGHT_LOCKS },
|
||||
};
|
||||
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */
|
||||
let planRead: Promise<RoutePlanResponse> | null = null;
|
||||
|
||||
@@ -130,6 +136,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
criteria: choice?.criteriaOverrides ?? base.criteria,
|
||||
defaults,
|
||||
weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) },
|
||||
weightMemory,
|
||||
planMinRadiusM: params.planMinRadiusM(),
|
||||
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
|
||||
current: opening!.planned.map(([x, y]) => [x, y]),
|
||||
|
||||
@@ -51,6 +51,68 @@ export function rebalanceWeights(
|
||||
return { expected: out.expected, earthwork: out.earthwork, grade: out.grade };
|
||||
}
|
||||
|
||||
/** 비중마다 고정 표시 — 고정한 값은 다른 비중을 움직여도 안 바뀐다. */
|
||||
export type WeightLocks = Record<WeightKey, boolean>;
|
||||
|
||||
export const NO_WEIGHT_LOCKS: WeightLocks = { expected: false, earthwork: false, grade: false };
|
||||
|
||||
const lockedKeys = (locks: WeightLocks): WeightKey[] => WEIGHT_KEYS.filter((name) => locks[name]);
|
||||
|
||||
/** 나머지 둘이 고정돼 저절로 정해지는 비중 — 없으면 null. 이 비중은 잠김으로 보인다. */
|
||||
export function derivedWeightKey(locks: WeightLocks): WeightKey | null {
|
||||
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
|
||||
return free.length === 1 ? free[0] : null;
|
||||
}
|
||||
|
||||
/** 이 비중의 고정 단추를 새로 누를 수 있나 — 셋 다 고정은 막는다(마지막 하나는 저절로 잠김).
|
||||
* 이미 고정한 것을 푸는 것은 늘 된다. */
|
||||
export function canLockWeight(locks: WeightLocks, key: WeightKey): boolean {
|
||||
return locks[key] || lockedKeys(locks).length < 2;
|
||||
}
|
||||
|
||||
/** 슬라이더로 한 비중을 옮긴다 — 고정한 것은 그대로, 안 고정한 것끼리 합 100 을 맞춘다.
|
||||
* 고정하지 않은 것이 셋이면 나머지 둘이 지금 비율대로(`rebalanceWeights`), 둘이면 다른 하나가 남는 몫을
|
||||
* 받고, 이 비중 혼자면(나머지 둘 고정) 값이 정해져 있어 안 움직인다. 값은 남는 몫 안으로 잘린다. */
|
||||
export function moveWeight(
|
||||
weights: RouteInitialWeights,
|
||||
locks: WeightLocks,
|
||||
key: WeightKey,
|
||||
value: number,
|
||||
): RouteInitialWeights {
|
||||
if (locks[key]) return { ...weights };
|
||||
const fixedSum = lockedKeys(locks).reduce((sum, name) => sum + weights[name], 0);
|
||||
const room = 100 - fixedSum;
|
||||
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
|
||||
if (free.length === WEIGHT_KEYS.length) return rebalanceWeights(weights, key, value);
|
||||
const out = { ...weights };
|
||||
if (free.length === 1) {
|
||||
out[key] = room;
|
||||
return out;
|
||||
}
|
||||
const other = free.find((name) => name !== key)!;
|
||||
const next = Math.min(room, Math.max(0, Math.round(Number.isFinite(value) ? value : 0)));
|
||||
out[key] = next;
|
||||
out[other] = room - next;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 값 칸에 직접 넣은 숫자 — 0~100 정수이고 고정 규칙 아래 합 100 을 맞출 수 있을 때만 받는다.
|
||||
* 못 맞추면 `ok: false`(칸에 빨간 테두리 · 서버에 안 보냄). 고정한 비중은 고칠 수 없다. */
|
||||
export function inputWeight(
|
||||
weights: RouteInitialWeights,
|
||||
locks: WeightLocks,
|
||||
key: WeightKey,
|
||||
value: number,
|
||||
): { ok: boolean; weights: RouteInitialWeights } {
|
||||
const same = { ok: false, weights: { ...weights } };
|
||||
if (locks[key] || !Number.isInteger(value) || value < 0 || value > 100) return same;
|
||||
const room = 100 - lockedKeys(locks).reduce((sum, name) => sum + weights[name], 0);
|
||||
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
|
||||
if (free.length === 1) return value === room ? { ok: true, weights: { ...weights } } : same;
|
||||
if (value > room) return same;
|
||||
return { ok: true, weights: moveWeight(weights, locks, key, value) };
|
||||
}
|
||||
|
||||
/** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 null. */
|
||||
export interface InitialMetrics {
|
||||
lengthM: number | null;
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
CRITERIA_KEYS,
|
||||
LOGIC_LABEL,
|
||||
metricsLine,
|
||||
rebalanceWeights,
|
||||
canLockWeight,
|
||||
derivedWeightKey,
|
||||
inputWeight,
|
||||
moveWeight,
|
||||
relaxedLine,
|
||||
violationsLine,
|
||||
violationText,
|
||||
@@ -33,6 +36,7 @@ import {
|
||||
type InitialChoice,
|
||||
type InitialCriteria,
|
||||
type MapLayer,
|
||||
type WeightLocks,
|
||||
type WeightKey,
|
||||
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
|
||||
@@ -55,6 +59,11 @@ const WEIGHT_NAME: Record<WeightKey, string> = {
|
||||
grade: "기울기",
|
||||
};
|
||||
|
||||
/** 고정 단추 그림 — 자물쇠(글자 색을 따라 밝음 · 어두움에서 보인다). */
|
||||
const LOCK_ICON = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3.5" y="7" width="9" height="6.5" rx="1.2" /><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2" /></svg>`;
|
||||
|
||||
/** 공통 제약 칸 — 표시 이름 · 단위 · 서버가 준 값(`InitialCriteria`)에서 그 칸을 꺼내는 법. */
|
||||
const CRITERIA_FIELD: Record<
|
||||
CriteriaKey,
|
||||
@@ -92,6 +101,8 @@ export interface RecommendParams {
|
||||
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — 빈 칸 placeholder. 여기서 채워 간다. */
|
||||
defaults: Partial<Record<CriteriaKey, number>>;
|
||||
weights: RouteInitialWeights;
|
||||
/** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */
|
||||
weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks };
|
||||
planMinRadiusM: number;
|
||||
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
|
||||
expected: Vertex[];
|
||||
@@ -108,7 +119,10 @@ export interface RecommendParams {
|
||||
|
||||
export function openRecommendModal(params: RecommendParams): void {
|
||||
let logic: RouteInitialLogic = "follow";
|
||||
let weights: RouteInitialWeights = { ...params.weights };
|
||||
const { locks } = params.weightMemory;
|
||||
let weights: RouteInitialWeights = { ...(params.weightMemory.weights ?? params.weights) };
|
||||
/** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */
|
||||
const invalid = new Set<WeightKey>();
|
||||
let preview: InitialChoice | null = null;
|
||||
let busy = false;
|
||||
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
|
||||
@@ -156,12 +170,14 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
<div class="b05-routerec__weights" hidden>
|
||||
<div class="b05-routerec__bars">
|
||||
${WEIGHT_KEYS.map(
|
||||
(
|
||||
key,
|
||||
) => `<label class="b05-routerec__bar"><output data-weight-out="${key}"></output>
|
||||
(key) => `<div class="b05-routerec__bar">
|
||||
<input type="number" class="b05-routerec__num" min="0" max="100" step="1"
|
||||
inputmode="numeric" data-weight-num="${key}" aria-label="${WEIGHT_NAME[key]} 비중 값" />
|
||||
<input type="range" min="0" max="100" step="1" data-weight="${key}"
|
||||
aria-label="${WEIGHT_NAME[key]} 비중" />
|
||||
<span>${WEIGHT_NAME[key]}</span></label>`,
|
||||
<button type="button" class="b05-routerec__lock" data-weight-lock="${key}"
|
||||
aria-pressed="false" aria-label="${WEIGHT_NAME[key]} 비중 고정">${LOCK_ICON}</button>
|
||||
<span>${WEIGHT_NAME[key]}</span></div>`,
|
||||
).join("")}
|
||||
</div>
|
||||
<span class="b05-routerec__sum" data-weight-sum></span>
|
||||
@@ -232,9 +248,31 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
pill.disabled = busy;
|
||||
});
|
||||
weightsBox.hidden = logic !== "weighted";
|
||||
const derived = derivedWeightKey(locks);
|
||||
for (const key of WEIGHT_KEYS) {
|
||||
q<HTMLInputElement>(`[data-weight="${key}"]`).value = String(weights[key]);
|
||||
q<HTMLOutputElement>(`[data-weight-out="${key}"]`).value = `${weights[key]}`;
|
||||
const slider = q<HTMLInputElement>(`[data-weight="${key}"]`);
|
||||
const num = q<HTMLInputElement>(`[data-weight-num="${key}"]`);
|
||||
const lock = q<HTMLButtonElement>(`[data-weight-lock="${key}"]`);
|
||||
// 고정했거나 나머지 둘이 고정돼 저절로 정해진 비중은 잠김 — 움직이지 않는다.
|
||||
const shut = locks[key] || derived === key;
|
||||
slider.value = String(weights[key]);
|
||||
slider.disabled = busy || shut;
|
||||
// 넣다 만 틀린 글은 지우지 않는다 — 빨간 테두리로 남겨 고치게 한다.
|
||||
if (!invalid.has(key)) num.value = String(weights[key]);
|
||||
num.disabled = busy || shut;
|
||||
num.classList.toggle("is-invalid", invalid.has(key));
|
||||
num.setAttribute("aria-invalid", String(invalid.has(key)));
|
||||
lock.setAttribute("aria-pressed", String(shut));
|
||||
lock.classList.toggle("is-on", shut);
|
||||
lock.classList.toggle("is-auto", derived === key);
|
||||
// 셋 다 고정은 막는다 — 마지막 하나는 고정 단추가 꺼지고 잠김으로만 보인다.
|
||||
lock.disabled = busy || !canLockWeight(locks, key) || derived === key;
|
||||
lock.title =
|
||||
derived === key
|
||||
? "나머지 둘이 고정돼 저절로 정해집니다"
|
||||
: locks[key]
|
||||
? "고정 풀기"
|
||||
: "이 값 고정 — 다른 비중을 움직여도 안 바뀝니다";
|
||||
}
|
||||
q<HTMLElement>("[data-weight-sum]").textContent =
|
||||
`합 ${weights.expected + weights.earthwork + weights.grade}`;
|
||||
@@ -245,8 +283,8 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
input.disabled = busy;
|
||||
});
|
||||
rerun.hidden = !dirty;
|
||||
rerun.disabled = busy;
|
||||
applyButton.disabled = busy || dirty || !preview;
|
||||
rerun.disabled = busy || invalid.size > 0;
|
||||
applyButton.disabled = busy || dirty || !preview || invalid.size > 0;
|
||||
line("metrics").textContent = preview
|
||||
? preview.metrics
|
||||
? metricsLine(preview.metrics)
|
||||
@@ -269,6 +307,10 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
if (logic === "weighted" && invalid.size > 0) {
|
||||
showToast("비중 값이 합 100 을 못 맞춥니다 — 빨간 칸을 고쳐 주세요.", "error");
|
||||
return;
|
||||
}
|
||||
const mine = ++seq;
|
||||
const sentWeights = logic === "weighted" ? { ...weights } : null;
|
||||
const criteria = currentCriteria();
|
||||
@@ -314,17 +356,59 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
void run();
|
||||
});
|
||||
});
|
||||
const remember = (): void => {
|
||||
params.weightMemory.weights = { ...weights };
|
||||
};
|
||||
body.querySelectorAll<HTMLInputElement>("[data-weight]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
weights = rebalanceWeights(
|
||||
weights = moveWeight(
|
||||
weights,
|
||||
locks,
|
||||
input.dataset.weight as WeightKey,
|
||||
Number(input.value),
|
||||
);
|
||||
invalid.clear();
|
||||
remember();
|
||||
dirty = preview !== null;
|
||||
render();
|
||||
});
|
||||
});
|
||||
// 값 칸 — 0~100 정수 · 고정 규칙 아래 합 100 을 못 맞추면 받지 않고 빨간 테두리.
|
||||
body.querySelectorAll<HTMLInputElement>("[data-weight-num]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const key = input.dataset.weightNum as WeightKey;
|
||||
const raw = input.value.trim();
|
||||
const result = inputWeight(weights, locks, key, raw === "" ? Number.NaN : Number(raw));
|
||||
if (result.ok) {
|
||||
weights = result.weights;
|
||||
invalid.clear();
|
||||
remember();
|
||||
dirty = preview !== null;
|
||||
// 다른 칸의 글도 새 값으로 — 이 칸은 넣은 그대로 둔다.
|
||||
body.querySelectorAll<HTMLInputElement>("[data-weight-num]").forEach((other) => {
|
||||
if (other !== input)
|
||||
other.value = String(weights[other.dataset.weightNum as WeightKey]);
|
||||
});
|
||||
} else {
|
||||
invalid.add(key);
|
||||
}
|
||||
render();
|
||||
});
|
||||
// 칸을 떠날 때 맞는 값이면 글을 값으로 다듬는다(예: 070 → 70).
|
||||
input.addEventListener("blur", () => {
|
||||
if (!invalid.has(input.dataset.weightNum as WeightKey)) render();
|
||||
});
|
||||
});
|
||||
// 고정 단추 — 지금 값을 붙든다. 셋 다 고정은 막혀 있다(단추가 꺼짐).
|
||||
body.querySelectorAll<HTMLButtonElement>("[data-weight-lock]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const key = button.dataset.weightLock as WeightKey;
|
||||
if (!canLockWeight(locks, key)) return;
|
||||
locks[key] = !locks[key];
|
||||
invalid.clear();
|
||||
render();
|
||||
});
|
||||
});
|
||||
criteriaInputs.forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
dirty = preview !== null;
|
||||
|
||||
@@ -163,13 +163,73 @@
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.b05-routerec__bar output {
|
||||
/* 값 칸 — 슬라이더 옆에서 숫자를 직접 넣는다. 합 100 을 못 맞추면 빨간 테두리. */
|
||||
.b05-routerec__num {
|
||||
width: 100%;
|
||||
min-width: 3.2em;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.b05-routerec__bar span {
|
||||
.b05-routerec__num::-webkit-outer-spin-button,
|
||||
.b05-routerec__num::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.b05-routerec__num:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.b05-routerec__num.is-invalid {
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
box-shadow: 0 0 0 1px var(--color-danger, #dc2626);
|
||||
}
|
||||
|
||||
/* 고정 단추 — 자물쇠. 눌린 것 = 고정 · 저절로 정해진 비중은 눌린 채 꺼져(잠김 표시). */
|
||||
.b05-routerec__lock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-routerec__lock:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.b05-routerec__lock.is-on {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-on-primary, #fff);
|
||||
}
|
||||
|
||||
.b05-routerec__lock:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.b05-routerec__lock.is-auto {
|
||||
opacity: 0.8;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.b05-routerec__bar > span {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
|
||||
@@ -255,6 +255,49 @@ trail.commit(step(1, { logic: "earthwork" }));
|
||||
trail.commit(step(2, null));
|
||||
const historyRecs = [trail.undo(), trail.undo(), trail.redo(), trail.redo()].map((one) => one.rec);
|
||||
|
||||
// ⑧ 비중 고정(계획서 26-2c) — 고정한 값은 안 움직이고 나머지가 합 100 을 맞춘다.
|
||||
const W = { expected: 70, earthwork: 15, grade: 15 };
|
||||
const L = (...keys) => ({ expected: false, earthwork: false, grade: false, ...Object.fromEntries(keys.map((k) => [k, true])) });
|
||||
const sweep = [];
|
||||
for (const locks of [L(), L("expected"), L("earthwork"), L("grade"), L("expected", "grade")]) {
|
||||
for (const key of ["expected", "earthwork", "grade"]) {
|
||||
for (let v = -5; v <= 105; v += 5) {
|
||||
const out = model.moveWeight(W, locks, key, v);
|
||||
sweep.push({
|
||||
sum: out.expected + out.earthwork + out.grade,
|
||||
lockedSame: Object.keys(locks).every((k) => !locks[k] || out[k] === W[k]),
|
||||
range: [out.expected, out.earthwork, out.grade].every((n) => Number.isInteger(n) && n >= 0 && n <= 100),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const weightLocks = {
|
||||
freeThree: model.moveWeight(W, L(), "expected", 40),
|
||||
lockExpected: model.moveWeight(W, L("expected"), "earthwork", 20),
|
||||
lockExpectedClamp: model.moveWeight(W, L("expected"), "earthwork", 90),
|
||||
twoLocked: model.moveWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 50),
|
||||
movingLocked: model.moveWeight(W, L("expected"), "expected", 10),
|
||||
derived: [model.derivedWeightKey(L()), model.derivedWeightKey(L("expected")), model.derivedWeightKey(L("expected", "earthwork"))],
|
||||
canLock: {
|
||||
none: model.canLockWeight(L(), "grade"),
|
||||
one: model.canLockWeight(L("expected"), "grade"),
|
||||
two: model.canLockWeight(L("expected", "earthwork"), "grade"),
|
||||
unlockOfTwo: model.canLockWeight(L("expected", "earthwork"), "expected"),
|
||||
},
|
||||
input: {
|
||||
free: model.inputWeight(W, L(), "earthwork", 55),
|
||||
lockedOk: model.inputWeight(W, L("expected"), "earthwork", 20),
|
||||
lockedTooBig: model.inputWeight(W, L("expected"), "earthwork", 40),
|
||||
fraction: model.inputWeight(W, L(), "earthwork", 1.5),
|
||||
negative: model.inputWeight(W, L(), "earthwork", -1),
|
||||
over: model.inputWeight(W, L(), "earthwork", 101),
|
||||
nan: model.inputWeight(W, L(), "earthwork", Number.NaN),
|
||||
onLocked: model.inputWeight(W, L("earthwork"), "earthwork", 15),
|
||||
derivedSame: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 10),
|
||||
derivedOther: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 30),
|
||||
},
|
||||
};
|
||||
|
||||
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
|
||||
model.writeInitialChoice("p-1", plain);
|
||||
const readBack = model.readInitialChoice("p-1");
|
||||
@@ -296,5 +339,7 @@ process.stdout.write(
|
||||
initialRoute,
|
||||
mapLines,
|
||||
historyRecs,
|
||||
sweep,
|
||||
weightLocks,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -213,3 +213,36 @@ def test_노선_추천_다듬기_23_3():
|
||||
assert "calc(var(--z-modal, 1000) + 2)" in css # 라벨 패널(+1) 위
|
||||
assert "writing-mode: vertical-lr" in css
|
||||
assert 'backdropClass: "b05-routerec-backdrop"' in read("B05_Profile_UI_RouteEdit_Recommend.ts")
|
||||
|
||||
|
||||
def test_비중_고정_합_100_규칙(results):
|
||||
"""고정한 값은 안 움직이고 나머지가 합 100 — 어떤 고정 · 어떤 값으로 옮겨도 정수 0~100 · 합 100."""
|
||||
assert all(row["sum"] == 100 and row["lockedSame"] and row["range"] for row in results["sweep"])
|
||||
w = results["weightLocks"]
|
||||
# 안 고정: 지금 비율대로 나눔(기존 규칙)
|
||||
assert w["freeThree"] == {"expected": 40, "earthwork": 30, "grade": 30}
|
||||
# 예상 70 고정 → 토공을 끌면 기울기만 따라 바뀜
|
||||
assert w["lockExpected"] == {"expected": 70, "earthwork": 20, "grade": 10}
|
||||
# 남는 몫(30)을 넘게 끌면 잘림
|
||||
assert w["lockExpectedClamp"] == {"expected": 70, "earthwork": 30, "grade": 0}
|
||||
# 둘 고정 → 셋째는 저절로 정해져 안 움직임
|
||||
assert w["twoLocked"] == {"expected": 70, "earthwork": 20, "grade": 10}
|
||||
assert w["movingLocked"] == {"expected": 70, "earthwork": 15, "grade": 15}
|
||||
assert w["derived"] == [None, None, "grade"]
|
||||
# 셋 다 고정은 막음 · 푸는 것은 늘 됨
|
||||
assert w["canLock"] == {"none": True, "one": True, "two": False, "unlockOfTwo": True}
|
||||
|
||||
|
||||
def test_비중_값_칸_직접_입력(results):
|
||||
"""0~100 정수 · 고정 규칙 아래 합 100 을 못 맞추면 받지 않음(빨간 테두리 · 안 보냄)."""
|
||||
i = results["weightLocks"]["input"]
|
||||
assert i["free"] == {"ok": True, "weights": {"expected": 37, "earthwork": 55, "grade": 8}}
|
||||
assert i["lockedOk"] == {"ok": True, "weights": {"expected": 70, "earthwork": 20, "grade": 10}}
|
||||
for name in ("lockedTooBig", "fraction", "negative", "over", "nan", "onLocked"):
|
||||
assert i[name]["ok"] is False, name
|
||||
assert i[name]["weights"] == {"expected": 70, "earthwork": 15, "grade": 15}, name
|
||||
assert i["derivedSame"]["ok"] is True
|
||||
assert i["derivedOther"] == {
|
||||
"ok": False,
|
||||
"weights": {"expected": 70, "earthwork": 20, "grade": 10},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user