Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4
충돌 풂 — Culvert_Tiers cutBerm = sub7 (lengthM) 꼴 + 91-4 복합경사 제원 · test_79 유출 끝 = 수평공간 없이 바로 절토(89-4) · Culvert_Geom 700줄 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"판": 19,
|
||||
"판": 20,
|
||||
"schema_version": 1,
|
||||
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
|
||||
"구조물군": {
|
||||
@@ -3177,25 +3177,14 @@
|
||||
],
|
||||
"options": [
|
||||
{
|
||||
"key": "length_m",
|
||||
"label": "길이",
|
||||
"key": "height_m",
|
||||
"label": "높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 10
|
||||
},
|
||||
{
|
||||
"key": "before_m",
|
||||
"label": "기준측점 전",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5
|
||||
},
|
||||
{
|
||||
"key": "after_m",
|
||||
"label": "기준측점 후",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5
|
||||
"default": 0.2,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"default_basis": "사용자 확정 10-8 · 임도기술교본 3-2 「두께 0.2m」 기준 · 명세 칸(상세 내용)이 받음"
|
||||
},
|
||||
{
|
||||
"key": "thickness_cm",
|
||||
@@ -3371,7 +3360,8 @@
|
||||
"chute_add": "도수로/돌붙임",
|
||||
"chute_add.structure": "구조",
|
||||
"chute_add.structure=도수로": "도수로",
|
||||
"chute_add.structure=돌붙임": "돌붙임"
|
||||
"chute_add.structure=돌붙임": "돌붙임",
|
||||
"chute_add.thickness": "두께 (cm)"
|
||||
}
|
||||
},
|
||||
"box_culvert": {
|
||||
@@ -3418,7 +3408,6 @@
|
||||
"outlet.height": "높이 (m)",
|
||||
"outlet.before": "기준측점 전 (m)",
|
||||
"outlet.after": "기준측점 후 (m)",
|
||||
"outlet.berm": "수평공간 (m)",
|
||||
"soil_add": "흙막이",
|
||||
"soil_add.form": "형태",
|
||||
"soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)",
|
||||
@@ -3434,7 +3423,8 @@
|
||||
"chute_add": "도수로/돌붙임",
|
||||
"chute_add.structure": "구조",
|
||||
"chute_add.structure=도수로": "도수로",
|
||||
"chute_add.structure=돌붙임": "돌붙임"
|
||||
"chute_add.structure=돌붙임": "돌붙임",
|
||||
"chute_add.thickness": "두께 (cm)"
|
||||
}
|
||||
},
|
||||
"ford_pavement": {
|
||||
@@ -3489,7 +3479,6 @@
|
||||
"outlet.height": "높이 (m)",
|
||||
"outlet.before": "기준측점 전 (m)",
|
||||
"outlet.after": "기준측점 후 (m)",
|
||||
"outlet.berm": "수평공간 (m)",
|
||||
"soil_add": "흙막이",
|
||||
"soil_add.form": "형태",
|
||||
"soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)",
|
||||
@@ -3505,7 +3494,8 @@
|
||||
"chute_add": "도수로/돌붙임",
|
||||
"chute_add.structure": "구조",
|
||||
"chute_add.structure=도수로": "도수로",
|
||||
"chute_add.structure=돌붙임": "돌붙임"
|
||||
"chute_add.structure=돌붙임": "돌붙임",
|
||||
"chute_add.thickness": "두께 (cm)"
|
||||
}
|
||||
},
|
||||
"revetment": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 옮기기 · 크기 · 회전 · 복사 · 배열 복제가 색 · 층 · 선 꼴을 지키는지(84-3).
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --config <include 를 이 파일로 바꾼 vitest.tmp.config.ts>
|
||||
*/
|
||||
import { Point } from "@flatten-js/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/LineEntity";
|
||||
import { PointEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/PointEntity";
|
||||
import { PolyLineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/PolyLineEntity";
|
||||
import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity";
|
||||
import { cloneKeepingLook } from "../../../B07_DesignDetail/openwebcad/src/tools/move-tool.helpers";
|
||||
import type { Entity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity";
|
||||
|
||||
const styled = <T extends Entity>(entity: T): T => {
|
||||
entity.lineColor = "#ff0000";
|
||||
entity.lineWidth = 2;
|
||||
entity.lineDash = [4, 2];
|
||||
entity.opacity = 0.5;
|
||||
entity.groupId = "g1";
|
||||
return entity;
|
||||
};
|
||||
const look = (entity: Entity) => [
|
||||
entity.layerId,
|
||||
entity.lineColor,
|
||||
entity.lineWidth,
|
||||
entity.lineDash,
|
||||
entity.opacity,
|
||||
];
|
||||
const LOOK = ["구조물", "#ff0000", 2, [4, 2], 0.5];
|
||||
|
||||
describe("cloneKeepingLook", () => {
|
||||
const samples: [string, () => Entity][] = [
|
||||
["선", () => styled(new LineEntity("구조물", new Point(0, 0), new Point(1, 0)))],
|
||||
["점", () => styled(new PointEntity("구조물", new Point(1, 1)))],
|
||||
["글", () => styled(new TextEntity("구조물", "배수관", new Point(0, 0)))],
|
||||
];
|
||||
for (const [name, make] of samples) {
|
||||
it(`${name} — 옮기기(inPlace) 는 id · 묶음 · 색 · 층 그대로`, () => {
|
||||
const source = make();
|
||||
const copy = cloneKeepingLook(source, true);
|
||||
copy.move(5, 5);
|
||||
expect(look(copy)).toEqual(LOOK);
|
||||
expect([copy.id, copy.groupId]).toEqual([source.id, "g1"]);
|
||||
});
|
||||
it(`${name} — 복사는 새 id · 색 · 층 그대로`, () => {
|
||||
const source = make();
|
||||
const copy = cloneKeepingLook(source);
|
||||
expect(look(copy)).toEqual(LOOK);
|
||||
expect(copy.id).not.toBe(source.id);
|
||||
});
|
||||
}
|
||||
|
||||
it("폴리선 — 자식 선까지 색 · 층 그대로(크기 바꿈 뒤)", () => {
|
||||
const child = styled(new LineEntity("구조물", new Point(0, 0), new Point(1, 0)));
|
||||
const polyline = styled(new PolyLineEntity("구조물", [child]));
|
||||
const copy = cloneKeepingLook(polyline, true) as PolyLineEntity;
|
||||
copy.scale(new Point(0, 0), 2);
|
||||
expect(look(copy)).toEqual(LOOK);
|
||||
expect(look(copy.getEntities()[0])).toEqual(LOOK);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* CAD → 부모 표 보내기(`integration/aislo-table-export.ts` · M02 83-2) 검증.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad table-export
|
||||
* (시험 파일은 바깥 라이브러리를 직접 부르지 않음 — 엔티티는 fromJson 으로 만듦)
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getCommandById,
|
||||
isViewOnlyCommand,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/commands/registry.ts";
|
||||
import type { JsonEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity.ts";
|
||||
import { LineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/LineEntity.ts";
|
||||
import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity.ts";
|
||||
import {
|
||||
AISLO_EXPORT_TABLE_MESSAGE,
|
||||
AISLO_EXPORT_TABLE_REQUEST_MESSAGE,
|
||||
handleTableExportMessage,
|
||||
selectionTable,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts";
|
||||
import {
|
||||
getEntities,
|
||||
setEntities,
|
||||
setLayers,
|
||||
setSelectedEntityIds,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/state.ts";
|
||||
|
||||
const layer = { id: "layer-1", isLocked: false, isVisible: true, name: "작업" };
|
||||
|
||||
const line = (x1: number, y1: number, x2: number, y2: number) =>
|
||||
LineEntity.fromJson({
|
||||
id: crypto.randomUUID(),
|
||||
type: "Line",
|
||||
lineColor: "#fff",
|
||||
lineWidth: 1,
|
||||
layerId: layer.id,
|
||||
shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } },
|
||||
} as unknown as JsonEntity<never>);
|
||||
const text = (label: string, x: number, y: number) =>
|
||||
TextEntity.fromJson({
|
||||
id: crypto.randomUUID(),
|
||||
type: "Text",
|
||||
lineColor: "#fff",
|
||||
lineWidth: 1,
|
||||
layerId: layer.id,
|
||||
shapeData: {
|
||||
label,
|
||||
basePoint: { x, y },
|
||||
options: {
|
||||
textDirection: { x: 1, y: 0 },
|
||||
textAlign: "left",
|
||||
textColor: "#fff",
|
||||
fontSize: 2.5,
|
||||
fontFamily: "Noto Sans KR",
|
||||
},
|
||||
},
|
||||
} as unknown as JsonEntity<never>);
|
||||
|
||||
/** 2×2 선 표 + 글 넷 + 표 밖 선 하나(고르지 않음) */
|
||||
async function drawing() {
|
||||
const parts = await Promise.all([
|
||||
line(0, 20, 40, 20),
|
||||
line(0, 10, 40, 10),
|
||||
line(0, 0, 40, 0),
|
||||
line(0, 0, 0, 20),
|
||||
line(20, 0, 20, 20),
|
||||
line(40, 0, 40, 20),
|
||||
text("항목", 2, 14),
|
||||
text("수량", 22, 14),
|
||||
text("흙깎기", 2, 4),
|
||||
text("1,500", 22, 4),
|
||||
]);
|
||||
const outside = await line(100, 100, 200, 100);
|
||||
setEntities([...parts, outside]);
|
||||
return { parts, outside };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setLayers([layer]);
|
||||
setEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
});
|
||||
|
||||
describe("고른 범위 → 표 자료", () => {
|
||||
it("고른 선 + 글만 표로 · 숫자 글은 숫자", async () => {
|
||||
const { parts } = await drawing();
|
||||
setSelectedEntityIds(parts.map((entity) => entity.id));
|
||||
const table = await selectionTable();
|
||||
expect(table).toMatchObject({
|
||||
kind: "grid",
|
||||
rows: [
|
||||
["항목", "수량"],
|
||||
["흙깎기", 1500],
|
||||
],
|
||||
colWidths: [20, 20],
|
||||
});
|
||||
});
|
||||
|
||||
it("고른 것 없음 = empty · 글 없는 선 = no-text", async () => {
|
||||
const { outside } = await drawing();
|
||||
expect(await selectionTable()).toBe("empty");
|
||||
setSelectedEntityIds([outside.id]);
|
||||
expect(await selectionTable()).toBe("no-text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("부모 요청 메시지", () => {
|
||||
it("export-table-request = 고른 범위를 export-table{table} 로 회신 · 도면은 그대로", async () => {
|
||||
const { parts } = await drawing();
|
||||
setSelectedEntityIds(parts.map((entity) => entity.id));
|
||||
const before = getEntities();
|
||||
const notify = vi.fn();
|
||||
expect(
|
||||
await handleTableExportMessage({ type: AISLO_EXPORT_TABLE_REQUEST_MESSAGE }, notify),
|
||||
).toBe(true);
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
const [type, payload] = notify.mock.calls[0];
|
||||
expect(type).toBe(AISLO_EXPORT_TABLE_MESSAGE);
|
||||
expect(payload.table.rows[1]).toEqual(["흙깎기", 1500]);
|
||||
expect(getEntities()).toBe(before);
|
||||
});
|
||||
|
||||
it("고른 것이 없으면 회신 안 함 · 다른 메시지는 false", async () => {
|
||||
await drawing();
|
||||
const notify = vi.fn();
|
||||
expect(
|
||||
await handleTableExportMessage({ type: AISLO_EXPORT_TABLE_REQUEST_MESSAGE }, notify),
|
||||
).toBe(true);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(await handleTableExportMessage({ type: "aislo:b08:save-request" }, notify)).toBe(false);
|
||||
expect(await handleTableExportMessage(null, notify)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CAD 명령 TOAREA", () => {
|
||||
it("고른 것이 있어야 하고 · 확정(보기 전용) 도면에서도 됨", () => {
|
||||
const command = getCommandById("TOAREA");
|
||||
expect(command?.label).toBe("면적산출로 보내기");
|
||||
expect(command?.needsSelection).toBe(true);
|
||||
expect(command && isViewOnlyCommand(command)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 고른 범위 → 표 자료(`helpers/table-from-drawing.ts` · M02 83-1) 검증.
|
||||
* 실행: node --experimental-strip-types resources/tester/cad/table-from-drawing.test.ts
|
||||
* 견본 셋 = 표 엔티티 · 선 표(병합 · PolyLine · 끊긴 선) · 글만 + 빈 범위 · 글 없는 선 · 회전된 표.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
cellValue,
|
||||
tableFromDrawing,
|
||||
type DrawingEntityJson,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/helpers/table-from-drawing.ts";
|
||||
|
||||
const line = (x1: number, y1: number, x2: number, y2: number): DrawingEntityJson => ({
|
||||
type: "Line",
|
||||
shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } },
|
||||
});
|
||||
const text = (label: string, x: number, y: number, fontSize = 2.5): DrawingEntityJson => ({
|
||||
type: "Text",
|
||||
shapeData: { label, basePoint: { x, y }, options: { fontSize, textAlign: "left" } },
|
||||
});
|
||||
|
||||
// 숫자 글
|
||||
assert.equal(cellValue("1,500"), 1500, "천 단위 쉼표");
|
||||
assert.equal(cellValue(" -2.75 "), -2.75, "음수 소수");
|
||||
assert.equal(cellValue("12,34"), "12,34", "쉼표 자리 틀리면 글");
|
||||
assert.equal(cellValue("D600"), "D600", "글 그대로");
|
||||
assert.equal(cellValue("1,234,567.5"), 1234567.5);
|
||||
|
||||
// ① 표 엔티티 — 칸 그대로 · 병합 · 폭 · 높이
|
||||
const tableEntity: DrawingEntityJson = {
|
||||
type: "Table",
|
||||
shapeData: {
|
||||
origin: { x: 0, y: 100 },
|
||||
columnWidths: [30, 20, 25],
|
||||
rowHeights: [8, 6],
|
||||
cells: [
|
||||
[{ text: "구분", rowSpan: 2 }, { text: "수량", colSpan: 2 }, null],
|
||||
[null, { text: "1,500" }, { text: "m2" }],
|
||||
],
|
||||
},
|
||||
};
|
||||
const t1 = tableFromDrawing([tableEntity, text("밖 글", 500, 500)]);
|
||||
assert.ok(t1);
|
||||
assert.equal(t1.kind, "table");
|
||||
assert.deepEqual(t1.rows, [
|
||||
["구분", "수량", ""],
|
||||
["", 1500, "m2"],
|
||||
]);
|
||||
assert.deepEqual(t1.merges, [
|
||||
{ row: 0, col: 0, rowSpan: 2, colSpan: 1 },
|
||||
{ row: 0, col: 1, rowSpan: 1, colSpan: 2 },
|
||||
]);
|
||||
assert.deepEqual(t1.colWidths, [30, 20, 25]);
|
||||
assert.deepEqual(t1.rowHeights, [8, 6]);
|
||||
|
||||
// ② 선 표 — 3열 × 3행 · 맨 위 줄 가운데 세로선 빠짐(병합) · 바깥 테두리는 PolyLine · 가로선 한 줄은 끊겨 둘
|
||||
// x 경계 0 · 40 · 70 · 100 / y 경계 60 · 50 · 40 · 30
|
||||
const outline: DrawingEntityJson = {
|
||||
type: "PolyLine",
|
||||
shapeData: null,
|
||||
children: [
|
||||
line(0, 60, 100, 60),
|
||||
line(100, 60, 100, 30),
|
||||
line(100, 30, 0, 30),
|
||||
line(0, 30, 0, 60),
|
||||
],
|
||||
};
|
||||
const gridEntities: DrawingEntityJson[] = [
|
||||
outline,
|
||||
line(0, 50, 55, 50), // 끊긴 가로선 둘(겹침 5)
|
||||
line(50, 50.05, 100, 50.05), // 살짝 어긋남(허용오차 안)
|
||||
line(0, 40, 100, 40),
|
||||
line(40, 60, 40, 30), // 세로선 — 맨 위 줄도 지남
|
||||
line(70, 50, 70, 30), // 맨 위 줄엔 없음 → (0,1)~(0,2) 병합
|
||||
text("구분", 2, 53),
|
||||
text("수량", 45, 53),
|
||||
text("흙깎기", 2, 43),
|
||||
text("1,500", 42, 43),
|
||||
text("m3", 72, 43),
|
||||
text("돌쌓기", 2, 33),
|
||||
text("2.5", 42, 33),
|
||||
text("m2", 72, 33),
|
||||
text("(전면)", 2, 31), // 같은 칸 두 줄 → 「돌쌓기 (전면)」
|
||||
];
|
||||
const t2 = tableFromDrawing(gridEntities);
|
||||
assert.ok(t2);
|
||||
assert.equal(t2.kind, "grid");
|
||||
assert.deepEqual(t2.rows, [
|
||||
["구분", "수량", ""],
|
||||
["흙깎기", 1500, "m3"],
|
||||
["돌쌓기 (전면)", 2.5, "m2"],
|
||||
]);
|
||||
assert.deepEqual(t2.merges, [{ row: 0, col: 1, rowSpan: 1, colSpan: 2 }]);
|
||||
assert.deepEqual(
|
||||
t2.colWidths.map((w) => Math.round(w)),
|
||||
[40, 30, 30],
|
||||
);
|
||||
assert.deepEqual(
|
||||
t2.rowHeights.map((h) => Math.round(h)),
|
||||
[10, 10, 10],
|
||||
);
|
||||
|
||||
// 사각형 엔티티 칸 · 짧은 눈금 선은 경계로 안 침
|
||||
const rectGrid = tableFromDrawing([
|
||||
{
|
||||
type: "Rectangle",
|
||||
shapeData: {
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 20, y: 0 },
|
||||
{ x: 20, y: 10 },
|
||||
{ x: 0, y: 10 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "Rectangle",
|
||||
shapeData: {
|
||||
points: [
|
||||
{ x: 20, y: 0 },
|
||||
{ x: 50, y: 0 },
|
||||
{ x: 50, y: 10 },
|
||||
{ x: 20, y: 10 },
|
||||
],
|
||||
},
|
||||
},
|
||||
line(35, 0, 35, 1), // 눈금
|
||||
text("가", 2, 4),
|
||||
text("나", 22, 4),
|
||||
]);
|
||||
assert.ok(rectGrid);
|
||||
assert.equal(rectGrid.kind, "grid");
|
||||
assert.deepEqual(rectGrid.rows, [["가", "나"]]);
|
||||
assert.deepEqual(rectGrid.colWidths, [20, 30]);
|
||||
|
||||
// ③ 글만 — 줄(y 묶음) · 칸(x 차례) · 모자란 칸은 빈 칸
|
||||
const t3 = tableFromDrawing([
|
||||
text("합계", 0, 10),
|
||||
text("3,000", 40, 10.3),
|
||||
text("비고", 80, 9.8),
|
||||
text("소계", 0, 0),
|
||||
text("1,000", 40, 0),
|
||||
]);
|
||||
assert.ok(t3);
|
||||
assert.equal(t3.kind, "text");
|
||||
assert.deepEqual(t3.rows, [
|
||||
["합계", 3000, "비고"],
|
||||
["소계", 1000, ""],
|
||||
]);
|
||||
assert.deepEqual(t3.merges, []);
|
||||
|
||||
// 빈 범위 · 글 없는 선 = null
|
||||
assert.equal(tableFromDrawing([]), null, "빈 범위");
|
||||
assert.equal(
|
||||
tableFromDrawing([line(0, 0, 10, 0), line(0, 5, 10, 5), line(0, 0, 0, 5), line(10, 0, 10, 5)]),
|
||||
null,
|
||||
"글 없는 선",
|
||||
);
|
||||
|
||||
// 회전된 표(30°) — 축 선이 없어 글 자리 폴백
|
||||
const rot = (x: number, y: number): [number, number] => {
|
||||
const a = Math.PI / 6;
|
||||
return [x * Math.cos(a) - y * Math.sin(a), x * Math.sin(a) + y * Math.cos(a)];
|
||||
};
|
||||
const rline = (x1: number, y1: number, x2: number, y2: number) =>
|
||||
line(...rot(x1, y1), ...rot(x2, y2));
|
||||
const t4 = tableFromDrawing([
|
||||
rline(0, 0, 40, 0),
|
||||
rline(0, 10, 40, 10),
|
||||
rline(0, 0, 0, 10),
|
||||
rline(40, 0, 40, 10),
|
||||
rline(20, 0, 20, 10),
|
||||
text("A", ...rot(2, 4)),
|
||||
text("B", ...rot(22, 4)),
|
||||
]);
|
||||
assert.ok(t4);
|
||||
assert.equal(t4.kind, "text");
|
||||
assert.equal(t4.rows.flat().filter(Boolean).length, 2, "글 둘 다 살아 있음");
|
||||
|
||||
// 격자 밖 글이 반 넘으면 폴백(표가 아닌 범위)
|
||||
const t5 = tableFromDrawing([
|
||||
line(0, 0, 10, 0),
|
||||
line(0, 10, 10, 10),
|
||||
line(0, 0, 0, 10),
|
||||
line(10, 0, 10, 10),
|
||||
text("안", 2, 4),
|
||||
text("밖1", 50, 50),
|
||||
text("밖2", 80, 50),
|
||||
]);
|
||||
assert.equal(t5?.kind, "text");
|
||||
|
||||
console.log("table-from-drawing: 통과");
|
||||
@@ -46,11 +46,14 @@ if (process.argv[2] === "road") {
|
||||
same: spec === road.PAVEMENT_SPEC,
|
||||
containers: spec.containers.map((c) => [c.id, c.title, c.required, c.link]),
|
||||
choices: road.PAVE_SPEC_CHOICES.map((c) => c.value),
|
||||
items: spec.containers[0].items.map((i) => [i.id, i.label, i.kind]),
|
||||
state,
|
||||
edited: s.writeOptions(spec, edited),
|
||||
cleared: s.writeOptions(spec, cleared),
|
||||
keys: s.specKeys(spec),
|
||||
retired: spec.retired,
|
||||
dropped: spec.dropped,
|
||||
byRange: spec.byRange,
|
||||
}),
|
||||
);
|
||||
process.exit(0);
|
||||
|
||||
@@ -118,6 +118,8 @@ const PIPE_CALC = {
|
||||
pipeDrawn: () => null,
|
||||
pipeLengthM: () => null,
|
||||
pipeExtraCount: () => Math.min(stored, LIMIT),
|
||||
pipeRevetChoice: () => null,
|
||||
setPipeRevetChoice: () => Promise.resolve(),
|
||||
setPipeExtraCount: (chainage, count) => {
|
||||
writes.push([chainage, count]);
|
||||
stored = count;
|
||||
@@ -127,11 +129,8 @@ const PIPE_CALC = {
|
||||
const STUBS = {
|
||||
"@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 },
|
||||
"../A00_Common/design/design_structure_pipe_calc": PIPE_CALC,
|
||||
// 관 보호공 [연동] · [경사] 줄(54-30) — 여기서는 자리만
|
||||
"./B05_Profile_UI_Drainage_Revet_Choice": {
|
||||
revetChoiceRow: () => ({ paint: () => new FakeNode("div") }),
|
||||
guardChoiceFields: () => () => [],
|
||||
},
|
||||
// 관 보호공 [연동] · [경사] 단추(54-30) — 여기서는 자리만 · 단 칸(90-1 공용)은 실제 파일
|
||||
"./B05_Profile_UI_Panel_Fields": { toggleButton: () => new FakeNode("button") },
|
||||
"../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": {
|
||||
buildSummaryRow: () => {
|
||||
const root = new FakeNode("div");
|
||||
@@ -169,7 +168,13 @@ const form = createFacilityOptionsForm();
|
||||
form.setChainage(100);
|
||||
form.setFacility("pipe", { pipe_diameter_mm: 1000 });
|
||||
|
||||
const spec = form.root.querySelector(".b05-structure__spec");
|
||||
// 시설마다 명세 폼이 따로 — 지금 시설의 폼(본체 상자 id 로 찾음)
|
||||
let body = null;
|
||||
const specOf = () =>
|
||||
form.root
|
||||
.querySelectorAll(".b05-structure__spec")
|
||||
.find((s) => !body || s.children.some((k) => k.dataset?.specContainer === body));
|
||||
let spec = specOf();
|
||||
const boxesOf = (id) => spec.children.filter((k) => k.dataset?.specContainer === id);
|
||||
const tierRow = () =>
|
||||
boxesOf("outlet")[0]
|
||||
@@ -199,10 +204,41 @@ async function main() {
|
||||
stored = 2;
|
||||
form.recount();
|
||||
out.fromCross = snap();
|
||||
out.writes = writes;
|
||||
// 다른 구조(옛 값)면 칸을 가림
|
||||
form.setFacility("pipe", { pipe_diameter_mm: 1000, outlet_type: "옛값" });
|
||||
out.otherHidden = tierRow()?.hidden ?? null;
|
||||
// 세월교 · 박스 유출 흙막이(90-1) — 같은 단 칸 · 단 수 = 흙막이 상자 수 · 「+ 흙막이」 없음 ·
|
||||
// 유출이 흙막이가 아니면 단 칸 · 옛 상자 숨김(값은 남김)
|
||||
const addButton = () =>
|
||||
spec.children.find((k) => k.dataset?.specAdd === "soil_add") ? true : false;
|
||||
const old = { soil_add_1_form: "바자", soil_add_2_form: "돌망태", soil_add_3_form: "바자" };
|
||||
for (const [facility, key, container] of [
|
||||
["ford_bridge", "outlet_type", "ford"],
|
||||
["box_culvert", "wing_out", "body"],
|
||||
]) {
|
||||
stored = 0;
|
||||
body = container;
|
||||
form.setFacility(facility, { [key]: "흙막이", ...old });
|
||||
spec = specOf();
|
||||
const steps = {};
|
||||
for (const n of ["2", "0"]) {
|
||||
const input = tierInput();
|
||||
input.value = n;
|
||||
input.dispatchEvent(new Event("change"));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
steps[`set${n}`] = { ...snap(), hidden: tierRow()?.hidden ?? null, add: addButton() };
|
||||
}
|
||||
form.setFacility(facility, { [key]: "날개벽", ...old });
|
||||
steps.wing = {
|
||||
boxes: boxesOf("soil_add").length,
|
||||
hidden: tierRow()?.hidden ?? null,
|
||||
add: addButton(),
|
||||
kept: Object.keys(form.readOptions()).filter((k) => k.startsWith("soil_add_")).length,
|
||||
};
|
||||
out[facility] = steps;
|
||||
}
|
||||
out.writes = writes;
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
}
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -137,9 +137,14 @@ out.boxWing = wingLayout.sides.map((s) => ({
|
||||
// ⑤ 흙막이 끝 절토 = 수평공간 뒤 절토 경사 — 수평공간 칸 값을 따름
|
||||
function cutTails(section, list) {
|
||||
const culvert = computeStoredLayouts(section, list).culvert;
|
||||
return [...culvert.outletFill.segments, ...culvert.basinFill.segments]
|
||||
const tagged = (segments, role) => segments.map((g) => ({ ...g, role }));
|
||||
return [
|
||||
...tagged(culvert.outletFill.segments, "outlet"),
|
||||
...tagged(culvert.basinFill.segments, "inlet"),
|
||||
]
|
||||
.filter((g) => g.kind === "cut")
|
||||
.map((g) => ({
|
||||
role: g.role,
|
||||
points: g.points.length,
|
||||
berm: r3(Math.abs(g.points[1].offset - g.points[0].offset)),
|
||||
flat: r3(g.points[1].elevation - g.points[0].elevation),
|
||||
@@ -148,12 +153,25 @@ function cutTails(section, list) {
|
||||
out.cutDefault = [cutTails(at(352.14), sections), cutTails(at(173.087), sections)];
|
||||
const berm2 = clone(at(352.14));
|
||||
berm2.culvert.inlet.revet_berm_m = 2;
|
||||
berm2.culvert.outlet.revet_berm_m = 2;
|
||||
berm2.culvert.outlet.revet_berm_m = 2; // 유출은 칸이 없어 무시(89-4)
|
||||
out.cutBerm2 = cutTails(
|
||||
berm2,
|
||||
sections.map((s) => (s === at(352.14) ? berm2 : s)),
|
||||
);
|
||||
|
||||
// ⑤b 유입 수평공간 시작 높이 = 본체 바닥(세월교 바닥판 상면 · 박스 저판 상면 · 89-3)
|
||||
out.inletFloor = [352.14, 173.087].map((chainage) => {
|
||||
const section = at(chainage);
|
||||
const layouts = computeStoredLayouts(section, sections);
|
||||
const start = layouts.culvert.basinFill.segments[0]?.points[0];
|
||||
const inletLeft = (section.uphill_side ?? "left") === "left";
|
||||
const floor = layouts.ford
|
||||
? layouts.ford.sides.find((s) => s.role === "inlet").slabTopElevation
|
||||
: layouts.box.sides.find((s) => (s.role === "left") === inletLeft).bottomElevation +
|
||||
layouts.box.box.slab_thickness_m;
|
||||
return { start: start ? r3(start.elevation) : null, floor: r3(floor) };
|
||||
});
|
||||
|
||||
// ⑥ 관 보호공 기본 자리 = 제일 위(성토선 0 지점 · d 0) · 관 길이 m 단위
|
||||
const pipe = clone(at(180));
|
||||
pipe.culvert = {
|
||||
|
||||
@@ -65,6 +65,8 @@ function pipeFit(edit) {
|
||||
const { ford, culvert } = computeStoredLayouts(section, list);
|
||||
const [right, left] = ford.pipe.outer.slice(2);
|
||||
const ends = { 1: left.offset, [-1]: right.offset };
|
||||
const [topLeft, topRight] = ford.pipe.outer;
|
||||
const tops = { 1: topLeft, [-1]: topRight };
|
||||
const slab = ford.slabBridge.map((p) => p.offset);
|
||||
const slabEnd = { 1: Math.max(...slab), [-1]: Math.min(...slab) };
|
||||
return {
|
||||
@@ -80,6 +82,13 @@ function pipeFit(edit) {
|
||||
role: side.role,
|
||||
guard: true,
|
||||
pipeGap: r3(ends[side.outward] - front),
|
||||
// 관 윗모서리 ↔ 그 높이의 흙막이 전면(사면 이용 · 89-1)
|
||||
topGap: r3(
|
||||
tops[side.outward].offset -
|
||||
(wall.points[2].offset +
|
||||
wall.outward * wall.lean * (wall.topJoint.elevation - tops[side.outward].elevation)),
|
||||
),
|
||||
lean: wall.lean,
|
||||
slabGap: r3(slabEnd[side.outward] - wall.bottomBack.offset),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* 노면공 확폭 구간 규칙(PLAN 87-3) — `design_structure_widening.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"A00_Common",
|
||||
"design",
|
||||
"design_structure_widening.ts",
|
||||
);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", js)(box.exports, box);
|
||||
const w = box.exports;
|
||||
|
||||
// 곡선 둘(앞 No.1+0~No.3+0 · 뒤 No.5+0~No.6+0) 사이에 직선 측점 하나 — 확폭 좌우 합
|
||||
const at = (chainage_m, left = 0, right = 0) => ({
|
||||
chainage_m,
|
||||
design: { widening_left_m: left, widening_right_m: right },
|
||||
});
|
||||
const stations = [
|
||||
at(0),
|
||||
at(20, 0.3, 0),
|
||||
at(40, 0.75, 0),
|
||||
at(60, 0.4, 0),
|
||||
at(80),
|
||||
at(100, 0, 0.5),
|
||||
at(120, 0, 0.6),
|
||||
at(140),
|
||||
];
|
||||
const runs = w.wideningRuns(stations, 0, 140);
|
||||
const inner = w.wideningRuns(stations, 30, 110); // 범위가 곡선을 가로지름
|
||||
const off = w.formatOff([[20, 60]]);
|
||||
const out = {
|
||||
runs,
|
||||
inner,
|
||||
none: w.wideningRuns(stations, 0, 10),
|
||||
fallback: w.wideningOf({ chainage_m: 1, curve_widening_m: 0.9, curve_outer_side: "left" }),
|
||||
fallbackStraight: w.wideningOf({ chainage_m: 1, curve_widening_m: 0.9, curve_outer_side: null }),
|
||||
off,
|
||||
parsed: w.parseOff("20-60, 100-120,bad,5"),
|
||||
isOff: runs.map((r) => w.isRunOff(r, off)),
|
||||
toggledOff: w.toggleRun(runs, "", runs[1]),
|
||||
toggledBoth: w.toggleRun(runs, off, runs[1]),
|
||||
toggledOn: w.toggleRun(runs, off, runs[0]),
|
||||
stale: w.offForRuns(runs, "20-60,0-5,100-130"),
|
||||
empty: w.offForRuns([], "20-60"),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,84 @@
|
||||
/* 90-2 도수로 · 돌붙임 띠 헬퍼 — `appendChuteBand`(B06_Section_UI_Cross_Wall_Hatch.ts)를 그 자리에서
|
||||
* 트랜스파일해 흉내 DOM 에 그림 · 결과 = 띠 꼭짓점(m) · 돌 수(표준출력 JSON). 짝 = `test_90_chute_band.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
class Node {
|
||||
constructor(tag) {
|
||||
this.tag = tag;
|
||||
this.attrs = {};
|
||||
this.children = [];
|
||||
}
|
||||
setAttribute(k, v) {
|
||||
this.attrs[k] = String(v);
|
||||
}
|
||||
getAttribute(k) {
|
||||
return this.attrs[k] ?? null;
|
||||
}
|
||||
append(...kids) {
|
||||
this.children.push(...kids);
|
||||
}
|
||||
set textContent(v) {
|
||||
this.text = v;
|
||||
}
|
||||
}
|
||||
globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) };
|
||||
|
||||
const { appendChuteBand } = loadTs(
|
||||
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Wall_Hatch.ts"),
|
||||
);
|
||||
const SCALE = 100;
|
||||
const run = (line) => {
|
||||
const layer = new Node("g");
|
||||
appendChuteBand(
|
||||
layer,
|
||||
[line],
|
||||
{ structure: "돌붙임", thickness_m: 0.3 },
|
||||
(o) => o * SCALE,
|
||||
(e) => -e * SCALE,
|
||||
);
|
||||
const band = layer.children.find((k) => k.tag === "polygon");
|
||||
const group = layer.children.find((k) => k.tag === "g");
|
||||
const points = band.attrs.points.split(" ").map((p) => {
|
||||
const [px, py] = p.split(",").map(Number);
|
||||
return { offset: px / SCALE, elevation: -py / SCALE };
|
||||
});
|
||||
return {
|
||||
points,
|
||||
stones: group.children.filter((k) => k.tag === "rect").length,
|
||||
clipped: group.attrs["clip-path"]?.startsWith("url(#") ?? false,
|
||||
title: band.children[0]?.text ?? "",
|
||||
};
|
||||
};
|
||||
const out = {
|
||||
// 좌측 유출(+ 바깥) · 성토 1:1.2 → 단 사이 수평 조각 → 다시 성토(흙막이 단 사이 포함)
|
||||
left: run([
|
||||
{ offset: 5, elevation: 10 },
|
||||
{ offset: 7.4, elevation: 8 },
|
||||
{ offset: 8.4, elevation: 8 },
|
||||
{ offset: 10.8, elevation: 6 },
|
||||
]),
|
||||
// 우측 유출(− 바깥) · 곧은 성토 한 줄
|
||||
right: run([
|
||||
{ offset: -5, elevation: 10 },
|
||||
{ offset: -8.6, elevation: 7 },
|
||||
]),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -147,9 +147,13 @@ function guardTails(base, edit = () => undefined) {
|
||||
const s = clone(base);
|
||||
edit(s);
|
||||
const { culvert } = layoutsOf(s, base);
|
||||
return [...culvert.outletFill.segments, ...culvert.basinFill.segments]
|
||||
const tagged = (segments, role) => segments.map((g) => ({ ...g, role }));
|
||||
return [
|
||||
...tagged(culvert.outletFill.segments, "outlet"),
|
||||
...tagged(culvert.basinFill.segments, "inlet"),
|
||||
]
|
||||
.filter((g) => g.kind === "cut")
|
||||
.map((g) => profile(g.points, s.samples));
|
||||
.map((g) => ({ role: g.role, ...profile(g.points, s.samples) }));
|
||||
}
|
||||
out.guardTails = [...guardTails(at(352.14)), ...guardTails(FORD)];
|
||||
// 박스 끝 · 세월교 측벽 끝(흙막이 없음)
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
/* =============================================================================
|
||||
* test_stage4_menu_items.ts — 우클릭 메뉴 덧붙인 항목(81-3) 시험.
|
||||
* 행 위/아래 · 열 좌우 삽입 · 셀 삽입/삭제 · 행열 삭제 · 크기 · 시트 정렬 · 필터 · 고정 · 그룹 ·
|
||||
* 범위 이름 · 붙여넣기 보안 문맥 갈래 · 구분선 · 읽기 전용 복사.
|
||||
* 돌리기: npx tsx resources/tester/spreadsheet/test_stage4_menu_items.ts
|
||||
* ========================================================================== */
|
||||
|
||||
import { register } from "node:module";
|
||||
import type { MapContextMenuItem } from "../../../ui_template/ui_template_context_menu.ts";
|
||||
import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands.ts";
|
||||
import { createCalcEngine } from "../../../A00_Common/spreadsheet/spreadsheet_graph.ts";
|
||||
import {
|
||||
canMenuPaste,
|
||||
cellInsertItems,
|
||||
cellMoreItems,
|
||||
colInsertItems,
|
||||
colMoreItems,
|
||||
copyOnlyItems,
|
||||
menuPaste,
|
||||
nameRange,
|
||||
pasteItems,
|
||||
resizeLines,
|
||||
rowInsertItems,
|
||||
rowMoreItems,
|
||||
sortByColumn,
|
||||
usedRange,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_menu_items.ts";
|
||||
import type { Cell, Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts";
|
||||
import type {
|
||||
Selection,
|
||||
SpreadsheetContext,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_view_types.ts";
|
||||
import { a1, assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
|
||||
|
||||
// 늦게 부르는 창 모듈의 css 는 node 에서 빈 모듈로
|
||||
register(
|
||||
"data:text/javascript,export async function load(u,c,n){return u.endsWith('.css')?{format:'module',source:'',shortCircuit:true}:n(u,c)}",
|
||||
);
|
||||
const tick = () => new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
/** 진짜 명령 · 엔진 · 고름을 쓰는 문맥 */
|
||||
function realCtx(cells: Record<string, Cell>): SpreadsheetContext & { sent: Command[] } {
|
||||
const book: Workbook = makeBook([makeSheet("s1", cells)]);
|
||||
const base = makeCtx(book, "s1");
|
||||
const sent: Command[] = [];
|
||||
const ctx = base as unknown as SpreadsheetContext & { sent: Command[] };
|
||||
ctx.engine = createCalcEngine(book);
|
||||
ctx.sent = sent;
|
||||
ctx.dispatch = (cmd: Command) => {
|
||||
sent.push(cmd);
|
||||
applyCommand(book, cmd);
|
||||
ctx.engine.rebuild(book);
|
||||
};
|
||||
ctx.select = (sel: Selection) => void (ctx.selection = sel);
|
||||
return ctx;
|
||||
}
|
||||
const col = (n: number) =>
|
||||
Object.fromEntries(Array.from({ length: n }, (_, i) => [a1(i, 0), v(i + 1)]));
|
||||
const row = (n: number) =>
|
||||
Object.fromEntries(Array.from({ length: n }, (_, i) => [a1(0, i), v(i + 1)]));
|
||||
const val = (ctx: SpreadsheetContext, r: number, c: number) => ctx.sheet().칸[a1(r, c)]?.값;
|
||||
const leaf = (items: MapContextMenuItem[], label: string): (() => void) => {
|
||||
for (const it of items) {
|
||||
if (it === "-") continue;
|
||||
if (Array.isArray(it)) {
|
||||
if (it[0] === label) return it[1];
|
||||
} else {
|
||||
const hit = it.children.find(([l]) => l === label);
|
||||
if (hit) return hit[1];
|
||||
}
|
||||
}
|
||||
throw new Error(`항목 없음: ${label}`);
|
||||
};
|
||||
const labels = (items: MapContextMenuItem[]) =>
|
||||
items.map((it) => (it === "-" ? "-" : Array.isArray(it) ? it[0] : `${it.label} ▸`));
|
||||
|
||||
// ── 행 위 · 아래 삽입(고른 2행 B2:B3 → at 1 · 수 2) ─────────────────────────
|
||||
{
|
||||
const ctx = realCtx(col(5));
|
||||
leaf(rowInsertItems(ctx, 1, 2), "위에 2행 삽입")();
|
||||
assertEqual(
|
||||
[val(ctx, 0, 0), val(ctx, 1, 0), val(ctx, 2, 0), val(ctx, 3, 0)],
|
||||
[1, undefined, undefined, 2],
|
||||
"위에 2행 — 2행부터 밀림",
|
||||
);
|
||||
}
|
||||
{
|
||||
const ctx = realCtx(col(5));
|
||||
leaf(rowInsertItems(ctx, 1, 2), "아래에 2행 삽입")();
|
||||
assertEqual(
|
||||
[val(ctx, 2, 0), val(ctx, 3, 0), val(ctx, 4, 0), val(ctx, 5, 0)],
|
||||
[3, undefined, undefined, 4],
|
||||
"아래에 2행 — 고른 줄 뒤에 끼움",
|
||||
);
|
||||
}
|
||||
// ── 열 왼 · 오른쪽 삽입 ──────────────────────────────────────────────────
|
||||
{
|
||||
const ctx = realCtx(row(4));
|
||||
leaf(colInsertItems(ctx, 1, 1), "왼쪽에 1열 삽입")();
|
||||
assertEqual([val(ctx, 0, 0), val(ctx, 0, 1), val(ctx, 0, 2)], [1, undefined, 2], "왼쪽에 1열");
|
||||
}
|
||||
{
|
||||
const ctx = realCtx(row(4));
|
||||
leaf(colInsertItems(ctx, 1, 1), "오른쪽에 1열 삽입")();
|
||||
assertEqual([val(ctx, 0, 1), val(ctx, 0, 2), val(ctx, 0, 3)], [2, undefined, 3], "오른쪽에 1열");
|
||||
}
|
||||
// ── 칸 메뉴 — 셀 삽입/삭제 ▸ · 행열 삭제 ▸ ──────────────────────────────
|
||||
{
|
||||
const g = { r0: 0, c0: 1, r1: 0, c1: 1 };
|
||||
const ctx = realCtx(row(3));
|
||||
const items = cellInsertItems(ctx, g);
|
||||
assertEqual(
|
||||
labels(items),
|
||||
["행 · 열 삽입 ▸", "셀 삽입 ▸", "행 · 열 삭제 ▸", "셀 삭제 ▸"],
|
||||
"칸 메뉴 하위 넷",
|
||||
);
|
||||
leaf(items, "셀 삽입 후 오른쪽으로 밀기")();
|
||||
await tick();
|
||||
assertEqual(
|
||||
[val(ctx, 0, 1), val(ctx, 0, 2), val(ctx, 0, 3)],
|
||||
[undefined, 2, 3],
|
||||
"셀 삽입 · 오른쪽으로",
|
||||
);
|
||||
leaf(cellInsertItems(ctx, g), "셀 삭제 후 왼쪽으로 당기기")();
|
||||
await tick();
|
||||
assertEqual([val(ctx, 0, 1), val(ctx, 0, 2)], [2, 3], "셀 삭제 · 왼쪽으로");
|
||||
}
|
||||
{
|
||||
const ctx = realCtx(col(3));
|
||||
leaf(cellInsertItems(ctx, { r0: 1, c0: 0, r1: 1, c1: 0 }), "셀 삽입 후 아래로 밀기")();
|
||||
await tick();
|
||||
assertEqual([val(ctx, 1, 0), val(ctx, 2, 0)], [undefined, 2], "셀 삽입 · 아래로");
|
||||
leaf(cellInsertItems(ctx, { r0: 1, c0: 0, r1: 1, c1: 0 }), "셀 삭제 후 위로 당기기")();
|
||||
await tick();
|
||||
assertEqual([val(ctx, 1, 0), val(ctx, 2, 0)], [2, 3], "셀 삭제 · 위로");
|
||||
leaf(cellInsertItems(ctx, { r0: 0, c0: 0, r1: 1, c1: 0 }), "2행 삭제")();
|
||||
assertEqual(val(ctx, 0, 0), 3, "칸 메뉴 행 삭제 · 고른 2행");
|
||||
}
|
||||
// ── 크기 — px → 열폭(글자) · 행높이(pt) · 잘못된 값 ─────────────────────────
|
||||
{
|
||||
const ctx = realCtx(row(2));
|
||||
assertEqual(
|
||||
resizeLines(ctx, false, [0, 1], "abc"),
|
||||
"1 이상 숫자를 넣을 것",
|
||||
"크기 · 글이면 거름",
|
||||
);
|
||||
assertEqual(resizeLines(ctx, false, [0, 1], "0"), "1 이상 숫자를 넣을 것", "크기 · 0 거름");
|
||||
assertEqual(resizeLines(ctx, false, [0, 1], "100"), null, "열 너비 100px");
|
||||
const w = ctx.sheet().열!;
|
||||
assertEqual(w.A?.폭 === w.B?.폭 && (w.A?.폭 ?? 0) > 10, true, "열 둘 같은 폭(한 명령)");
|
||||
resizeLines(ctx, true, [2], "40");
|
||||
assertEqual(
|
||||
Math.round((ctx.sheet().행?.["3"]?.높이 ?? 0) * 100) / 100,
|
||||
30,
|
||||
"행 높이 40px = 30pt",
|
||||
);
|
||||
}
|
||||
// ── 시트 정렬(열 머리) — 쓴 범위 통째 · 고름 되돌림 · 빈 열은 안 함 ───────────────
|
||||
{
|
||||
const ctx = realCtx({
|
||||
A1: v("이름"),
|
||||
B1: v("값"),
|
||||
A2: v("다"),
|
||||
B2: v(3),
|
||||
A3: v("가"),
|
||||
B3: v(1),
|
||||
A4: v("나"),
|
||||
B4: v(2),
|
||||
});
|
||||
const keep = ctx.selection;
|
||||
await sortByColumn(ctx, 1, false);
|
||||
assertEqual(
|
||||
[val(ctx, 1, 0), val(ctx, 2, 0), val(ctx, 3, 0)],
|
||||
["가", "나", "다"],
|
||||
"B 열 기준 오름 — A 열도 같이",
|
||||
);
|
||||
assertEqual(val(ctx, 0, 0), "이름", "머리 행 그대로");
|
||||
assertEqual(ctx.selection, keep, "정렬 뒤 고름 되돌림");
|
||||
await sortByColumn(ctx, 1, true);
|
||||
assertEqual([val(ctx, 1, 1), val(ctx, 3, 1)], [3, 1], "내림");
|
||||
const before = ctx.sent.length;
|
||||
await sortByColumn(ctx, 5, false);
|
||||
assertEqual(ctx.sent.length, before, "빈 열 정렬은 명령 없음");
|
||||
assertEqual(usedRange(ctx.sheet()), { r0: 0, c0: 0, r1: 3, c1: 1 }, "쓴 범위");
|
||||
}
|
||||
// ── 필터 · 고정 · 그룹 · 더보기 ─────────────────────────────────────────────
|
||||
{
|
||||
const ctx = realCtx({ A1: v("머리"), A2: v(1), B2: v(2) });
|
||||
const calls: [string, unknown][] = [];
|
||||
ctx.dataMenu = (kind) => calls.push([kind, structuredClone(ctx.selection.범위)]);
|
||||
const more = colMoreItems(ctx, [0]);
|
||||
assertEqual(
|
||||
labels(more),
|
||||
[
|
||||
"-",
|
||||
"열 크기 조절…",
|
||||
"데이터에 맞추기",
|
||||
"-",
|
||||
"시트 정렬 A → Z",
|
||||
"시트 정렬 Z → A",
|
||||
"필터 만들기",
|
||||
"-",
|
||||
"고정 ▸",
|
||||
"그룹 ▸",
|
||||
],
|
||||
"열 머리 덧붙임 순서",
|
||||
);
|
||||
leaf(more, "필터 만들기")();
|
||||
await tick();
|
||||
assertEqual(calls[0], ["filter", [{ r0: 0, c0: 0, r1: 1, c1: 1 }]], "열 머리 필터 = 쓴 범위로");
|
||||
leaf(cellMoreItems(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }), "드롭다운…")();
|
||||
leaf(cellMoreItems(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }), "조건부 서식…")();
|
||||
assertEqual(
|
||||
calls.slice(1).map((c) => c[0]),
|
||||
["validation", "condfmt"],
|
||||
"더보기 → 데이터 창",
|
||||
);
|
||||
leaf(rowMoreItems(ctx, [0]), "첫 행 고정")();
|
||||
await tick();
|
||||
assertEqual(ctx.sent.at(-1)?.종류, "틀고정", "고정 ▸ 첫 행");
|
||||
ctx.selection = {
|
||||
시트: "s1",
|
||||
범위: [{ r0: 1, c0: 0, r1: 1, c1: 16383 }],
|
||||
활성: { r: 1, c: 0 },
|
||||
기준: { r: 1, c: 0 },
|
||||
};
|
||||
leaf(rowMoreItems(ctx, [1]), "그룹으로 묶기")();
|
||||
await tick();
|
||||
assertEqual(ctx.sheet().행?.["2"]?.묶음, { 층: 1 }, "그룹 ▸ 묶기");
|
||||
assertEqual(
|
||||
await nameRange(ctx, "", { r0: 0, c0: 0, r1: 0, c1: 0 }),
|
||||
"이름을 넣을 것",
|
||||
"범위 이름 · 빈 값",
|
||||
);
|
||||
assertEqual(
|
||||
await nameRange(ctx, "합계칸", { r0: 1, c0: 0, r1: 1, c1: 1 }),
|
||||
null,
|
||||
"범위 이름 지정",
|
||||
);
|
||||
assertEqual(
|
||||
typeof (await nameRange(ctx, "합계칸", { r0: 0, c0: 0, r1: 0, c1: 0 })),
|
||||
"string",
|
||||
"같은 이름은 거름",
|
||||
);
|
||||
}
|
||||
// ── 붙여넣기 — 보안 문맥 갈래 ───────────────────────────────────────────────
|
||||
{
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g.isSecureContext = false;
|
||||
assertEqual(canMenuPaste(), false, "http(보안 문맥 아님) — 메뉴로 못 읽음 → Ctrl+V 안내");
|
||||
g.isSecureContext = true;
|
||||
assertEqual(canMenuPaste(), false, "클립보드 API 없음 — 안내");
|
||||
const clip = { html: "", text: "7\t8" };
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
read: async () => [
|
||||
{
|
||||
types: ["text/plain"],
|
||||
getType: async () => new Blob([clip.text]),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
assertEqual(canMenuPaste(), true, "localhost · https — 메뉴로 읽음");
|
||||
class FakeData {
|
||||
data: Record<string, string> = {};
|
||||
setData(k: string, s: string) {
|
||||
this.data[k] = s;
|
||||
}
|
||||
}
|
||||
class FakePaste {
|
||||
constructor(
|
||||
public type: string,
|
||||
public init: { clipboardData: FakeData },
|
||||
) {}
|
||||
}
|
||||
g.DataTransfer = FakeData;
|
||||
g.ClipboardEvent = FakePaste;
|
||||
const ctx = realCtx({});
|
||||
const events: FakePaste[] = [];
|
||||
ctx.root = { dispatchEvent: (e: FakePaste) => events.push(e) } as unknown as HTMLElement;
|
||||
await menuPaste(ctx);
|
||||
assertEqual(
|
||||
[events[0]?.type, events[0]?.init.clipboardData.data["text/plain"]],
|
||||
["paste", "7\t8"],
|
||||
"보통 붙여넣기 = 부품 paste 길로",
|
||||
);
|
||||
ctx.selection = {
|
||||
시트: "s1",
|
||||
범위: [{ r0: 2, c0: 0, r1: 2, c1: 0 }],
|
||||
활성: { r: 2, c: 0 },
|
||||
기준: { r: 2, c: 0 },
|
||||
};
|
||||
await menuPaste(ctx, "값");
|
||||
assertEqual([val(ctx, 2, 0), val(ctx, 2, 1)], [7, 8], "값만 붙여넣기 — 활성 칸부터");
|
||||
assertEqual(labels(pasteItems(ctx)), ["붙여넣기", "선택하여 붙여넣기 ▸"], "붙여넣기 항목");
|
||||
}
|
||||
// ── 읽기 전용 — 복사 한 줄 ─────────────────────────────────────────────────
|
||||
{
|
||||
const ctx = realCtx({});
|
||||
assertEqual(labels(copyOnlyItems(ctx)), ["복사"], "읽기 전용 · 미리보기 = 복사 한 줄");
|
||||
}
|
||||
console.log("spreadsheet_menu_items 시험 끝");
|
||||
@@ -0,0 +1,46 @@
|
||||
/* =============================================================================
|
||||
* test_stage4_menu_perf.ts — 우클릭 속도(81-2③) 재현 시험. 돌리기: npx tsx <이 파일>.
|
||||
* `spreadsheet_menu.ts` rowMenuItems/colMenuItems 의 `at` 계산 — 옛 Math.min(...rows) 는
|
||||
* 전체 고름(열 머리 104만 개) 배열을 스프레드해 콜스택을 넘침(RangeError) · 고친 `rows[0]`
|
||||
* (rows 는 늘 오름차순 연속이라 첫 자리가 최소)는 크기와 무관하게 안전·상수 시간.
|
||||
* `computeSelectionStats` 쪽(①)은 createMapContextMenu 가 끌어오는 css 임포트 때문에
|
||||
* 이 node 시험 환경에서 직접 불러오지 못함 — ORCA 화면 검증으로 수치 확인(81-2 보고 참고).
|
||||
* ========================================================================== */
|
||||
|
||||
import { MAX_COLS, MAX_ROWS } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts";
|
||||
import { assertEqual } from "./test_stage2_helpers.ts";
|
||||
|
||||
const bigRows = Array.from({ length: MAX_ROWS }, (_, i) => i); // 열 머리 전체 고름 재현
|
||||
|
||||
let oldThrew = false;
|
||||
try {
|
||||
Math.min(...bigRows); // 옛 코드 — 이 크기에선 RangeError(콜스택 넘침)
|
||||
} catch {
|
||||
oldThrew = true;
|
||||
}
|
||||
if (!oldThrew) {
|
||||
console.error(
|
||||
"FAIL 옛 Math.min(...rows) 재현 실패 — 이 Node 가 다른 콜스택 한도를 씀(시험 전제 깨짐)",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("ok 옛 Math.min(...rows) 는 104만 개에서 콜스택을 넘침(고치기 전 재현)");
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
const at = bigRows[0]; // 고친 코드(spreadsheet_menu.ts rowMenuItems)
|
||||
const ms = performance.now() - t0;
|
||||
assertEqual(at, 0, "rows[0] = 최소값(오름차순 연속)");
|
||||
if (ms >= 5) {
|
||||
console.error(`FAIL rows[0] 은 5ms 안(상수 시간) — 실측 ${ms.toFixed(2)}ms`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`ok rows[0] ${ms.toFixed(3)}ms(크기 무관 상수 시간)`);
|
||||
}
|
||||
|
||||
// 열 머리(MAX_COLS) 도 같은 모양
|
||||
const bigCols = Array.from({ length: MAX_COLS }, (_, i) => i);
|
||||
assertEqual(bigCols[0], 0, "cols[0] = 최소값");
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
console.log("spreadsheet_menu 우클릭 속도(81-2③) 시험 끝");
|
||||
@@ -36,7 +36,15 @@ def test_명세_등록_필수_컨테이너_하나(out):
|
||||
|
||||
|
||||
def test_포장사양_선택지는_명세_한_곳(out):
|
||||
assert out["choices"] == ["콘크리트"]
|
||||
assert out["choices"] == ["콘크리트", "아스콘", "쇄석"]
|
||||
|
||||
|
||||
def test_상세내용_차례_포장사양_길이_출력_높이(out):
|
||||
assert out["items"] == [
|
||||
["spec", "포장사양", "select"],
|
||||
["length", "길이 (m)", "auto"],
|
||||
["height", "높이 (m)", "number"],
|
||||
]
|
||||
|
||||
|
||||
def test_저장_키_왕복_옛_칸은_명세_밖(out):
|
||||
@@ -44,7 +52,10 @@ def test_저장_키_왕복_옛_칸은_명세_밖(out):
|
||||
assert out["edited"] == {"pave_spec": "콘크리트", "height_m": 0.35}
|
||||
assert out["cleared"] == {"pave_spec": "콘크리트"}
|
||||
assert out["keys"] == {"keys": ["pave_spec", "height_m"], "prefixes": []}
|
||||
assert "length_m" not in out["retired"] # 구간 길이 · 전 · 후는 정의 칸 그대로
|
||||
# 길이 · 기준측점 전/후는 입력 없음 — 시작 · 종료만 받고 옛 값은 저장 때 뺌(87-1)
|
||||
assert out["byRange"] is True
|
||||
assert out["dropped"] == ["length_m", "before_m", "after_m"]
|
||||
assert "length_m" not in out["retired"]
|
||||
assert set(out["retired"]) == {
|
||||
"thickness_cm",
|
||||
"width_m",
|
||||
@@ -62,4 +73,7 @@ def test_기타_상세_종류_이름_글_칸():
|
||||
name = next(o for o in etc["options"] if o["key"] == "name")
|
||||
assert name["label"] == "상세 종류 이름" and name["input"] == "text"
|
||||
pave = next(t for t in doc["types"] if t["type_id"] == "pavement_concrete")
|
||||
assert [o["key"] for o in pave["options"]][:3] == ["length_m", "before_m", "after_m"]
|
||||
keys = [o["key"] for o in pave["options"]]
|
||||
assert not {"length_m", "before_m", "after_m"} & set(keys) # 87-1 시작 · 종료 직접 입력
|
||||
height = next(o for o in pave["options"] if o["key"] == "height_m")
|
||||
assert height["default"] == 0.2
|
||||
|
||||
@@ -75,7 +75,8 @@ def test_저장_값은_도_와_인스턴스_키(out):
|
||||
"width": "6",
|
||||
"height": "0.3",
|
||||
} # 월류 폭 · 높이 = 옛 키 그대로 읽음(65-5)
|
||||
assert out["state"] == {"slope": [slope], "chute": [{"structure": "돌붙임"}]}
|
||||
# 두께(cm · 90-2) — 빈 칸은 빈 값(새 상자만 기본 30)
|
||||
assert out["state"] == {"slope": [slope], "chute": [{"structure": "돌붙임", "thickness": ""}]}
|
||||
assert out["written"] == {
|
||||
"ford_slope_deg": 15,
|
||||
"ford_width_m": 6,
|
||||
|
||||
@@ -89,6 +89,8 @@ def test_다단_벽_고르기는_흙막이_컨테이너로() -> None:
|
||||
dock = (ROOT / "B06_Section" / "B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
|
||||
assert 'fieldset[data-spec-container="soil_add"]' in dock
|
||||
assert "panelRoot.dataset.extraIndex" in dock
|
||||
# 세월교 · 박스 유출 다단도 그 상자로(90-1 · 유출 자리 = 날개벽 칸)
|
||||
assert "ownerSlot === sideSlots.outlet || ownerSlot === sideSlots.wingOutlet" in dock
|
||||
panel = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Structure_Panel.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
@@ -45,7 +45,19 @@ def test_지형_한도를_넘으면_세운_만큼(out) -> None:
|
||||
|
||||
|
||||
def test_좌측_칸은_횡단_단_수_자리에_씀(out) -> None:
|
||||
assert out["writes"] == [[100, 2], [100, 1], [100, 5], [100, 0]]
|
||||
assert out["writes"][:4] == [[100, 2], [100, 1], [100, 5], [100, 0]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("facility", ["ford_bridge", "box_culvert"])
|
||||
def test_세월교_박스_유출도_같은_단_칸(out, facility) -> None:
|
||||
"""90-1 — 단 수 = 흙막이 상자 수 · 「+ 흙막이」 없음 · 유출이 흙막이 아니면 칸 · 옛 상자 숨김(값 남김)."""
|
||||
steps = out[facility]
|
||||
assert (steps["set2"]["value"], steps["set2"]["boxes"]) == ("2", 2)
|
||||
assert (steps["set0"]["value"], steps["set0"]["boxes"]) == ("0", 0)
|
||||
assert steps["set2"]["structures"] == ["형태", "형태"]
|
||||
assert not steps["set2"]["hidden"] and not steps["set2"]["add"]
|
||||
assert steps["wing"] == {"boxes": 0, "hidden": True, "add": False, "kept": 3}
|
||||
assert out["writes"][4:] == [[100, 2], [100, 0]] * 2
|
||||
|
||||
|
||||
def test_횡단에서_바꾸면_좌측_칸이_따라감(out) -> None:
|
||||
|
||||
@@ -35,7 +35,7 @@ GUARD = [
|
||||
["height", ["흙막이"], None], # 빈 칸 = 본체 끝 높이(79-7 · 횡단도가 셈)
|
||||
["before", ["흙막이"], "5"],
|
||||
["after", ["흙막이"], "5"],
|
||||
["berm", ["흙막이"], "1"], # 절토부 앞 수평공간(79-7)
|
||||
["berm", ["흙막이"], "1"], # 절토부 앞 수평공간(79-7) — 유입만(89-4)
|
||||
]
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@ def out() -> dict:
|
||||
def test_유입_유출은_구조와_흙막이_칸만(out):
|
||||
for spec in ("ford", "box"):
|
||||
for side in ("inlet", "outlet"):
|
||||
assert out[spec][side] == GUARD, (spec, side)
|
||||
want = GUARD if side == "inlet" else GUARD[:-1]
|
||||
assert out[spec][side] == want, (spec, side)
|
||||
assert [i[0] for i in out[spec]["soil_add"]] == [
|
||||
"form",
|
||||
"length",
|
||||
@@ -63,7 +64,8 @@ def test_유입_유출은_구조와_흙막이_칸만(out):
|
||||
"before",
|
||||
"after",
|
||||
]
|
||||
assert out[spec]["chute_add"] == [["structure", None, None]]
|
||||
# 도수로/돌붙임 = 구조 · 두께(cm · 새 상자 30 · 90-2)
|
||||
assert out[spec]["chute_add"] == [["structure", None, None], ["thickness", None, "30"]]
|
||||
keys = out[f"{spec}Keys"]
|
||||
assert keys["prefixes"] == ["soil_add_", "chute_add_"]
|
||||
# 측벽 높이 · 좌우 · 상하 · 바닥판 · 날개벽 치수 칸 없음
|
||||
@@ -133,18 +135,28 @@ def test_자동_배치_새_세월교는_기본값과_월류_높이():
|
||||
assert "ford_height_m" not in blind.options # 유량 없으면 빈칸
|
||||
|
||||
|
||||
def test_집계표_세월교_흙막이_상자_건():
|
||||
item = {
|
||||
"type_id": "ford_bridge",
|
||||
"options": {"soil_add_1_form": "바자", "soil_add_2_form": "돌망태"},
|
||||
"복사본": "pipe@50.0",
|
||||
"흙막이단": 0, # 배관 단 수는 세월교에 안 걸림
|
||||
}
|
||||
rows = container_rows(item, {"gsoiladd2": "SS000001"})
|
||||
assert [(r["그룹"], r["summary_item"]) for r in rows] == [
|
||||
("흙막이 1", None),
|
||||
("흙막이 2", "SS000001"),
|
||||
]
|
||||
def test_집계표_세월교_박스_흙막이_상자는_유출_단_수만큼():
|
||||
"""90-1 — 관보호공과 같은 방향(단 수 → 상자) · 유출이 흙막이가 아니면 옛 상자는 집계에서 뺌."""
|
||||
soil = {"soil_add_1_form": "바자", "soil_add_2_form": "돌망태"}
|
||||
|
||||
def groups(type_id: str, outlet: str, tiers: int | None) -> list[tuple[str, object]]:
|
||||
key = {"ford_bridge": "outlet_type", "box_culvert": "wing_out"}[type_id]
|
||||
item = {
|
||||
"type_id": type_id,
|
||||
"options": {**soil, key: outlet},
|
||||
"복사본": "pipe@50.0",
|
||||
"흙막이단": tiers,
|
||||
}
|
||||
rows = container_rows(item, {"gsoiladd2": "SS000001"})
|
||||
return [(r["그룹"], r["summary_item"]) for r in rows]
|
||||
|
||||
for type_id in ("ford_bridge", "box_culvert"):
|
||||
both = [("흙막이 1", None), ("흙막이 2", "SS000001")]
|
||||
assert groups(type_id, "흙막이", 2) == both
|
||||
assert groups(type_id, "흙막이", 1) == both[:1]
|
||||
assert groups(type_id, "흙막이", None) == both # 모름 = 저장된 것 그대로
|
||||
assert groups(type_id, "날개벽", 2) == []
|
||||
assert groups(type_id, "", None) == []
|
||||
|
||||
|
||||
def test_B06_세월교_박스_칸은_측벽_높이_줄만_받음():
|
||||
|
||||
@@ -79,13 +79,27 @@ def test_박스_날개벽_끝_절성토선(shape: dict) -> None:
|
||||
def test_흙막이_끝_절토는_수평공간_뒤(shape: dict) -> None:
|
||||
tails = [tail for group in shape["cutDefault"] for tail in group]
|
||||
assert tails, "절토 끝선이 없음"
|
||||
assert any(tail["role"] == "inlet" for tail in tails)
|
||||
for tail in tails:
|
||||
# 시작 · 수평공간 끝 · (암 무릎 · 소단) · 원지반 교점 — 본선 절토와 같은 복합경사(91-4)
|
||||
assert tail["points"] >= 3
|
||||
assert tail["flat"] == pytest.approx(0, abs=1e-6)
|
||||
assert tail["berm"] == pytest.approx(1.0, abs=1e-3)
|
||||
assert tail["points"] >= 2
|
||||
if tail["role"] == "inlet":
|
||||
assert tail["flat"] == pytest.approx(0, abs=1e-6)
|
||||
assert tail["berm"] == pytest.approx(1.0, abs=1e-3)
|
||||
else:
|
||||
# 유출은 수평공간 없음 — 묻히면 첫 꺾임부터 바로 절토(89-4)
|
||||
assert tail["flat"] > 0
|
||||
for tail in shape["cutBerm2"]:
|
||||
assert tail["berm"] == pytest.approx(2.0, abs=1e-3)
|
||||
if tail["role"] == "inlet":
|
||||
assert tail["berm"] == pytest.approx(2.0, abs=1e-3)
|
||||
else:
|
||||
assert tail["flat"] > 0
|
||||
|
||||
|
||||
def test_유입_수평공간_시작은_본체_바닥(shape: dict) -> None:
|
||||
"""89-3 — 박스 저판 상면 · 세월교 바닥판 상면 높이에서 수평 → 절토."""
|
||||
for case in shape["inletFloor"]:
|
||||
assert case["start"] == pytest.approx(case["floor"], abs=1e-3)
|
||||
|
||||
|
||||
def test_관_보호공_기본_자리는_제일_위(shape: dict) -> None:
|
||||
|
||||
@@ -47,6 +47,17 @@ def test_관은_흙막이_전면까지_바닥판은_배면까지(fit: dict) -> N
|
||||
assert side["slabGap"] == pytest.approx(0, abs=1e-3)
|
||||
|
||||
|
||||
def test_관_윗모서리도_흙막이_전면_사면에(fit: dict) -> None:
|
||||
"""89-1 — 흙막이 사면 이용: 윗모서리가 그 높이 전면 자리라 벽 밖으로 안 튀어나감."""
|
||||
leaned = 0
|
||||
for case in (fit[k] for k in ("both", "inletPlus1", "outletPlus2", "inletOnly")):
|
||||
for side in case["sides"]:
|
||||
if side["guard"]:
|
||||
assert side["topGap"] == pytest.approx(0, abs=1e-3)
|
||||
leaned += side["lean"] > 0
|
||||
assert leaned
|
||||
|
||||
|
||||
def test_흙막이를_옮기면_관이_그만큼_따라감(fit: dict) -> None:
|
||||
base = fit["both"]["lengthM"]
|
||||
assert fit["inletPlus1"]["lengthM"] == pytest.approx(base + 1, abs=1e-3)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""노면공 확폭 구간(PLAN 87-3) — 구간 묶기 · `widening_off` 글 읽고 쓰기 · 안 맞는 값 무시.
|
||||
|
||||
규칙은 Node 헬퍼(`helper_87_widening.cjs`)가 `design_structure_widening.ts` 를 그대로 돌림.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_87_widening.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_곡선_둘은_구간_둘_확폭은_구간_최대(out):
|
||||
assert out["runs"] == [
|
||||
{"start_m": 20, "end_m": 60, "widening_m": 0.75},
|
||||
{"start_m": 100, "end_m": 120, "widening_m": 0.6},
|
||||
]
|
||||
|
||||
|
||||
def test_범위_안_측점만_묶음(out):
|
||||
assert out["inner"] == [
|
||||
{"start_m": 40, "end_m": 60, "widening_m": 0.75},
|
||||
{"start_m": 100, "end_m": 100, "widening_m": 0.5},
|
||||
]
|
||||
assert out["none"] == []
|
||||
|
||||
|
||||
def test_설계_없으면_곡선_확폭_바깥쪽만(out):
|
||||
assert out["fallback"] == 0.9
|
||||
assert out["fallbackStraight"] == 0
|
||||
|
||||
|
||||
def test_끈_구간_글_왕복(out):
|
||||
assert out["off"] == "20-60"
|
||||
assert out["parsed"] == [[20, 60], [100, 120]] # 틀린 조각은 버림
|
||||
assert out["isOff"] == [True, False]
|
||||
assert out["toggledOff"] == "100-120"
|
||||
assert out["toggledBoth"] == "20-60,100-120"
|
||||
assert out["toggledOn"] == ""
|
||||
|
||||
|
||||
def test_안_맞는_끈_값은_무시_저장_때_빠짐(out):
|
||||
assert out["stale"] == "20-60"
|
||||
assert out["empty"] == ""
|
||||
@@ -0,0 +1,59 @@
|
||||
"""90-2 도수로 · 돌붙임 띠 — 윗면 = 유출 성토사면 선 · 사면 안쪽(아래)으로 두께만큼 · 안에 돌.
|
||||
|
||||
화면 TS(`appendChuteBand`)를 그 자리에서 돌린 값(`helper_90_chute_band.cjs`).
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
ROOT = HERE.parents[1]
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_90_chute_band.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def _distance(p: dict, a: dict, b: dict) -> float:
|
||||
dx, dz = b["offset"] - a["offset"], b["elevation"] - a["elevation"]
|
||||
cross = (p["offset"] - a["offset"]) * dz - (p["elevation"] - a["elevation"]) * dx
|
||||
return abs(cross) / math.hypot(dx, dz)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["left", "right"])
|
||||
def test_띠_두께는_사면에서_직각으로_0_30m_아래(out: dict, case: str) -> None:
|
||||
points = out[case]["points"]
|
||||
n = len(points) // 2
|
||||
top, bottom = points[:n], points[n:][::-1]
|
||||
for i, low in enumerate(bottom):
|
||||
# 꼭짓점마다 붙은 변(앞 · 뒤)까지 직각 거리 = 두께
|
||||
for j in {max(i - 1, 0), min(i, n - 2)}:
|
||||
assert _distance(low, top[j], top[j + 1]) == pytest.approx(0.3, abs=1e-6)
|
||||
assert low["elevation"] < top[i]["elevation"] # 사면 안쪽 = 아래
|
||||
|
||||
|
||||
def test_안에_돌_윗면은_사면선(out: dict) -> None:
|
||||
for case in out.values():
|
||||
assert case["stones"] >= 2 and case["clipped"]
|
||||
assert "돌붙임" in case["title"] and "0.30m" in case["title"]
|
||||
assert out["left"]["points"][:4] == [
|
||||
{"offset": 5, "elevation": 10},
|
||||
{"offset": 7.4, "elevation": 8},
|
||||
{"offset": 8.4, "elevation": 8},
|
||||
{"offset": 10.8, "elevation": 6},
|
||||
]
|
||||
@@ -94,7 +94,9 @@ def _knee(cut: dict) -> bool:
|
||||
def test_흙막이_수평공간_뒤_절토_복합경사(fit: dict) -> None:
|
||||
tails = fit["guardTails"]
|
||||
assert tails and all(cut["endGap"] < 0.01 for cut in tails)
|
||||
assert all(cut["legs"][0]["ratio"] is None for cut in tails) # 수평공간 먼저
|
||||
# 유입 = 수평공간 먼저 · 유출 = 수평공간 없이 바로 절토(89-4)
|
||||
for cut in tails:
|
||||
assert (cut["legs"][0]["ratio"] is None) == (cut["role"] == "inlet")
|
||||
assert sum(_knee(cut) for cut in tails) >= 2
|
||||
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402
|
||||
APRON_LENGTH_FACTOR,
|
||||
APRON_THICKNESS_M,
|
||||
CHUTE_THICKNESS_CM,
|
||||
MIN_PIPE_COVER_M,
|
||||
REVET_FACE_SLOPE,
|
||||
_culvert_set,
|
||||
@@ -24,10 +23,9 @@ from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402
|
||||
|
||||
|
||||
def test_cross_reference_constants_match_plan():
|
||||
"""계획서 3-0 표 값 — 토피 0.5m·보호공 길이 2배·두께 0.45m(사용자 확정)·전면 1:0.3."""
|
||||
"""계획서 3-0 표 값 — 토피 0.5m·전면 1:0.3 · 도수로/돌붙임 빈 두께 30 cm(90-2 사용자 확정)."""
|
||||
assert MIN_PIPE_COVER_M == 0.5
|
||||
assert APRON_LENGTH_FACTOR == 2.0
|
||||
assert APRON_THICKNESS_M == 0.45
|
||||
assert CHUTE_THICKNESS_CM == 30.0
|
||||
assert REVET_FACE_SLOPE == 0.3
|
||||
# 배관은 수평도 가능 — 세트에 경사 강제값을 싣지 않는다(2026-08-20 사용자 확정).
|
||||
assert "min_slope" not in _culvert_set(None)
|
||||
@@ -54,9 +52,8 @@ def test_saved_values_drive_members():
|
||||
assert part["revet_form"] is None
|
||||
assert part["revet_height_m"] == 2.0
|
||||
assert part["face_slope"] == REVET_FACE_SLOPE
|
||||
# 보호공 = 낙차고(2.0) × 2 = 4.0m, 두께 0.45m(사용자 확정).
|
||||
assert part["apron_length_m"] == 4.0
|
||||
assert part["apron_thickness_m"] == 0.45
|
||||
# 옛 보호공(apron) 칸은 걷음 — 유출 보호는 도수로/돌붙임 띠(90-2)
|
||||
assert "apron_length_m" not in part
|
||||
|
||||
|
||||
def test_basin_inlet_skips_revet_and_apron():
|
||||
@@ -64,10 +61,9 @@ def test_basin_inlet_skips_revet_and_apron():
|
||||
spec = _culvert_set({"inlet_type": "집수정", "outlet_type": "기슭막이"})
|
||||
assert spec["inlet"]["structure"] == "집수정"
|
||||
assert "revet_height_m" not in spec["inlet"]
|
||||
assert "apron_length_m" not in spec["inlet"]
|
||||
# 유출측은 그대로 세트를 갖춘다.
|
||||
assert spec["outlet"]["structure"] == "기슭막이"
|
||||
assert spec["outlet"]["apron_length_m"] == 4.0 # 빈 높이 = 관경 기준 최소 2.0 × 2
|
||||
assert spec["outlet"]["revet_height_m"] == 2.0 # 빈 높이 = 관경 기준 최소
|
||||
|
||||
|
||||
def test_string_diameter_and_custom_height():
|
||||
@@ -77,7 +73,15 @@ def test_string_diameter_and_custom_height():
|
||||
)
|
||||
assert spec["diameter_m"] == 0.8
|
||||
assert spec["outlet"]["revet_height_m"] == 1.5
|
||||
assert spec["outlet"]["apron_length_m"] == 3.0 # 1.5 × 2
|
||||
|
||||
|
||||
def test_chute_thickness_cm_to_m():
|
||||
"""도수로/돌붙임(90-2) — 구조를 고르면 `chute` · 두께 cm → m · 빈 두께 30 cm · 안 고르면 없음."""
|
||||
assert "chute" not in _culvert_set({"chute_add_1_thickness": 40})
|
||||
spec = _culvert_set({"chute_add_1_structure": "돌붙임", "chute_add_1_thickness": "45"})
|
||||
assert spec["chute"] == {"structure": "돌붙임", "thickness_m": 0.45}
|
||||
blank = _culvert_set({"chute_add_1_structure": "도수로", "chute_add_1_thickness": ""})
|
||||
assert blank["chute"] == {"structure": "도수로", "thickness_m": 0.3}
|
||||
|
||||
|
||||
def test_attach_only_pipe_facilities(tmp_path):
|
||||
|
||||
@@ -57,6 +57,10 @@ POINTS = [
|
||||
"inlet_type": "흙막이",
|
||||
"inlet_revet_form": "콘크리트",
|
||||
"inlet_revet_height_m": 1.5,
|
||||
"inlet_revet_berm_m": 2,
|
||||
"outlet_revet_berm_m": 3, # 유출 수평공간 칸 없음(89-4) — 세트에 안 실림
|
||||
# 도수로/돌붙임(90-2) — 빈 두께 = 30 cm
|
||||
"chute_add_1_structure": "도수로",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -72,6 +76,8 @@ POINTS = [
|
||||
"outlet_revet_form": "돌쌓기(찰)",
|
||||
"outlet_revet_height_m": 3.5,
|
||||
"outlet_revet_length_m": 10,
|
||||
"chute_add_2_structure": "돌붙임",
|
||||
"chute_add_2_thickness": 45,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -199,3 +205,8 @@ def test_ford_box_guard_attached_as_culvert() -> None:
|
||||
assert box["diameter_m"] == round(b["top_thickness_m"] + 2.5 + b["slab_thickness_m"], 3)
|
||||
# 흙막이 없는 시설엔 안 얹음
|
||||
assert "culvert" not in sections[100.0]
|
||||
# 유입만 수평공간(89-4) · 도수로/돌붙임 두께 cm → m(90-2)
|
||||
assert ford["inlet"]["revet_berm_m"] == 2 and "revet_berm_m" not in ford["outlet"]
|
||||
assert sections[60.0]["ford"]["chute"] == {"structure": "도수로", "thickness_m": 0.3}
|
||||
assert box["chute"] == {"structure": "돌붙임", "thickness_m": 0.45}
|
||||
assert "chute" not in sections[40.0]["culvert"]
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""노면공 포장 제원(88) — 파이썬·TS 짝 거울 시험.
|
||||
|
||||
`common_util/common_util_structure_pavement.py`(서버 · 저장분) 와 `.ts`(브라우저 · 초안)가
|
||||
같은 입력에 같은 `section.pavement` 를 내는지 대조. TS 는 프로젝트 tsc 로 옮겨 실제 코드 그대로 돌림.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
from common_util.common_util_structure_pavement import ( # noqa: E402
|
||||
attach_pavement_specs,
|
||||
pavement_specs_from,
|
||||
)
|
||||
|
||||
STRUCTURES = [
|
||||
{
|
||||
"type_id": "pavement_concrete",
|
||||
"placement": "interval",
|
||||
"chainage_m": None,
|
||||
"start_m": 100.0,
|
||||
"end_m": 160.0,
|
||||
"options": {"pave_spec": "콘크리트", "height_m": 0.25, "widening_off": "120-140, x-1,,"},
|
||||
},
|
||||
{
|
||||
# 뒤집힌 구간 · 두께 빔 · 사양 빔 — 두 벌 다 작은 값을 시작으로 · null 로.
|
||||
"type_id": "gravel_surfacing",
|
||||
"placement": "interval",
|
||||
"start_m": 150.0,
|
||||
"end_m": 130.0,
|
||||
"options": {"pave_spec": "", "height_m": "0.3"},
|
||||
},
|
||||
{"type_id": "pavement_concrete", "start_m": None, "end_m": None, "options": {}},
|
||||
{"type_id": "masonry_wet", "start_m": 0.0, "end_m": 500.0, "options": {}},
|
||||
{
|
||||
"type_id": "pavement_concrete",
|
||||
"chainage_m": 300.0,
|
||||
"options": {"pave_spec": "아스콘", "height_m": 0.05, "widening_off": "290-310"},
|
||||
},
|
||||
]
|
||||
TYPE_IDS = ["pavement_concrete", "gravel_surfacing"]
|
||||
CHAINAGES = [99.97, 100.0, 119.99, 130.0, 145.0, 160.02, 160.05, 300.0, 400.0]
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { attachPavementSpecs, pavementSpecsFrom } from "./common_util_structure_pavement.js";
|
||||
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const sections = input.chainages.map((chainage_m) => ({ chainage_m }));
|
||||
attachPavementSpecs(sections, pavementSpecsFrom(input.structures, new Set(input.type_ids)));
|
||||
writeFileSync(process.argv[3], JSON.stringify(sections));
|
||||
"""
|
||||
|
||||
|
||||
def _python_result() -> list[dict]:
|
||||
sections = [{"chainage_m": value} for value in CHAINAGES]
|
||||
attach_pavement_specs(sections, pavement_specs_from(STRUCTURES, set(TYPE_IDS)))
|
||||
return sections
|
||||
|
||||
|
||||
def _ts_result(tmp_path: Path) -> list[dict]:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(PROJECT_ROOT / "common_util" / "common_util_structure_pavement.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
payload = tmp_path / "input.json"
|
||||
result = tmp_path / "output.json"
|
||||
payload.write_text(
|
||||
json.dumps({"structures": STRUCTURES, "type_ids": TYPE_IDS, "chainages": CHAINAGES}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_pavement_specs_mirror():
|
||||
with tempfile.TemporaryDirectory() as workdir:
|
||||
ts = _ts_result(Path(workdir))
|
||||
py = _python_result()
|
||||
assert ts == py
|
||||
by = {section["chainage_m"]: section.get("pavement") for section in py}
|
||||
# 범위 밖 · 경계 오차(0.02 m) 안
|
||||
assert by[99.97] is None and by[160.05] is None and by[400.0] is None
|
||||
assert [entry["type_id"] for entry in by[100.0]] == ["pavement_concrete"]
|
||||
assert by[160.02][0]["thickness_m"] == 0.25
|
||||
# 확폭 끈 구간 120~140 (±0.02) — 끔 · 못 읽는 조각은 버림
|
||||
assert by[119.99][0]["widening_on"] is False
|
||||
assert by[100.0][0]["widening_on"] is True
|
||||
# 겹친 범위 = 둘 다 · 뒤집힌 구간 정렬 · 문자 두께 · 빈 사양 = null
|
||||
assert [entry["type_id"] for entry in by[145.0]] == ["pavement_concrete", "gravel_surfacing"]
|
||||
gravel = by[145.0][1]
|
||||
assert (gravel["start_m"], gravel["end_m"]) == (130.0, 150.0)
|
||||
assert gravel["thickness_m"] is None and gravel["spec"] is None
|
||||
# 기준 측점만 있는 옛 항목 = 그 측점 한 점
|
||||
assert by[300.0] == [
|
||||
{
|
||||
"type_id": "pavement_concrete",
|
||||
"spec": "아스콘",
|
||||
"thickness_m": 0.05,
|
||||
"start_m": 300.0,
|
||||
"end_m": 300.0,
|
||||
"widening_on": False,
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
"""B07 도면 불러오기 — 호 · bulge 폴리선 · 거울 블록 · 겹 블록 견본 DXF(84-2) · 내보내기 시계방향 호(84-3).
|
||||
|
||||
견본은 ezdxf 로 그 자리에서 만들어 끌어다 놓기 길(`drawing_file_to_entities`)로 읽는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import math
|
||||
|
||||
import ezdxf
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
|
||||
from B07_DesignDetail import B07_DesignDetail_Engine_Frame_Export as export
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import drawing_file_to_entities
|
||||
|
||||
|
||||
def _read(doc: ezdxf.document.Drawing) -> list[dict]:
|
||||
stream = io.StringIO()
|
||||
doc.write(stream)
|
||||
entities, _layers = drawing_file_to_entities("견본.dxf", stream.getvalue().encode("utf-8"))
|
||||
return entities
|
||||
|
||||
|
||||
def _points(polyline: dict) -> list[tuple[float, float]]:
|
||||
children = polyline["children"]
|
||||
points = [
|
||||
(c["shapeData"]["startPoint"]["x"], c["shapeData"]["startPoint"]["y"]) for c in children
|
||||
]
|
||||
last = children[-1]["shapeData"]["endPoint"]
|
||||
return [*points, (last["x"], last["y"])]
|
||||
|
||||
|
||||
def _on_circle(points, cx: float, cy: float, r: float) -> bool:
|
||||
return all(abs(math.hypot(x - cx, y - cy) - r) < 0.21 for x, y in points)
|
||||
|
||||
|
||||
def test_호는_1사분면_작은_호():
|
||||
doc = ezdxf.new()
|
||||
doc.modelspace().add_arc((0, 0), 10, 0, 90)
|
||||
(arc,) = _read(doc)
|
||||
points = _points(arc)
|
||||
assert _on_circle(points, 0, 0, 10)
|
||||
assert points[0] == (10.0, 0.0) and math.isclose(points[-1][0], 0, abs_tol=1e-9)
|
||||
assert all(x >= -1e-9 and y >= -1e-9 for x, y in points) # 큰 호(270°)로 뒤집히지 않음
|
||||
|
||||
|
||||
def test_bulge_폴리선은_호로_펴짐():
|
||||
doc = ezdxf.new()
|
||||
space = doc.modelspace()
|
||||
# 반원(bulge 1 · 반시계 → 아래로) · 작은 호(bulge −0.2 · 시계 → 위로 1)
|
||||
space.add_lwpolyline([(0, 0, 0, 0, 1), (10, 0)], format="xyseb")
|
||||
space.add_lwpolyline([(0, 20, 0, 0, -0.2), (10, 20)], format="xyseb")
|
||||
half, small = _read(doc)
|
||||
half_points = _points(half)
|
||||
assert len(half_points) > 3 and _on_circle(half_points, 5, 0, 5)
|
||||
assert min(y for _x, y in half_points) < -4.9
|
||||
small_points = _points(small)
|
||||
assert len(small_points) > 3
|
||||
assert math.isclose(max(y for _x, y in small_points), 21.0, abs_tol=0.01) # 처짐 = 0.2 × 10 / 2
|
||||
assert min(y for _x, y in small_points) >= 20 - 1e-9
|
||||
|
||||
|
||||
def test_거울_블록은_WCS_자리():
|
||||
doc = ezdxf.new()
|
||||
block = doc.blocks.new("거울")
|
||||
block.add_lwpolyline([(0, 0, 0, 0, -0.2), (10, 0)], format="xyseb")
|
||||
block.add_arc((20, 0), 2, 0, 90)
|
||||
block.add_text("가", dxfattribs={"height": 2.5}).set_placement((2, 3))
|
||||
doc.modelspace().add_blockref("거울", (100, 0), dxfattribs={"xscale": -1})
|
||||
by_type: dict[str, list[dict]] = {}
|
||||
for entity in _read(doc):
|
||||
by_type.setdefault(entity["type"], []).append(entity)
|
||||
bulge, arc = by_type["PolyLine"]
|
||||
bulge_points = _points(bulge)
|
||||
assert (
|
||||
min(x for x, _y in bulge_points) >= 90 - 1e-9
|
||||
and max(x for x, _y in bulge_points) <= 100 + 1e-9
|
||||
)
|
||||
assert math.isclose(max(y for _x, y in bulge_points), 1.0, abs_tol=0.01) # 위로 그대로
|
||||
arc_points = _points(arc)
|
||||
assert _on_circle(arc_points, 80, 0, 2)
|
||||
assert all(x <= 80 + 1e-9 and y >= -1e-9 for x, y in arc_points) # 2사분면으로 대칭
|
||||
(text,) = by_type["Text"]
|
||||
assert text["shapeData"]["basePoint"] == {"x": 98.0, "y": 3.0}
|
||||
|
||||
|
||||
def test_겹_블록도_풀림():
|
||||
doc = ezdxf.new()
|
||||
doc.blocks.new("속").add_line((0, 0), (1, 0))
|
||||
doc.blocks.new("겉").add_blockref("속", (5, 0))
|
||||
doc.modelspace().add_blockref("겉", (10, 0))
|
||||
(line,) = _read(doc)
|
||||
assert line["type"] == "Line"
|
||||
assert line["shapeData"] == {
|
||||
"startPoint": {"x": 15.0, "y": 0.0},
|
||||
"endPoint": {"x": 16.0, "y": 0.0},
|
||||
}
|
||||
|
||||
|
||||
def test_TEXT_정렬점_자리():
|
||||
doc = ezdxf.new()
|
||||
space = doc.modelspace()
|
||||
space.add_text("가운데", dxfattribs={"height": 2}).set_placement(
|
||||
(50, 5), align=TextEntityAlignment.MIDDLE_CENTER
|
||||
)
|
||||
space.add_text("오른", dxfattribs={"height": 2}).set_placement(
|
||||
(80, 5), align=TextEntityAlignment.BOTTOM_RIGHT
|
||||
)
|
||||
center, right = _read(doc)
|
||||
assert center["shapeData"]["basePoint"] == {"x": 50.0, "y": 5.0}
|
||||
assert center["shapeData"]["options"]["textAlign"] == "center"
|
||||
assert right["shapeData"]["basePoint"] == {"x": 80.0, "y": 5.0}
|
||||
assert right["shapeData"]["options"]["textAlign"] == "right"
|
||||
|
||||
|
||||
def test_내보내기_시계방향_작은_호(tmp_path):
|
||||
arc = {
|
||||
"type": "Arc",
|
||||
"layerId": "기본",
|
||||
"shapeData": {
|
||||
"center": {"x": 0, "y": 0},
|
||||
"radius": 1,
|
||||
"startAngle": math.pi / 2,
|
||||
"endAngle": 0,
|
||||
"counterClockwise": False,
|
||||
},
|
||||
}
|
||||
raw, skipped = export.drawing_to_dxf({"entities": [arc], "layers": []})
|
||||
assert skipped == {}
|
||||
path = tmp_path / "out.dxf"
|
||||
path.write_bytes(raw)
|
||||
(out,) = ezdxf.readfile(path).modelspace().query("ARC")
|
||||
span = (out.dxf.end_angle - out.dxf.start_angle) % 360
|
||||
assert math.isclose(span, 90) and math.isclose(out.dxf.start_angle % 360, 0, abs_tol=1e-9)
|
||||
@@ -0,0 +1,233 @@
|
||||
"""CAD 공용 탭 줄(PLAN 82-1 `cad_host_tabs.ts`) · M02 구조물 도면 여러 장(82-2 `mountStructureSheets`)
|
||||
— Node 로 TS 를 돌려 판정(가짜 DOM · 가짜 CAD).
|
||||
|
||||
못박는 것
|
||||
- 탭 바꿀 때 지금 장을 받아 두고(capture) 다음 장을 실음 · 탭 셋 왕복 뒤 장마다 엔티티 수 그대로.
|
||||
- 고정 탭(1장) 앞으로는 못 끎 · 지우기 · 같은 이름 막음.
|
||||
- 여러 장 문서 getDoc = `도면`(1장) + `더한장[{장id, 이름, 도면}]` · 새 장 · 장 도번 SD-1-2 · 백업 칸 장마다.
|
||||
- 안 실린 장 표 갈기(updateTable) = 받아 둔 장에서.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = r"""
|
||||
const fs = require("fs");
|
||||
const root = process.argv[1];
|
||||
const ts = require(root + "/config/node_modules/typescript");
|
||||
const load = (file, stubs) => {
|
||||
const src = fs.readFileSync(root + "/" + file, "utf8");
|
||||
const js = ts.transpileModule(src, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, (n) =>
|
||||
n.endsWith(".css") ? {} : (stubs[n] ?? require(n)),
|
||||
);
|
||||
return box.exports;
|
||||
};
|
||||
// 가짜 DOM — 쓰는 것만
|
||||
const node = (tag) => ({
|
||||
tag, kids: [], dataset: {}, on: {}, attrs: {}, hidden: false, textContent: "", title: "", className: "",
|
||||
classList: { add() {}, toggle() {} }, style: {},
|
||||
append(...x) { this.kids.push(...x); }, prepend(...x) { this.kids.unshift(...x); },
|
||||
replaceChildren(...x) { this.kids = x; }, replaceWith() {}, remove() {}, select() {}, focus() {},
|
||||
setAttribute(k, v) { this.attrs[k] = v; }, addEventListener(k, f) { this.on[k] = f; },
|
||||
getBoundingClientRect: () => ({ left: 0, width: 100 }), querySelector: () => null,
|
||||
});
|
||||
globalThis.document = { createElement: node };
|
||||
const toasts = [];
|
||||
const confirms = [];
|
||||
const elements = {
|
||||
showToast: (m, k) => toasts.push([k, m]),
|
||||
showConfirmDialog: async (m) => (confirms.push(m), true),
|
||||
createButton: () => node("button"),
|
||||
createInputField: (o) => ({ root: node("div"), input: { value: o.value, disabled: false, addEventListener() {} }, setError() {} }),
|
||||
};
|
||||
const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template_elements": elements });
|
||||
|
||||
(async () => {
|
||||
const out = {};
|
||||
// ① 탭 셋 왕복 — CAD 하나에 장을 갈아 실음
|
||||
let shown = { id: "a", n: 3 };
|
||||
const log = [];
|
||||
const tabs = tabsMod.createCadTabs({
|
||||
features: { add: true, rename: true, remove: true, reorder: true },
|
||||
capture: async () => ({ drawing: { ...shown, n: shown.n + (shown.id === "b" ? 1 : 0) }, dirty: shown.id === "b" }),
|
||||
show: (tab) => (log.push(tab.id), (shown = tab.drawing)),
|
||||
onChange: () => log.push("change"),
|
||||
});
|
||||
tabs.set([
|
||||
{ id: "a", name: "1장", drawing: { id: "a", n: 3 }, fixed: true },
|
||||
{ id: "b", name: "2장", drawing: { id: "b", n: 5 } },
|
||||
{ id: "c", name: "3장", drawing: { id: "c", n: 0 } },
|
||||
], "a");
|
||||
for (const id of ["b", "c", "a", "b", "a"]) await tabs.select(id);
|
||||
const all = await tabs.collect();
|
||||
out.roundtrip = all.map((t) => [t.id, t.drawing.n, !!t.dirty]);
|
||||
out.shownOrder = log.filter((x) => x !== "change");
|
||||
// ② 끌기 — c 를 a(고정) 앞에 놓아도 a 뒤
|
||||
const tabNode = (id) => tabs.element.kids.find((k) => k.dataset.tabId === id);
|
||||
tabNode("c").on.dragstart({ dataTransfer: { setData() {} } });
|
||||
tabNode("a").on.dragover({ preventDefault() {}, clientX: 10 });
|
||||
tabNode("a").on.drop({ preventDefault() {} });
|
||||
out.order = tabs.tabs().map((t) => t.id);
|
||||
out.fixedDraggable = !!tabNode("a").draggable;
|
||||
out.fixedX = tabNode("a").kids.length; // 고정 탭 = 글만(지우기 없음)
|
||||
// ③ 지우기 — 물음 뒤 · 지금 탭이면 다음 탭을 실음
|
||||
await tabs.select("c");
|
||||
const xc = tabNode("c").kids.find((k) => k.className === "cad-tabs__x");
|
||||
await xc.on.click({ stopPropagation() {} });
|
||||
out.afterRemove = { ids: tabs.tabs().map((t) => t.id), active: tabs.active().id, confirms: confirms.length };
|
||||
// ④ [+] — create 없으면 아무것도 안 함 · 닫기 기능(B07) = 미저장 탭만 물음
|
||||
const b07 = [];
|
||||
const closer = tabsMod.createCadTabs({
|
||||
features: { close: true },
|
||||
capture: async () => ({ drawing: "편집본", dirty: true }),
|
||||
show: (tab) => b07.push(["show", tab.id, tab.drawing ?? null, !!tab.dirty]),
|
||||
onClosed: (tab) => b07.push(["closed", tab.id]),
|
||||
});
|
||||
await closer.open({ id: "d1", name: "종단" });
|
||||
await closer.open({ id: "d2", name: "횡단" });
|
||||
await closer.open({ id: "d1", name: "종단" });
|
||||
const before = confirms.length;
|
||||
const d2 = closer.element.kids.find((k) => k.dataset.tabId === "d2");
|
||||
await d2.kids.find((k) => k.className === "cad-tabs__x").on.click({ stopPropagation() {} });
|
||||
const last = closer.element.kids.find((k) => k.dataset.tabId === "d1").kids.length; // 마지막 탭 = 닫기 없음
|
||||
out.b07 = { log: b07, asked: confirms.length - before, ids: closer.tabs().map((t) => t.id), last };
|
||||
|
||||
// ⑤ M02 여러 장 — 가짜 CAD(requestSave = 지금 실린 도면 + 편집)
|
||||
const cadLoads = [];
|
||||
let inCad = null;
|
||||
const fakeCad = {
|
||||
element: node("div"),
|
||||
beginLoading() {},
|
||||
load: (drawing, meta, frameEdit, fields, extra) => (cadLoads.push(extra.recoveryScope), (inCad = drawing)),
|
||||
requestSave: async () => ({ drawing: inCad, quantityTable: null }),
|
||||
placeTable() {},
|
||||
updateTable: async () => 1,
|
||||
destroy() {},
|
||||
};
|
||||
const urls = [];
|
||||
globalThis.fetch = async (url) => (urls.push(url), { ok: true, json: async () => ({ entities: [{ type: "Text", url, layerId: "m02-structure-frame" }], layer: { id: "m02-structure-frame" } }) });
|
||||
const drawingMod = load("M02_MasterTemplete/M02_MasterTemplete_Drawing.ts", {
|
||||
"@config/config_frontend": { API_BASE_URL: "/api" },
|
||||
"@ui/cad_host/cad_host": { createCadHost: () => fakeCad },
|
||||
"@ui/cad_host/cad_host_tabs": tabsMod,
|
||||
"@ui/ui_template_elements": elements,
|
||||
"@ui/ui_template_modal": { openModal: () => ({ close() {} }) },
|
||||
});
|
||||
const table = (link) => ({ type: "Table", shapeData: { cells: [[{ text: "옛" }]], style: { 연결: link } } });
|
||||
const doc = {
|
||||
도번: "SD-1",
|
||||
도면: { format: 6, entities: [{ type: "Line" }, { type: "Line" }], layers: [] },
|
||||
더한장: [{ 장id: "s2", 이름: "단면", 도면: { format: 6, entities: [table("T2")], layers: [] } }],
|
||||
};
|
||||
const dirt = [];
|
||||
const host = node("div");
|
||||
const sheets = drawingMod.mountStructureSheets(host, doc, {
|
||||
name: "rv", layer: "system", noArea: true, frame: true, onChanged: (d) => dirt.push(d),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const bar = host.kids[0].kids.find((k) => k.className === "cad-tabs");
|
||||
const plus = bar.kids.find((k) => k.className === "cad-tabs__add");
|
||||
await plus.on.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
inCad = { ...inCad, entities: [...inCad.entities, { type: "Circle" }] }; // 새 장에 하나 그림
|
||||
const third = bar.kids.find((k) => k.dataset.tabId && k.dataset.active === "true").dataset.tabId;
|
||||
await bar.kids.find((k) => k.dataset.tabId === "#1").on.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
out.updated = await sheets.updateTable("T2", [[{ text: "새" }]]);
|
||||
const got = await sheets.getDoc();
|
||||
out.m02 = {
|
||||
first: got.도면.entities.length,
|
||||
more: got.더한장.map((s) => [s.이름, s.도면.entities.length]),
|
||||
t2: got.더한장[0].도면.entities[0].shapeData.cells[0][0].text,
|
||||
newId: got.더한장[1].장id === third,
|
||||
scopes: cadLoads,
|
||||
active: sheets.activeSheet(),
|
||||
dirty: dirt.at(-1),
|
||||
number: drawingMod.sheetNumber("SD-1", 2),
|
||||
frames: urls,
|
||||
};
|
||||
console.log(JSON.stringify(out));
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got():
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
proc = subprocess.run(
|
||||
["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def test_탭_셋_왕복_엔티티_수_그대로_고친_장만_표시(got):
|
||||
# b 는 들를 때마다 하나씩 더 그림(+1 두 번) · a · c 는 그대로
|
||||
assert got["roundtrip"] == [["a", 3, False], ["b", 7, True], ["c", 0, False]]
|
||||
assert got["shownOrder"] == ["b", "c", "a", "b", "a"]
|
||||
|
||||
|
||||
def test_고정_탭_앞으로는_못_끎(got):
|
||||
assert got["order"] == ["a", "c", "b"]
|
||||
assert got["fixedDraggable"] is False and got["fixedX"] == 1
|
||||
|
||||
|
||||
def test_지우기는_물음_뒤_다음_탭을_실음(got):
|
||||
assert got["afterRemove"] == {"ids": ["a", "b"], "active": "b", "confirms": 1}
|
||||
|
||||
|
||||
def test_닫기_기능은_미저장_탭만_물음(got):
|
||||
b07 = got["b07"]
|
||||
assert b07["log"][:3] == [
|
||||
["show", "d1", None, False],
|
||||
["show", "d2", None, False],
|
||||
["show", "d1", "편집본", True], # 받아 둔 편집본으로 돌아옴
|
||||
]
|
||||
assert b07["asked"] == 1 and ["closed", "d2"] in b07["log"] and b07["ids"] == ["d1"]
|
||||
assert b07["last"] == 1
|
||||
|
||||
|
||||
def test_B07_목록에서_연_도면이_탭_미저장_편집_지킴():
|
||||
page = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Page.ts").read_text("utf-8")
|
||||
assert "features: { close: true }," in page # [+] · 지우기 없음(서버 길 없음)
|
||||
assert "dirty: cadDirty }" in page # 떠날 때 받아 둠
|
||||
assert "tab.dirty ? tab.drawing : undefined" in page # 돌아오면 받아 둔 편집본
|
||||
assert "cadDirty = !!stash;" in page and "cad.load(stash ?? response.drawing" in page
|
||||
assert "if (!cadDirty && !tabs.anyDirty()) return true;" in page
|
||||
assert "void openTab(drawings[target]);" in page # 이전 · 다음 = 그 도면 탭
|
||||
|
||||
|
||||
def test_여러_장_문서_장_도번_백업_칸(got):
|
||||
m02 = got["m02"]
|
||||
assert m02["first"] == 2
|
||||
assert m02["more"] == [["단면", 1], ["3장", 1]]
|
||||
assert m02["newId"] is True and m02["active"] == ""
|
||||
assert m02["scopes"][0] == "m02:system:rv" and m02["scopes"][1].startswith("m02:system:rv#s")
|
||||
assert m02["number"] == "SD-1-3"
|
||||
q = "/api/m02/structure-frame?number="
|
||||
assert m02["frames"] == [q + "SD-1", q + "SD-1-3"] # 장 도번마다 한 번(1장은 다시 안 받음)
|
||||
assert m02["dirty"] is True # 장 더함 = 저장 안 한 고침
|
||||
|
||||
|
||||
def test_안_실린_장_표도_갈음(got):
|
||||
assert got["updated"] == 2 # 받아 둔 둘째 장 1 + 실린 장(가짜 CAD) 1
|
||||
assert got["m02"]["t2"] == "새"
|
||||
|
||||
|
||||
def test_카드는_1장_그리고_장_수():
|
||||
card = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts").read_text("utf-8")
|
||||
assert "const sheets = 1 + (doc.더한장?.length ?? 0);" in card
|
||||
assert "badge.textContent = `장 ${sheets}`;" in card
|
||||
main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
|
||||
assert '"mountStructureSheets"' in main
|
||||
assert '...(edited as Pick<StructureDoc, "도면" | "더한장">),' in main
|
||||
@@ -0,0 +1,110 @@
|
||||
// B07 CAD 수확 — pattern 채움(노면공 무늬 셋 · 물넘이 빗금)을 다각형 안 선 · 원으로 펴기(88-4).
|
||||
// 실제 코드(`B07_DesignDetail_Cross_Overlay.patternMarks`)를 vite ssr 로 묶어 돌린다.
|
||||
// 실행: node resources/tester/test_cad_pattern_harvest.mjs
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, "../..");
|
||||
const out = mkdtempSync(join(tmpdir(), "cad-pattern-"));
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
"./config/node_modules/vite/bin/vite.js",
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
"../B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts",
|
||||
"--outDir",
|
||||
out,
|
||||
"--logLevel",
|
||||
"error",
|
||||
],
|
||||
{ cwd: ROOT, stdio: "pipe" },
|
||||
);
|
||||
const { patternMarks } = await import(
|
||||
pathToFileURL(join(out, "B07_DesignDetail_Cross_Overlay.js")).href
|
||||
);
|
||||
|
||||
// 도면 좌표(y 위로) 다각형 — SVG y 100~103(두께 3) · x 10~70. 닫힌 점열.
|
||||
const polygon = [
|
||||
[10, -100],
|
||||
[70, -100],
|
||||
[70, -103],
|
||||
[10, -103],
|
||||
[10, -100],
|
||||
];
|
||||
const inPoly = ([x, y]) => x >= 10 - 1e-6 && x <= 70 + 1e-6 && y <= -100 + 1e-6 && y >= -103 - 1e-6;
|
||||
const allPoints = (marks) => [
|
||||
...marks.polylines.flat(),
|
||||
...marks.circles.flatMap(({ center: [cx, cy], radius }) => [
|
||||
[cx - radius, cy],
|
||||
[cx + radius, cy],
|
||||
[cx, cy - radius],
|
||||
[cx, cy + radius],
|
||||
]),
|
||||
];
|
||||
|
||||
// ① 콘크리트 · 물넘이 빗금(M0,6 L6,0 · 칸 6) — 대각선이 칸을 넘어 이어져 한 줄씩 · 모두 다각형 안
|
||||
{
|
||||
const marks = patternMarks(polygon, {
|
||||
width: 6,
|
||||
height: 6,
|
||||
paths: [
|
||||
[
|
||||
[0, 6],
|
||||
[6, 0],
|
||||
],
|
||||
],
|
||||
rings: [],
|
||||
});
|
||||
assert.equal(marks.circles.length, 0);
|
||||
// x+y = 6k 인 대각선 중 띠(y 100~103)를 지나는 것: x+y ∈ (110, 173) → k = 19..28 = 10 줄
|
||||
assert.equal(marks.polylines.length, 10, `빗금 줄 수 ${marks.polylines.length}`);
|
||||
for (const run of marks.polylines) assert.equal(run.length, 2, "한 줄 = 두 점(이어 붙임)");
|
||||
assert.ok(allPoints(marks).every(inPoly), "빗금이 다각형 밖으로 나감");
|
||||
}
|
||||
|
||||
// ② 아스콘 점(원 r 0.7 · 칸 5) — 온전히 든 원은 원 그대로
|
||||
{
|
||||
const marks = patternMarks(polygon, {
|
||||
width: 5,
|
||||
height: 5,
|
||||
paths: [],
|
||||
rings: [{ cx: 1.5, cy: 1.5, rx: 0.7, ry: 0.7 }],
|
||||
});
|
||||
// 중심 y = 101.5 (칸 j=20) · x = 11.5, 16.5 … 66.5 = 12 개 — 모두 띠 안
|
||||
assert.equal(marks.circles.length, 12, `점 수 ${marks.circles.length}`);
|
||||
assert.equal(marks.polylines.length, 0);
|
||||
assert.ok(allPoints(marks).every(inPoly), "점이 다각형 밖으로 나감");
|
||||
}
|
||||
|
||||
// ③ 쇄석 돌(원 r 1.6 + 타원 · 칸 9) — 걸친 것은 잘린 폴리선 · 밖으로 안 나감
|
||||
{
|
||||
const marks = patternMarks(polygon, {
|
||||
width: 9,
|
||||
height: 9,
|
||||
paths: [],
|
||||
rings: [
|
||||
{ cx: 2.5, cy: 2.5, rx: 1.6, ry: 1.6 },
|
||||
{ cx: 6.8, cy: 6.5, rx: 1.8, ry: 1.2 },
|
||||
],
|
||||
});
|
||||
assert.ok(marks.polylines.length + marks.circles.length > 0, "돌 무늬 없음");
|
||||
assert.ok(allPoints(marks).every(inPoly), "돌이 다각형 밖으로 나감");
|
||||
}
|
||||
|
||||
// ④ 빈 칸 · 다각형 없음 = 무늬 없음
|
||||
{
|
||||
assert.deepEqual(patternMarks(polygon, { width: 0, height: 6, paths: [], rings: [] }), {
|
||||
polylines: [],
|
||||
circles: [],
|
||||
});
|
||||
assert.equal(patternMarks([], { width: 6, height: 6, paths: [], rings: [] }).polylines.length, 0);
|
||||
}
|
||||
|
||||
console.log("OK test_cad_pattern_harvest.mjs — 4건");
|
||||
@@ -104,3 +104,53 @@ def test_item_all_marked() -> None:
|
||||
total += 1
|
||||
missing += not any(f.get("item") for f in make.source(src)["found"])
|
||||
assert total >= 30 and missing == 0, (total, missing)
|
||||
|
||||
|
||||
def _m02_client(role: str = "USER") -> TestClient:
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(combos.router)
|
||||
app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": role}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_m02_원문_대리_길과_pdf() -> None:
|
||||
"""85-4 — 일반 사용자도 원문(M01 길과 같은 몸) · pdf 통째(책 이름만 · 그 밖 400 · 한글 파일명)."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos
|
||||
|
||||
client = _m02_client()
|
||||
got = client.get("/api/m02/logic-lib/source", params={"src": "산림품셈 3-1"})
|
||||
assert got.status_code == 200 and got.json() == make.source("산림품셈 3-1")
|
||||
for book in ("건설", "산림"):
|
||||
res = client.get("/api/m02/logic-lib/source-pdf", params={"book": book})
|
||||
assert res.status_code == 200 and res.headers["content-type"] == "application/pdf"
|
||||
assert (
|
||||
len(res.content) == combos.SOURCE_PDF[book].stat().st_size
|
||||
and res.content[:4] == b"%PDF"
|
||||
)
|
||||
assert "filename*=utf-8''" in res.headers["content-disposition"]
|
||||
again = client.get(
|
||||
"/api/m02/logic-lib/source-pdf",
|
||||
params={"book": book},
|
||||
headers={"if-none-match": res.headers["etag"]},
|
||||
)
|
||||
assert again.status_code == 304
|
||||
for bad in ("../../main.py", "건설품셈", ""):
|
||||
assert client.get("/api/m02/logic-lib/source-pdf", params={"book": bad}).status_code == 400
|
||||
|
||||
|
||||
def test_원문_패널_길_주입() -> None:
|
||||
"""85-4 — M01 은 옛 길 그대로(기본값) · M02 일위대가 상세는 M02 길 + pdf."""
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
src = (root / "M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts").read_text(encoding="utf-8")
|
||||
assert 'opts.path ?? "/m01/logic/source"' in src
|
||||
side = (root / "M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts").read_text(encoding="utf-8")
|
||||
assert "createSourceView()" in side
|
||||
labor = (root / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert 'path: "/m02/logic-lib/source"' in labor and 'pdf: "/m02/logic-lib/source-pdf"' in labor
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""도면 → 면적산출 한 번 옮기기(PLAN 83-3 · `M02_MasterTemplete_AreaFromDrawing.ts`) — Node 로 TS 를 돌려 판정.
|
||||
|
||||
못박는 것
|
||||
- 표 자료(CAD `table-from-drawing.ts` 꼴) → 새 시트 「도면에서 n」(안 쓴 가장 작은 n · 안 쓴 id) · 기존 시트 안 덮음.
|
||||
- 숫자 칸 = 수 · 빈 칸 안 적음 · 병합 = A1 범위 · 열 폭(글자) · 행 높이(pt) = 도면 mm 환산 · 폴백(text) = 폭 · 높이 없음.
|
||||
- 싣기 = 공개 손잡이 addSheet(시트, {activate}) 한 번 · 손잡이 없으면 안 옮기고 알림.
|
||||
- 패널 메시지 이름 = CAD `aislo-table-export.ts` 와 같음 · 읽기 면적은 보내기 단추 없음 · 시트 2장 이상이면 탭 줄.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
SCRIPT = r"""
|
||||
const fs = require("fs");
|
||||
const ts = require(process.argv[1] + "/config/node_modules/typescript");
|
||||
const src = fs.readFileSync(process.argv[1] + "/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts", "utf8");
|
||||
const js = ts.transpileModule(src, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const toasts = [];
|
||||
const stubs = { "@ui/ui_template_elements": { showToast: (m, k) => toasts.push([k, m]) } };
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n));
|
||||
const { tableToSheet, nextSheetName, importDrawingTable } = box.exports;
|
||||
|
||||
const book = { 시트: [{ id: "s1", 이름: "면적" }, { id: "s2", 이름: "도면에서 1" }] };
|
||||
const grid = {
|
||||
kind: "grid",
|
||||
rows: [["구분", "수량", ""], ["흙깎기", 1500, "m3"], ["", 2.5, ""]],
|
||||
merges: [{ row: 0, col: 1, rowSpan: 1, colSpan: 2 }, { row: 1, col: 0, rowSpan: 2, colSpan: 1 }],
|
||||
colWidths: [40, 30, 300],
|
||||
rowHeights: [10, 7, 1],
|
||||
};
|
||||
const text = { kind: "text", rows: [["합계", 3000], ["소계", ""]], merges: [], colWidths: [], rowHeights: [] };
|
||||
|
||||
const out = {};
|
||||
out.gridSheet = tableToSheet(grid, book);
|
||||
out.textSheet = tableToSheet(text, { 시트: [] });
|
||||
out.names = [nextSheetName({ 시트: [] }), nextSheetName(book), nextSheetName({ 시트: [{ 이름: "도면에서 2" }] })];
|
||||
|
||||
const added = [];
|
||||
const handle = { getDoc: () => book, addSheet: (s, o) => added.push([s, o]) };
|
||||
out.imported = importDrawingTable(handle, text);
|
||||
out.added = added;
|
||||
out.noHandle = importDrawingTable({ getDoc: () => book }, grid);
|
||||
out.noSheet = importDrawingTable(null, grid);
|
||||
out.toasts = toasts;
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def run():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node 없음")
|
||||
done = subprocess.run(
|
||||
[node, "-e", SCRIPT, str(ROOT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_grid_table_becomes_new_sheet(run):
|
||||
sheet = run["gridSheet"]
|
||||
assert sheet["이름"] == "도면에서 2" # 「도면에서 1」 이 이미 있음
|
||||
assert sheet["id"] == "s3"
|
||||
assert sheet["칸"] == {
|
||||
"A1": {"값": "구분"},
|
||||
"B1": {"값": "수량"},
|
||||
"A2": {"값": "흙깎기"},
|
||||
"B2": {"값": 1500},
|
||||
"C2": {"값": "m3"},
|
||||
"B3": {"값": 2.5},
|
||||
}
|
||||
assert sheet["병합"] == ["B1:C1", "A2:A3"]
|
||||
# 40 mm → 21.6 글자 · 300 mm → 60(위 끝) · 10 mm → 28.3 pt · 1 mm → 12(아래 끝)
|
||||
assert sheet["열"] == {"A": {"폭": 21.6}, "B": {"폭": 16.2}, "C": {"폭": 60}}
|
||||
assert sheet["행"] == {"1": {"높이": 28.3}, "2": {"높이": 19.8}, "3": {"높이": 12}}
|
||||
|
||||
|
||||
def test_text_fallback_has_no_sizes(run):
|
||||
sheet = run["textSheet"]
|
||||
assert sheet["이름"] == "도면에서 1" and sheet["id"] == "s1"
|
||||
assert sheet["칸"] == {"A1": {"값": "합계"}, "B1": {"값": 3000}, "A2": {"값": "소계"}}
|
||||
assert "병합" not in sheet and "열" not in sheet and "행" not in sheet
|
||||
|
||||
|
||||
def test_sheet_names(run):
|
||||
assert run["names"] == ["도면에서 1", "도면에서 2", "도면에서 1"]
|
||||
|
||||
|
||||
def test_import_uses_add_sheet_once(run):
|
||||
assert run["imported"] == "도면에서 2"
|
||||
assert len(run["added"]) == 1
|
||||
sheet, opts = run["added"][0]
|
||||
assert sheet["이름"] == "도면에서 2" and opts == {"activate": True}
|
||||
assert run["noHandle"] is None and run["noSheet"] is None
|
||||
kinds = [kind for kind, _ in run["toasts"]]
|
||||
assert kinds == [
|
||||
"success",
|
||||
"info",
|
||||
"error",
|
||||
"error",
|
||||
] # 옮김 · 글 폴백 확인 · 손잡이 없음 · 시트 없음
|
||||
assert "「도면에서 2」" in run["toasts"][0][1]
|
||||
|
||||
|
||||
def test_panel_wiring_matches_cad():
|
||||
cad = (ROOT / "B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
panel = (M02 / "M02_MasterTemplete_AreaDrawing.ts").read_text(encoding="utf-8")
|
||||
|
||||
def const(text, name):
|
||||
return re.search(rf"{name} = ['\"]([^'\"]+)['\"]", text).group(1)
|
||||
|
||||
assert const(cad, "AISLO_EXPORT_TABLE_REQUEST_MESSAGE") == const(panel, "EXPORT_TABLE_REQUEST")
|
||||
assert const(cad, "AISLO_EXPORT_TABLE_MESSAGE") == const(panel, "EXPORT_TABLE")
|
||||
assert "readOnly: true" in panel and "preview: true" not in panel # 보기 전용 + 고르기
|
||||
apanel = (M02 / "M02_MasterTemplete_AreaPanel.ts").read_text(encoding="utf-8")
|
||||
assert "onTable: opts.readOnly ? undefined" in apanel # 읽기 면적 = 단추 없음
|
||||
main = (M02 / "M02_MasterTemplete_UI_Main.ts").read_text(encoding="utf-8")
|
||||
assert "tabs: ((doc as { 시트?: unknown[] }).시트?.length ?? 0) > 1" in main
|
||||
@@ -112,6 +112,16 @@ globalThis.fetch = async (url, init) => {
|
||||
await slot.kids[0].on.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
ad.onDrawing(handle, { ...area, kind: "structure", name: "item2" }, drawing([]));
|
||||
const other = placed.length - before;
|
||||
// 여러 장(82-3) — 둘째 장 T2 가 있으니 새 표는 T3 · 저장 연결표 줄 장 = 표가 놓인 장id
|
||||
ad.select({ 시트: "산출", 칸: "A1", 범위: "A1" });
|
||||
await slot.kids[0].on.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const multi = drawing([table([["a"]], { 연결: "T1" })], [old]);
|
||||
multi.더한장 = [{ 장id: "s2", 이름: "2장", 도면: { entities: [table([["b"]], { 연결: "T2" })] } }];
|
||||
ad.onDrawing(handle, { ...area, kind: "structure" }, multi);
|
||||
const multiId = placed.at(-1)[1];
|
||||
const multiSaved = ad.withLinks({ ...multi, 연결표: [old, { ...old, 연결: "T2" }] }).연결표.map((r) => [r.연결, r.장]);
|
||||
// 빈 칸만 고른 범위 — 크기를 묻기 전에 막고 알림 · 도면 안 엶
|
||||
now = [["", " "]];
|
||||
const openedBefore = opened.length;
|
||||
@@ -119,7 +129,7 @@ globalThis.fetch = async (url, init) => {
|
||||
await slot.kids[0].on.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const empty = { opened: opened.length - openedBefore, toast: toasts.at(-1) };
|
||||
console.log(JSON.stringify({ ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, plain: "연결표" in plain, other: placed.length - before, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty }));
|
||||
console.log(JSON.stringify({ ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, multiId, multiSaved, plain: "연결표" in plain, other, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty }));
|
||||
})();
|
||||
"""
|
||||
|
||||
@@ -161,12 +171,17 @@ def test_다시_열면_다름_알림과_업데이트(got):
|
||||
def test_저장_연결표는_남은_표만(got):
|
||||
now = {"s1!B2": "1.5", "s1!C2": "2"}
|
||||
assert got["saved"] == [
|
||||
{"연결": "T1", "시트": "s1", "범위": "A1:A1", "글자크기": 3, "값": now}, # [업데이트] 값
|
||||
{"연결": "T2", "시트": "s1", "범위": "B2:C2", "글자크기": 2.5, "값": now},
|
||||
{"연결": "T1", "장": "", "시트": "s1", "범위": "A1:A1", "글자크기": 3, "값": now},
|
||||
{"연결": "T2", "장": "", "시트": "s1", "범위": "B2:C2", "글자크기": 2.5, "값": now},
|
||||
]
|
||||
assert got["plain"] is False
|
||||
|
||||
|
||||
def test_여러_장_새_번호는_모든_장에서_안_쓴_것_연결표_장(got):
|
||||
assert got["multiId"] == "T3"
|
||||
assert got["multiSaved"] == [["T1", ""], ["T2", "s2"]]
|
||||
|
||||
|
||||
def test_다른_열_도면에는_안_놓음(got):
|
||||
assert got["other"] == 0
|
||||
|
||||
|
||||
@@ -66,11 +66,14 @@ def test_알약은_전체_편집기와_같은_부품():
|
||||
def test_저장_안_한_카드_고침_물음():
|
||||
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
|
||||
show = struct.split("const show = ")[1].split("const pickPrice")[0]
|
||||
assert "cards.dirty()" in show and "showConfirmDialog(TEXT.discardCards)" in show
|
||||
assert "cards.dirty()" in show and "leaveOrStay(TEXT.discardCards," in show
|
||||
assert "opts.keepColumn?.(from)" in show
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert "const busy = (): boolean => dirty || !!panel?.dirty() || toDrawing.waiting();" in main
|
||||
assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle)
|
||||
assert main.count("busy()") >= 2 # 떠나기 · 새로고침 막기
|
||||
assert (
|
||||
'askUnsaved(L("M02_TreeUnsaved"))' in main
|
||||
) # 나무 고침(settle)도 같은 물음 · 카드 저장 실패까지 봄
|
||||
assert main.index("await panel.saveCards();") < main.index("await panel.reload();")
|
||||
assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ def test_일위대가_카드는_상세_카드_갈래():
|
||||
assert "onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable())," in cards
|
||||
assert "Labor_View" not in cards # 한 줄 표(40-2) 안 씀
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert "...(opt.card ? {} : { move })" in detail # 순서 단추 없음 · 빼기는 그대로
|
||||
assert "move: reorder," in detail # 85-3 — 카드도 순서(▲▼ · 끌기) · 빼기는 그대로
|
||||
# 서고 모드(50-3 · lib)도 더하기 · 다이어그램 없음
|
||||
# 51-11 사전조합 = 일위대가 상세와 같은 [로직 추가] · 같은 목록(걸러냄 없음)
|
||||
assert "opt.card || promote ? null : addLogic" in detail
|
||||
@@ -134,7 +134,10 @@ def test_일위대가_줄_고름과_옵션_단추():
|
||||
) # 읽기만도 고름 · [옵션] = 보기 모달
|
||||
assert "selected: picked" in detail
|
||||
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
|
||||
up, down = rows.index('small(tc("Up")'), rows.index('small(tc("Down")')
|
||||
# 85-3 — ▲▼ 작은 단추(글은 title · aria) + 손잡이 끌기
|
||||
up, down = rows.index('arrow("▲", tc("Up")'), rows.index('arrow("▼", tc("Down")')
|
||||
assert rows.index("grip(),") < up and 'b.setAttribute("aria-label", label)' in rows
|
||||
assert "move: reorder," in detail and "opt.card ? {} : { move }" not in detail # 카드도 순서
|
||||
opt, rm = (
|
||||
rows.index('small(tc("Labor_Option")'),
|
||||
rows.index('small(tc("Remove"), () => remove(i), "danger")'),
|
||||
|
||||
@@ -90,3 +90,49 @@ def test_머리_고침은_쓸_수_있는_층만_나무로():
|
||||
assert page.index("...(canEdit(layer, isAdmin)\n ? {\n onHeadRename") > 0
|
||||
tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert "if (opt.canEdit && (await ready())) await edit(body, colId);" in tree
|
||||
|
||||
|
||||
def test_일위대가_합계_줄_번호_칸(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""86-3 — `structure_numbers` 의 일위대가 합계: 반영합 `계` · 없으면 기준합 `계` + 합계기준 ·
|
||||
담은 로직 0 · 계산값 없음 = 칸 없음(목록 `labor` 합계와 같은 규칙)."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
(tmp_path / "labor").mkdir()
|
||||
docs = {
|
||||
"a": {"일위대가번": "SU-000001", "담은로직": ["x"], "계산값": {"반영합": {"계": 1500.4}}},
|
||||
"b": {
|
||||
"일위대가번": "SU-000002",
|
||||
"담은로직": ["x"],
|
||||
"계산값": {"반영합": {"계": 0}, "기준합": {"계": 900}},
|
||||
},
|
||||
"c": {"일위대가번": "SU-000003", "담은로직": [], "계산값": {"반영합": {"계": 5}}},
|
||||
"d": {"일위대가번": "SU-000004", "담은로직": ["x"], "계산값": {}},
|
||||
}
|
||||
for name, doc in docs.items():
|
||||
(tmp_path / "labor" / f"{name}.json").write_text(json.dumps(doc), encoding="utf-8")
|
||||
got = store.structure_numbers()
|
||||
assert (got["a"]["일위대가합계"], got["a"]["합계기준"]) == (1500.4, False)
|
||||
assert (got["b"]["일위대가합계"], got["b"]["합계기준"]) == (900.0, True)
|
||||
assert "일위대가합계" not in got["c"] and "일위대가합계" not in got["d"]
|
||||
assert got["d"]["일위대가번"] == "SU-000004" and got["d"]["도번"] == ""
|
||||
|
||||
|
||||
def test_일위대가_합계_줄_표_부품과_연결():
|
||||
sheet = _text(SHEET / "ui_template_sheet.ts")
|
||||
assert "laborSumLabel: opts.laborSumLabel," in sheet
|
||||
ops = _text(SHEET / "ui_template_sheet_ops.ts")
|
||||
assert 'export const KEY_SUM = "s:laborsum";' in ops
|
||||
assert "[KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_SUM]" in ops # 읽기만 — 고치기 · 쓰기 막힘
|
||||
assert "KEY_SUM" not in re.search(r"MASTER_ROWS = \[[^\]]*\]", ops).group(
|
||||
0
|
||||
) # 방향키 · 고르기 밖
|
||||
render = _text(SHEET / "ui_template_sheet_render.ts")
|
||||
assert "state.combo && state.laborSumLabel" in render and "masterRow(KEY_SUM)" in render
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert "laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId])" in main
|
||||
assert "onNumbers: () => sheet?.recalc()" in main # 카드 [저장] 뒤 다시 읽기
|
||||
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
|
||||
assert "fetchStructureNumbers().then(" in struct and "opts.onNumbers?.()" in struct
|
||||
cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts")
|
||||
assert "opts.onSaved?.();" in cards
|
||||
|
||||
@@ -46,7 +46,7 @@ def test_메뉴는_숨기고_저장은_도면_칸만():
|
||||
def test_저장_안_한_카드_고침은_물음():
|
||||
cards = _text("M02_MasterTemplete_ItemCards.ts")
|
||||
# 물음 한 번(31-7) — 어느 카드든(도면 · 엑셀) 고침이 있으면 · 틀의 저장 표시(saver)로 봄
|
||||
assert "if (!again && anyDirty() && col?.id !== column && !(await showConfirmDialog(" in cards
|
||||
assert "(await leaveOrStay(TEXT.discard, { save: saveAll, stillDirty: anyDirty })) === \"stay\"" in cards
|
||||
assert "const anyDirty = (): boolean => cards.some((c) => c.saver.dirty());" in cards
|
||||
assert "dirty: anyDirty," in cards
|
||||
assert "dirty: () => cards.dirty()," in _text("M02_MasterTemplete_Structure.ts")
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""M02 구조물 도면 여러 장(PLAN 82-2 · 82-3) — 저장 꼴 `더한장` · 뼈대 검사 · 모든 장 표 맞춤 ·
|
||||
복사 · 스냅샷 · 개인 저장이 더한장을 같이 나름 · 장 도번 도각 `{{도면번호}}`.
|
||||
|
||||
시스템 층 = 견본 표(임시 폴더 · `test_m02_area_table.world`) · storage 는 임시.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Item_Personal as personal
|
||||
from M02_MasterTemplete import M02_Item_Source as source
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import structure_frame
|
||||
from resources.tester.test_m02_area_table import world # noqa: F401 — 같은 견본 층
|
||||
|
||||
_ = world
|
||||
|
||||
|
||||
def _table(link: str) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "Table",
|
||||
"shapeData": {
|
||||
"origin": {"x": 0, "y": 0},
|
||||
"columnWidths": [9, 9],
|
||||
"rowHeights": [3, 3],
|
||||
"cells": [[{"text": "낡음"}, {"text": "낡음"}], [None, {"text": "낡음"}]],
|
||||
"style": {"fontSize": 2.5, "연결": link},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _sheet(sheet_id: str, name: str, entities: list) -> dict[str, Any]:
|
||||
return {
|
||||
"장id": sheet_id,
|
||||
"이름": name,
|
||||
"도면": {"format": 6, "entities": entities, "layers": [{"id": "기본", "name": "기본"}]},
|
||||
}
|
||||
|
||||
|
||||
def _write_sheets(folder: Path) -> dict[str, Any]:
|
||||
"""rv 도면 = 1장(T1 표) + 더한장 둘(s2 = T2 표 · s3 = 빈 장) · 연결표 줄에 `장`."""
|
||||
with store.at(folder):
|
||||
got = store.read("structure", "rv")
|
||||
doc = got["문서"]
|
||||
doc["도면"]["entities"] = [_table("T1")]
|
||||
doc[layers.MORE_SHEETS] = [_sheet("s2", "단면", [_table("T2")]), _sheet("s3", "상세", [])]
|
||||
doc["연결표"] = [
|
||||
{"연결": "T1", "장": "", "시트": "s1", "범위": "A1:B2", "글자크기": 2.5, "값": {}},
|
||||
{"연결": "T2", "장": "s2", "시트": "s1", "범위": "A1:B2", "글자크기": 2.5, "값": {}},
|
||||
]
|
||||
store.write("structure", "rv", got["판"], doc)
|
||||
return store.read("structure", "rv")["문서"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"more",
|
||||
[
|
||||
"장",
|
||||
[{"이름": "가", "도면": {"entities": []}}],
|
||||
[_sheet("s2", "가", []), _sheet("s2", "나", [])],
|
||||
[{"장id": "s2", "이름": "가", "도면": {}}],
|
||||
[{"장id": "s2", "도면": {"entities": []}}],
|
||||
],
|
||||
)
|
||||
def test_더한장_모양이_틀리면_거절(more):
|
||||
doc = {"도면": {"entities": []}, layers.MORE_SHEETS: more}
|
||||
with pytest.raises(ValueError):
|
||||
layers.check_skeleton("structure", doc)
|
||||
|
||||
|
||||
def test_더한장_없거나_맞으면_통과_장_목록():
|
||||
layers.check_skeleton("structure", {"도면": {"entities": []}}) # 옛 문서 그대로
|
||||
doc = {"도면": {"entities": [1]}, layers.MORE_SHEETS: [_sheet("s2", "가", [2])]}
|
||||
layers.check_skeleton("structure", doc)
|
||||
assert layers.drawing_sheets(doc) == [("", doc["도면"]), ("s2", doc["더한장"][0]["도면"])]
|
||||
assert layers.drawing_sheets({"도면": {"entities": []}, "더한장": ["틀림"]}) == [
|
||||
("", {"entities": []})
|
||||
]
|
||||
|
||||
|
||||
def test_저장_다시_읽기_둘째_장_그대로(world): # noqa: F811
|
||||
doc = _write_sheets(world["system"])
|
||||
assert [s["장id"] for s in doc["더한장"]] == ["s2", "s3"]
|
||||
assert doc["더한장"][0]["도면"]["entities"][0]["shapeData"]["style"]["연결"] == "T2"
|
||||
with store.at(world["system"]), pytest.raises(store.StoreError):
|
||||
got = store.read("structure", "rv")
|
||||
store.write("structure", "rv", got["판"], {**got["문서"], "더한장": [{"장id": ""}]})
|
||||
|
||||
|
||||
def test_면적_저장_뒤_안_열린_둘째_장_표도_갱신(world): # noqa: F811
|
||||
system = world["system"]
|
||||
_write_sheets(system)
|
||||
with store.at(system):
|
||||
got = store.read("area", "rv")
|
||||
got["문서"]["시트"][0]["칸"]["B1"] = {"값": 10, "서식": 1}
|
||||
store.write("area", "rv", got["판"], got["문서"])
|
||||
doc = store.read("structure", "rv")["문서"]
|
||||
first = doc["도면"]["entities"][0]["shapeData"]["cells"]
|
||||
second = doc["더한장"][0]["도면"]["entities"][0]["shapeData"]["cells"]
|
||||
assert first[0][1]["text"] == "10.00" and second[1][1]["text"] == "20.00"
|
||||
assert [row["장"] for row in doc["연결표"]] == ["", "s2"] # 장 칸 그대로
|
||||
assert doc["연결표"][1]["값"]["s1!B2"] == "20.00"
|
||||
|
||||
|
||||
def test_다른_열로_복사_더한장_같이(world): # noqa: F811
|
||||
_write_sheets(world["system"])
|
||||
with store.at(world["system"]):
|
||||
if not store._path("structure", "c1").is_file():
|
||||
store.create_structure("c1")
|
||||
store.copy_from("structure", "c1", "rv")
|
||||
doc = store.read("structure", "c1")["문서"]
|
||||
rv = store.read("structure", "rv")["문서"]
|
||||
assert [s["장id"] for s in doc["더한장"]] == ["s2", "s3"]
|
||||
assert doc["도번"] != rv["도번"] # 번호는 받는 열 그대로
|
||||
|
||||
|
||||
def _rv_key(system: Path) -> str:
|
||||
with store.at(system):
|
||||
return next(r["키"] for r in store.read_combos()["줄"] if r.get("열") == "rv")
|
||||
|
||||
|
||||
def test_스냅샷_원본이_더한장_같이_나름(world): # noqa: F811
|
||||
_write_sheets(world["system"])
|
||||
files = source.build_source({"layer": "system", "key": _rv_key(world["system"])})
|
||||
assert len(files["도면.json"]["더한장"]) == 2
|
||||
|
||||
|
||||
def test_개인에_저장_더한장_같이(world, tmp_path): # noqa: F811
|
||||
_write_sheets(world["system"])
|
||||
files = source.build_source({"layer": "system", "key": _rv_key(world["system"])})
|
||||
copy = tmp_path / "copy"
|
||||
copy.mkdir()
|
||||
for name in ("집계.json", "도면.json"):
|
||||
(copy / name).write_text(json.dumps(files[name], ensure_ascii=False), encoding="utf-8")
|
||||
folder = layers.personal_dir(7, 99)
|
||||
made = personal.create_item(folder, ["가"], "나", "m", copy=copy)
|
||||
with store.at(folder):
|
||||
doc = store.read("structure", made["열"])["문서"]
|
||||
assert [s["이름"] for s in doc["더한장"]] == ["단면", "상세"]
|
||||
|
||||
|
||||
def test_장_도번이_도각_도면번호에():
|
||||
texts = [
|
||||
(e.get("shapeData") or {}).get("label")
|
||||
for e in structure_frame("SD-000010-2")["entities"]
|
||||
if e.get("type") == "Text"
|
||||
]
|
||||
assert "SD-000010-2" in texts
|
||||
blank = [
|
||||
(e.get("shapeData") or {}).get("label")
|
||||
for e in structure_frame()["entities"]
|
||||
if e.get("type") == "Text"
|
||||
]
|
||||
assert "SD-000010-2" not in blank and not any("{{" in str(t) for t in blank)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""켜쌓기율(85-2) — 표 주문 「골쌓기 기준」 로직만 · 세부항목 줄 전부 수량 × 율 · 문서 · 사본 같은 함수.
|
||||
|
||||
못박는 것
|
||||
- 대상 = 표 주문 자동 판정(지금 GC000260 · GC000261) · 입력 목록에 「켜쌓기」 표시.
|
||||
- GC000260 90% = 100% × 0.9(비목 · 계 · 줄 수량 · 금액) · 단가는 그대로.
|
||||
- clean = 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖은 안 둠.
|
||||
- 프로젝트 사본 계산(`M02_Item_Prices.calc`)도 같은 율 · 같은 값.
|
||||
- 서고(단가승격)도 율을 담고 같은 값.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
CODE = "GC000260"
|
||||
|
||||
|
||||
def _d(value) -> Decimal:
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def test_대상은_골쌓기_기준_주문_로직() -> None:
|
||||
assert labor_inputs.layered() == {"GC000260", "GC000261"}
|
||||
got = labor_inputs.logic_inputs([CODE, "GF000219"])["로직"]
|
||||
assert [x["켜쌓기"] for x in got] == [True, False]
|
||||
|
||||
|
||||
def test_90퍼센트는_100퍼센트_곱하기_0점9() -> None:
|
||||
whole = m01.loaded()[1]
|
||||
full = labor.run_line(whole, CODE, {})
|
||||
part = labor.run_line(whole, CODE, {}, 90)
|
||||
for k in (*labor.COSTS, "계"):
|
||||
assert part["기준"][k] == pytest.approx(full["기준"][k] * Decimal("0.9"))
|
||||
for a, b in zip(full["줄"], part["줄"]):
|
||||
assert b["단가"] == a["단가"]
|
||||
assert b["수량"] == pytest.approx(a["수량"] * Decimal("0.9"))
|
||||
assert b["금액"] == pytest.approx(a["금액"] * Decimal("0.9"))
|
||||
for k, v in a["비목"].items():
|
||||
assert b["비목"][k] == pytest.approx(v * Decimal("0.9"))
|
||||
assert labor.run_line(whole, CODE, {}, 100) == full
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -5, 200.1, 90.55, "abc"])
|
||||
def test_율_범위_밖은_400(bad) -> None:
|
||||
with pytest.raises(store.StoreError):
|
||||
labor.clean({"담은로직": [{"로직": CODE, "켜쌓기율": bad}]})
|
||||
|
||||
|
||||
def test_율_정리() -> None:
|
||||
def rate(code: str, value):
|
||||
return labor.clean({"담은로직": [{"로직": code, "켜쌓기율": value}]})["담은로직"][0].get(
|
||||
"켜쌓기율"
|
||||
)
|
||||
|
||||
assert rate(CODE, "90") == 90
|
||||
assert rate(CODE, 87.5) == 87.5
|
||||
assert rate(CODE, 200) == 200
|
||||
assert rate(CODE, 100) is None # 100 = 없음
|
||||
assert rate(CODE, None) is None
|
||||
assert rate("GF000219", 90) is None # 대상 밖은 안 둠
|
||||
|
||||
|
||||
def test_문서_계산과_프로젝트_사본_같음() -> None:
|
||||
doc = labor.clean({"단위": "㎡", "담은로직": [{"로직": CODE, "켜쌓기율": 90}]})
|
||||
whole = m01.loaded()[1]
|
||||
auto = m01.mau.values(whole, whole.logic(CODE))
|
||||
values = {CODE: {k: {"값": labor._json(v), "확인": True} for k, v in auto.items()}}
|
||||
copy = {"줄": doc, "알약": labor.pills(doc)}
|
||||
got = prices.calc(copy, prices.to_json(prices.collect([CODE])), values, {})
|
||||
origin = labor.calc(doc, {})
|
||||
(line,) = got["줄"]
|
||||
assert line["계"] == pytest.approx(origin["줄"][0]["기준"]["계"])
|
||||
full = labor.calc({**doc, "담은로직": [{**doc["담은로직"][0], "켜쌓기율": None}]}, {})
|
||||
assert _d(line["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9"))
|
||||
|
||||
|
||||
def test_서고_단가승격도_율() -> None:
|
||||
doc = refs.clean_doc(
|
||||
"promote", {"키": "SP-900001", "담은로직": [{"로직": CODE, "켜쌓기율": 90}]}
|
||||
)
|
||||
assert doc["담은로직"][0]["켜쌓기율"] == 90
|
||||
whole = m01.loaded()[1]
|
||||
expand = refs.Expand(whole, lambda ref: doc)
|
||||
got = expand({"층": "system", "주인": None, "키": "SP-900001"})
|
||||
full = labor.run_line(whole, CODE, {})
|
||||
times = _d(doc["배"])
|
||||
assert got["기준"]["계"] == pytest.approx(full["기준"]["계"] * Decimal("0.9") / times)
|
||||
|
||||
|
||||
RATE_SCRIPT = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const root = process.argv[1];
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) });
|
||||
const f = path.join(root, "M02_MasterTemplete", "M02_MasterTemplete_Labor_Input.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
|
||||
const m = box.exports;
|
||||
console.log(JSON.stringify(["", "90", "87.5", "100", "200", "0", "200.1", "90.55", "x"].map(
|
||||
(v) => { const r = m.layerRate(v); return Number.isNaN(r) ? "bad" : r; })));
|
||||
"""
|
||||
|
||||
|
||||
def test_화면_율_칸_검사() -> None:
|
||||
"""옵션 모달 켜쌓기율 칸 — 서버 `_rate` 와 같은 범위(빈 칸 · 100 = 없음)."""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node 없음")
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
out = subprocess.run(
|
||||
[node, "-e", RATE_SCRIPT, str(root)], capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert json.loads(out.stdout) == [None, 90, 87.5, None, 200, "bad", "bad", "bad", "bad"]
|
||||
@@ -70,6 +70,9 @@ const kind = { 이름: "종류", 고르기: ["막돌", "견치돌"] };
|
||||
const size = { 이름: "규격", 고르기: [25, 40] };
|
||||
const area = { 이름: "지역" };
|
||||
const inputs = [height, kind, size, area];
|
||||
const machine = {
|
||||
이름: "기계", 고르기: ["0201-0060", "9999"], 고르기이름: { "0201-0060": "굴착기(무한궤도) 0.6㎥" },
|
||||
};
|
||||
console.log(JSON.stringify({
|
||||
stored: [
|
||||
m.storedOf(height, ""), m.storedOf(height, "2.5"), m.storedOf(kind, "막돌"),
|
||||
@@ -82,6 +85,11 @@ console.log(JSON.stringify({
|
||||
known: m.keepKnown(inputs, { 높이: 2, 종류: "", 없앤것: 3 }),
|
||||
line: m.valueLine(inputs, { 높이: 2, 규격: 40 }),
|
||||
lineEmpty: m.valueLine(inputs, {}),
|
||||
named: [
|
||||
m.choicesOf(machine).map((c) => [c.값, c.이름]), m.valueHint(machine),
|
||||
m.valueLine([machine], { 기계: "0201-0060" }).text, m.labelOf(machine, "9999"),
|
||||
m.storedOf(machine, "0201-0060"),
|
||||
],
|
||||
}));
|
||||
"""
|
||||
|
||||
@@ -100,6 +108,27 @@ def test_값_셈() -> None:
|
||||
assert got["known"] == {"kept": {"높이": 2}, "lost": ["없앤것"]}
|
||||
assert got["line"] == {"text": "높이 2m · 규격 40", "empty": 2} # 종류 · 지역 = 자동값 없음
|
||||
assert got["lineEmpty"] == {"text": "", "empty": 3} # 높이는 자동값이 있어 빈 값 아님
|
||||
# 85-1 — 보이는 글 = 이름 규격 · 모르는 번호는 그대로 · 저장 값은 번호
|
||||
assert got["named"] == [
|
||||
[["0201-0060", "굴착기(무한궤도) 0.6㎥"], ["9999", "9999"]],
|
||||
"굴착기(무한궤도) 0.6㎥ / 9999",
|
||||
"기계 굴착기(무한궤도) 0.6㎥",
|
||||
"9999",
|
||||
"0201-0060",
|
||||
]
|
||||
|
||||
|
||||
def test_고르기_원문번호에_이름표(client: TestClient) -> None:
|
||||
"""85-1 — 기계 · 집게 = 「이름 규격」 · 직종(입력 이름에 「직종」) = 직종 이름 · 고르기는 번호 그대로."""
|
||||
got = client.get("/api/m02/logic-lib/inputs", params={"keys": "GC000260"}).json()
|
||||
specs = {i["이름"]: i for i in got["로직"][0]["입력"]}
|
||||
assert specs["기계"]["고르기이름"]["0201-0060"] == "굴착기(무한궤도) 0.6㎥"
|
||||
assert specs["집게"]["고르기이름"]["7206-0020"] == "부착용 집게 0.2㎥"
|
||||
assert "0201-0060" in specs["기계"]["고르기"]
|
||||
assert "고르기이름" not in specs["뒷길이"] and "고르기이름" not in specs["지역"]
|
||||
whole = labor_inputs.m01.loaded()[1]
|
||||
job = {"이름": "조종원직종", "고르기": ["1048", "없는번호"]}
|
||||
assert labor_inputs._labels(whole, job) == {"1048": "건설기계운전사"}
|
||||
|
||||
|
||||
ROWS_SCRIPT = r"""
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""저장 안 한 고침 3단추 물음(PLAN 86-1 · `ui_template_leave_dialog.ts`) — Node 로 TS 를 돌려 판정.
|
||||
|
||||
못박는 것
|
||||
- 단추 순서 [취소] [버리고 넘어감] [저장 후 넘어감] · 기본 초점 = 취소 · Esc = 취소.
|
||||
- 저장 후 넘어감: 저장 성공 + 고침 없음 = saved / 저장 던짐 · 고침 남음 = stay(머묾).
|
||||
- 버리고 넘어감: discard 갈고리 호출 · 취소: 아무것도 안 부름.
|
||||
- M02 자리(UI_Main · Structure · ItemCards)가 이 물음을 씀.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
SCRIPT = r"""
|
||||
const fs = require("fs");
|
||||
const ts = require(process.argv[1] + "/config/node_modules/typescript");
|
||||
const src = fs.readFileSync(process.argv[1] + "/ui_template/ui_template_leave_dialog.ts", "utf8");
|
||||
const js = ts.transpileModule(src, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
let focused = null;
|
||||
let overlay = null;
|
||||
let buttons = [];
|
||||
const node = (tag, o = {}) => ({
|
||||
tag, ...o, kids: [], on: {},
|
||||
append(...x) { this.kids.push(...x); },
|
||||
addEventListener(k, f) { this.on[k] = f; },
|
||||
remove() { overlay = null; },
|
||||
});
|
||||
const stubs = {
|
||||
"./ui_template_elements": {
|
||||
el: node,
|
||||
createButton: (o) => {
|
||||
const b = node("button", { label: o.label, variant: o.variant });
|
||||
b.focus = () => { focused = b.label; };
|
||||
b.click = o.onClick;
|
||||
buttons.push(b);
|
||||
return b;
|
||||
},
|
||||
},
|
||||
"./ui_template_locale": { t: (k) => k },
|
||||
};
|
||||
globalThis.document = { activeElement: null, body: { append(x) { overlay = x; } } };
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n));
|
||||
const { leaveOrStay } = box.exports;
|
||||
|
||||
const run = async (pick, hooks) => {
|
||||
buttons = [];
|
||||
focused = null;
|
||||
const calls = [];
|
||||
const wrap = {
|
||||
save: async () => { calls.push("save"); if (hooks.throws) throw new Error("x"); },
|
||||
stillDirty: () => !!hooks.left,
|
||||
discard: () => calls.push("discard"),
|
||||
};
|
||||
const p = leaveOrStay("msg", wrap);
|
||||
const labels = buttons.map((b) => b.label);
|
||||
const first = focused;
|
||||
if (pick === "esc") overlay.on.keydown({ key: "Escape", stopPropagation() {} });
|
||||
else buttons[pick].click();
|
||||
return { got: await p, calls, labels, first };
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const out = {
|
||||
cancel: await run(0, {}),
|
||||
esc: await run("esc", {}),
|
||||
discard: await run(1, {}),
|
||||
save: await run(2, {}),
|
||||
saveThrows: await run(2, { throws: true }),
|
||||
saveLeft: await run(2, { left: true }),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got() -> dict:
|
||||
done = subprocess.run(
|
||||
["node", "-e", SCRIPT, str(ROOT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_단추_순서_기본_초점_취소(got):
|
||||
row = got["cancel"]
|
||||
assert row["labels"] == ["M02_TreeCancel", "M02_TreeDiscardGo", "M02_TreeSaveGo"]
|
||||
assert row["first"] == "M02_TreeCancel"
|
||||
|
||||
|
||||
def test_취소_Esc는_머묾(got):
|
||||
assert got["cancel"]["got"] == "stay" and got["cancel"]["calls"] == []
|
||||
assert got["esc"]["got"] == "stay" and got["esc"]["calls"] == []
|
||||
|
||||
|
||||
def test_버리고_넘어감(got):
|
||||
assert got["discard"]["got"] == "discarded"
|
||||
assert got["discard"]["calls"] == ["discard"]
|
||||
|
||||
|
||||
def test_저장_후_넘어감과_실패_머묾(got):
|
||||
assert got["save"] == {**got["save"], "got": "saved", "calls": ["save"]}
|
||||
assert got["saveThrows"]["got"] == "stay" # 저장 던짐
|
||||
assert got["saveLeft"]["got"] == "stay" # 저장 뒤에도 고침이 남음(카드 저장 실패 등)
|
||||
|
||||
|
||||
def test_M02_자리가_3단추_물음을_씀():
|
||||
main = (M02 / "M02_MasterTemplete_UI_Main.ts").read_text(encoding="utf-8")
|
||||
assert "leaveOrStay(message, { save: doSave, stillDirty: busy" in main
|
||||
assert "askSettle" not in main
|
||||
assert "!(await showConfirmDialog(L(" not in main
|
||||
cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8")
|
||||
assert "leaveOrStay(TEXT.discard" in cards
|
||||
struct = (M02 / "M02_MasterTemplete_Structure.ts").read_text(encoding="utf-8")
|
||||
assert struct.count("leaveOrStay(TEXT.discardCards") == 2
|
||||
@@ -74,7 +74,7 @@ def test_sub2_가_부르는_이름과_모양():
|
||||
), name
|
||||
assert "fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>>" in body
|
||||
assert re.search(
|
||||
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;\s*\}",
|
||||
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;\s*\}",
|
||||
body,
|
||||
)
|
||||
assert re.search(r'export type Kind = [^;]*"area" \| "labor"', body) # 재료산출 없앰
|
||||
@@ -90,10 +90,11 @@ def test_집계표만_컨테이너를_붙임():
|
||||
r'at\.layer !== "project" && at\.name === STRUCTURE_TABLE\s*\? buildPanel\(at\.layer\)\s*: null',
|
||||
body,
|
||||
)
|
||||
# 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로
|
||||
# 도면 칸만 갈아 끼움 · 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`)
|
||||
# 구조물 도면 저장은 `도면` · `더한장`(82-2) 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로
|
||||
# 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`)
|
||||
assert re.search(
|
||||
r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*도면: edited as StructureDoc\["도면"\],\s*\}\)',
|
||||
r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*'
|
||||
r'\.\.\.\(edited as Pick<StructureDoc, "도면" \| "더한장">\),\s*\}\)',
|
||||
body,
|
||||
)
|
||||
|
||||
|
||||
@@ -216,3 +216,11 @@ def test_측점이_없어진_손_값은_맨_아래_비고_줄로(project: Path)
|
||||
assert last["값"] == {"sta": "NO.27+15", "h_intake": 1, "memo": "설계에서 측점 없어짐"}
|
||||
kept = fill.strip_design(doc)
|
||||
assert kept["줄"][-1]["값"]["h_intake"] == 1 # 다음 저장에도 남음
|
||||
|
||||
|
||||
def test_집계_길이는_구간형이면_시작_종료가_먼저(project: Path) -> None:
|
||||
# 노면공 87-2 — 범위를 고쳐도 옛 length_m 이 안 앞섬
|
||||
moved = {"start_m": 200, "end_m": 245, "options": {"length_m": 30}}
|
||||
assert fill._length(moved) == 45
|
||||
assert fill._length({"options": {"length_m": 12}}) == 12 # 점형 · 구간 없음 = 입력 길이
|
||||
assert fill._length({"options": {}}) is None
|
||||
|
||||
@@ -198,6 +198,39 @@ def test_빈_층에서_폴더_항목_깊이_1_2_5_이름_옮기기_지우기(wor
|
||||
assert json.loads((root / "table/구조물집계표.json").read_bytes())["열"] == []
|
||||
|
||||
|
||||
def test_옮기기_같은_이름은_이름_번호로_저장(world):
|
||||
"""옮기기(항목 · 폴더) 같은 폴더 같은 이름 = 말없이 「이름(1)」 「이름(2)」 · 키 · 번호 그대로.
|
||||
사용자가 직접 친 이름(더하기 · 이름 바꾸기) 겹침은 그대로 409."""
|
||||
root = world["storage"] / "7" / "42" / "templates"
|
||||
t = Tree(_as(world), "personal")
|
||||
t.do(op="add-folder", 경로=[], 이름="A")
|
||||
t.do(op="add-folder", 경로=[], 이름="B")
|
||||
for f in ("A", "B"):
|
||||
t.do(op="add-item", 경로=[f], 이름="같음")
|
||||
t.do(op="add-item", 경로=["B"], 이름="같음(1)")
|
||||
t.do(op="add-folder", 경로=["A"], 이름="하위")
|
||||
t.do(op="add-folder", 경로=["B"], 이름="하위")
|
||||
t.do(op="add-item", 경로=["B", "하위"], 이름="안쪽")
|
||||
mine = t.item(["A", "같음"])
|
||||
note = t.do(op="move", 키=mine["key"], 새경로=["B"])["알림"]
|
||||
assert note["경로"] == ["B", "같음(2)"] and note["바뀐이름"] == ["같음(2)"] # (1) 도 있어서
|
||||
moved = t.item(["B", "같음(2)"])
|
||||
assert moved["key"] == mine["key"] and _numbers(root, moved["열"]) == _numbers(root, mine["열"])
|
||||
# 겹침 없으면 이름 그대로 · 바뀐이름 없음 · 제자리 옮기기도 그대로
|
||||
free = t.do(op="move", 키=moved["key"], 새경로=["A"])["알림"]
|
||||
assert free["경로"] == ["A", "같음(2)"] and "바뀐이름" not in free
|
||||
stay = t.do(op="move", 키=t.item(["B", "같음"])["key"], 새경로=["B"])["알림"]
|
||||
assert stay["경로"] == ["B", "같음"] and "바뀐이름" not in stay
|
||||
# 폴더 — 같은 이름 폴더가 이미 있으면 「하위(1)」 · 안의 항목 따라감 · 같은 부모로는 그대로
|
||||
folder = t.do(op="move", 경로=["A", "하위"], 새경로=["B"])["알림"]
|
||||
assert folder["경로"] == ["B", "하위(1)"] and folder["바뀐이름"] == ["하위(1)"]
|
||||
t.do(op="move", 경로=["B", "하위"], 새경로=["B"]) # 제자리 — 409 아님
|
||||
assert ["B", "하위", "안쪽"] in t.paths()
|
||||
# 직접 친 이름은 지금처럼 막음
|
||||
t.do(409, op="add-item", 경로=["B"], 이름="같음")
|
||||
t.do(409, op="rename", 키=t.item(["B", "같음(1)"])["key"], 이름="같음")
|
||||
|
||||
|
||||
def test_판_409_권한_403(world):
|
||||
admin = Tree(_as(world, "ADMIN"), "company")
|
||||
stale = admin.now["판"]
|
||||
@@ -254,7 +287,7 @@ def test_표_저장과_나무가_한_줄기(world):
|
||||
|
||||
def test_프로젝트에서_새_항목도_경로_그대로(world):
|
||||
"""개인에 저장 · 프로젝트에서 새 항목(`M02_Item_Personal`)도 경로를 넷에 접지 않고 그대로 ·
|
||||
같은 경로면 「이름 (2)」."""
|
||||
같은 경로면 「이름(1)」."""
|
||||
from M02_MasterTemplete import M02_Item_Personal as personal
|
||||
|
||||
folder = layers.personal_dir(7, 42)
|
||||
@@ -262,5 +295,5 @@ def test_프로젝트에서_새_항목도_경로_그대로(world):
|
||||
again = personal.create_item(folder, ["가", "나", "다", "라"], "마", "m")
|
||||
t = Tree(_as(world), "personal")
|
||||
assert t.item(["가", "나", "다", "라", "마"])["key"] == made["key"]
|
||||
assert t.item(["가", "나", "다", "라", "마 (2)"])["key"] == again["key"]
|
||||
assert t.item(["가", "나", "다", "라", "마(1)"])["key"] == again["key"]
|
||||
assert made["이름"] == "가 나 다 라 마"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""M02 구조물 라이브러리 여러 개 · 층 사이 붙여넣기(PLAN 30-2) — 임시 storage.
|
||||
|
||||
① 회사 항목 · 폴더를 일반 사용자 개인 층에 붙임 — 새 SP 키 · 본뜬 = 원본 · 세 파일 복사 ·
|
||||
폴더는 안의 것 · 빈 폴더까지 · 다시 붙이면 「이름 (2)」 · 고른 폴더 안의 것은 한 번만.
|
||||
폴더는 안의 것 · 빈 폴더까지 · 다시 붙이면 「이름(1)」 · 고른 폴더 안의 것은 한 번만.
|
||||
② 권한 — 쓰기 층만(일반 사용자 회사 층 403 · 남의 층 403) · 복사한 층은 읽기 권한(남의 회사 403).
|
||||
③ 여러 개 옮기기 · 지우기 한 번에(하나라도 막히면 아무것도 안 바뀜).
|
||||
"""
|
||||
@@ -52,10 +52,10 @@ def test_회사_항목_폴더를_개인에_붙임_새_키_파일_이름2(world):
|
||||
assert made["본뜬"] == {"layer": "company", "owner": "7", "key": wall}
|
||||
for kind in ("structure", "area"):
|
||||
assert (root / kind / f"{made['id']}.json").is_file()
|
||||
# 다시 붙이면 같은 이름 「(2)」 · 붙일 폴더 안으로
|
||||
# 다시 붙이면 같은 이름 「(1)」 · 붙일 폴더 안으로
|
||||
again = client.post(PASTE.format("personal"), json={**body, "경로": []}).json()
|
||||
assert ["배수 (2)", "측구"] in [r["경로"] for r in again["items"]]
|
||||
assert ["옹벽 (2)"] in [r["경로"] for r in again["items"]]
|
||||
assert ["배수(1)", "측구"] in [r["경로"] for r in again["items"]]
|
||||
assert ["옹벽(1)"] in [r["경로"] for r in again["items"]]
|
||||
inner = client.post(PASTE.format("personal"), json={**body, "대상": [body["대상"][2]],
|
||||
"경로": ["배수"]}).json() # fmt: skip
|
||||
assert ["배수", "옹벽"] in [r["경로"] for r in inner["items"]]
|
||||
@@ -109,6 +109,19 @@ def test_여러_개_옮기기_지우기_한_번에(world):
|
||||
assert t.paths() == [["나", "둘"]]
|
||||
|
||||
|
||||
def test_여러_개_옮기기_같은_이름은_이름_번호로(world):
|
||||
t = Tree(_as(world), "personal")
|
||||
t.do(op="add-folder", 경로=[], 이름="나")
|
||||
t.do(op="add-item", 경로=["나"], 이름="같음")
|
||||
t.do(op="add-item", 경로=[], 이름="같음")
|
||||
t.do(op="add-folder", 경로=[], 이름="묶음")
|
||||
t.do(op="add-item", 경로=["묶음"], 이름="같음")
|
||||
moves = [{"경로": [], "키": t.item(["같음"])["key"]}, {"경로": ["묶음"]}]
|
||||
note = t.do(op="move", 대상=moves, 새경로=["나"])["알림"]
|
||||
assert sorted(t.paths()) == [["나", "같음"], ["나", "같음(1)"], ["나", "묶음", "같음"]]
|
||||
assert note["바뀐이름"] == ["같음(1)"]
|
||||
|
||||
|
||||
def test_항목_추가_일위대가_단위_없이(world):
|
||||
"""PLAN 31-2 · 39-1d — 항목 추가 = 종류(폴더) + 이름 · 단위 안 받아도 됨 ·
|
||||
일위대가 키는 안 받음 — 열 칸에 안 적고 늘 빈 labor 문서
|
||||
|
||||
@@ -76,7 +76,7 @@ def test_text_column_pick_rows_blank():
|
||||
def test_labor_row_shows_su_number():
|
||||
"""마스터 일위대가 줄 = 그 열 일위대가번(SU) — 열의 옛 `일위대가` 칸(UA 키)은 안 읽음."""
|
||||
ops = OPS.read_text(encoding="utf-8")
|
||||
assert "REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]" in ops
|
||||
assert "REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_SUM]" in ops
|
||||
render = (OPS.parent / "ui_template_sheet_render.ts").read_text(encoding="utf-8")
|
||||
assert re.search(r"key === KEY_PRICE\s*\?\s*state\.laborLabel", render)
|
||||
assert "col.일위대가" not in render
|
||||
|
||||
@@ -42,3 +42,24 @@ def test_폭_자르기():
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [280, 500, 1360, 280]
|
||||
|
||||
|
||||
def test_따라붙기_끔_옵션():
|
||||
"""85-5 — `follow: false` = followView 안 부름 · 칸 높이 채움(is-fill) · M02 상세 둘만 씀 · M01 그대로."""
|
||||
src = PART.read_text(encoding="utf-8")
|
||||
assert (
|
||||
'if (opts.follow === false) root.classList.add("is-fill");\n else followView(root);' in src
|
||||
)
|
||||
css = PART.with_suffix(".css").read_text(encoding="utf-8")
|
||||
assert ".ui-side-panels.is-fill {" in css and "overscroll-behavior: contain" in css
|
||||
m02 = ROOT / "M02_MasterTemplete"
|
||||
for name in ("M02_MasterTemplete_AreaPanel.ts", "M02_MasterTemplete_Labor_Detail.ts"):
|
||||
assert "follow: false," in (m02 / name).read_text(encoding="utf-8"), name
|
||||
m01 = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts").read_text(encoding="utf-8")
|
||||
assert "follow" not in m01
|
||||
style = (m02 / "M02_MasterTemplete_UI_Style.css").read_text(encoding="utf-8")
|
||||
assert ".m02-main:has(> .m02-main__host > .ui-side-panels-row)" in style
|
||||
source = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert 'scrollIntoView({ block: "center" })' not in source and "box.scrollTop +=" in source
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* - handles = 패널 수(M01 2 · 일위대가 1 · 면적 2) · 손잡이 x < 패널 x(왼쪽 가장자리 밖)
|
||||
* - steps[i].open = 누른 패널 하나만 · width 모두 같음(폭 공유) · none.open = [] · none.rootW = 0(손잡이만 남음)
|
||||
* - sticky.tops = [0 · 250 · 400 굴렸을 때 패널 top] — 머리띠(head) 아래 8 에 닿으면 그 자리에 멈춤(250 = 400) · 본문이 짧으면 덜 굴러감(scrolled) · inView = 패널 위아래 끝이 창 안
|
||||
* · M02 일위대가 · 면적산출(85-5 · is-fill) = 본문 스크롤 칸이 패널 형제 본문 · tops 셋 같음(이동 0)
|
||||
* - ownScroll: 패널 몸을 굴리면 몸 scrollTop 만 바뀌고 본문 스크롤 그대로(mainMoved 0)
|
||||
* - m01Cells = m02Cells(같은 로직 GC000198 원문 표 칸 수) · 표 둘(7칸 · 2칸)
|
||||
* - m01Look = m02Look(sameLook true) — 같은 클래스 · 글자 크기(16px) · 표 칸 꼴 · tableFit 모두 true(표 높이 = 내용 · 자체 세로 스크롤 없음)
|
||||
@@ -60,7 +61,16 @@
|
||||
const panel = box(scrollId);
|
||||
const body = $(".ui-side-panels__body", panel);
|
||||
// 본문 스크롤 칸 — 위 칸 중 굴러가는 것 · 없으면 창 · 숨은 탭은 scroll 알림이 안 와 직접 보냄
|
||||
let sc = null;
|
||||
let sc = sp.classList.contains("is-fill")
|
||||
? ([...sp.parentElement.querySelectorAll("*")].find((n) => {
|
||||
const oy = getComputedStyle(n).overflowY;
|
||||
return (
|
||||
!sp.contains(n) &&
|
||||
(oy === "auto" || oy === "scroll") &&
|
||||
n.scrollHeight > n.clientHeight + 1
|
||||
);
|
||||
}) ?? null)
|
||||
: null;
|
||||
for (let n = sp.parentElement; n && !sc; n = n.parentElement) {
|
||||
const oy = getComputedStyle(n).overflowY;
|
||||
if ((oy === "auto" || oy === "scroll") && n.scrollHeight > n.clientHeight + 1) sc = n;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 시작 화면: 패널이 이미 열린 상세 화면 하나(M02 일위대가 상세 · M02 면적산출 · M01 로직 상세 중 하나) · 화면마다 한 번씩 부름
|
||||
* 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 기대: ok = true · h = want(innerHeight − max(머리띠, 칸 위)) ±2 · 칸이 올라가 붙은 뒤는 innerHeight − 머리띠 · inView = 그대로·처음·중간·끝 모두 true
|
||||
* · M02 일위대가 · 면적산출 상세(85-5 · follow:false = is-fill) — want = 칸 높이 · 본문 스크롤 칸 = 패널 형제 본문 · moved(처음~끝 top 차) = 0
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -10,7 +11,17 @@
|
||||
);
|
||||
if (!sp) return JSON.stringify({ error: "펴진 패널 없음" });
|
||||
const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0;
|
||||
const fill = sp.classList.contains("is-fill");
|
||||
const scrolls = (n) =>
|
||||
["auto", "scroll"].includes(getComputedStyle(n).overflowY) &&
|
||||
n.scrollHeight > n.clientHeight + 1;
|
||||
const scroller = (() => {
|
||||
// is-fill(85-5) — 메인 = 같은 칸의 본문(패널 밖)
|
||||
if (fill)
|
||||
return (
|
||||
[...sp.parentElement.querySelectorAll("*")].find((n) => !sp.contains(n) && scrolls(n)) ??
|
||||
document.scrollingElement
|
||||
);
|
||||
for (let n = sp.parentElement; n; n = n.parentElement) {
|
||||
const y = getComputedStyle(n).overflowY;
|
||||
if ((y === "auto" || y === "scroll") && n.scrollHeight > n.clientHeight + 1) return n;
|
||||
@@ -23,9 +34,9 @@
|
||||
window.dispatchEvent(new Event("scroll")); // 뒤에 가린 탭은 스크롤 알림이 안 와서 직접
|
||||
await w(150);
|
||||
const r = sp.getBoundingClientRect();
|
||||
const want = Math.round(
|
||||
innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top),
|
||||
);
|
||||
const want = fill
|
||||
? Math.round(sp.parentElement.getBoundingClientRect().height)
|
||||
: Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top));
|
||||
const body = sp.querySelector(".ui-side-panels__panel:not([hidden]) .ui-side-panels__body");
|
||||
return {
|
||||
name,
|
||||
@@ -43,7 +54,9 @@
|
||||
const still = {
|
||||
name: "그대로",
|
||||
h: Math.round(r0.height),
|
||||
want: Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top)),
|
||||
want: fill
|
||||
? Math.round(sp.parentElement.getBoundingClientRect().height)
|
||||
: Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top)),
|
||||
inView: r0.top >= head - 2 && r0.bottom <= innerHeight + 2,
|
||||
};
|
||||
const rows = [
|
||||
@@ -58,6 +71,11 @@
|
||||
head: Math.round(head),
|
||||
scrollMax: Math.round(max),
|
||||
rows,
|
||||
ok: rows.every((r) => Math.abs(r.h - r.want) <= 2 && r.inView),
|
||||
fill,
|
||||
moved:
|
||||
Math.max(...rows.slice(1).map((r) => r.top)) - Math.min(...rows.slice(1).map((r) => r.top)),
|
||||
ok:
|
||||
rows.every((r) => Math.abs(r.h - r.want) <= 2 && r.inView) &&
|
||||
(!fill || rows.slice(1).every((r) => r.top === rows[1].top)),
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
* orca eval --expression "window.__m5210='restore'; $(cat 이 파일)" → 추가한 둘 삭제 · [저장]
|
||||
* 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 지움)
|
||||
* 기대 수치:
|
||||
* 포장 — containers ["detail"] · 상세 내용 · 포장사양 선택지 ["콘크리트"] · 높이 0.35 · 잇기 지정 ·
|
||||
* 종류 단위 잇기 줄 가림 · 옛 상세 칸(두께 · 줄눈 …) 안 보임 · 길이 · 전 · 후 칸 보임
|
||||
* 포장 — containers ["detail"] · 상세 내용 · 포장사양 선택지 [콘크리트 · 아스콘 · 쇄석] · 칸 차례 포장사양 → 길이(출력 20) →
|
||||
* 높이 0.35 · 잇기 지정 · 종류 단위 잇기 줄 가림 · 옛 상세 칸(두께 · 줄눈 …) 안 보임 ·
|
||||
* 기준 측점 칸 없음 · 시작 · 종료 칸 보임 · 길이 · 전 · 후 입력 칸 없음
|
||||
* 기타 — 「상세 종류 이름」 글 칸 = 「52-10 시험 기타」 · 목록 이름도 그 글 ·
|
||||
* 선택 컨테이너 「상세 내용」(52-14) 하나만(52-17) — 추가 → [추가] 없음 · 빼기 → 다시 보임 ·
|
||||
* 다시 추가 · 비고 「52-14 비고」(길이 상한 100) · 잇기 · 하나 남음 · 종류 단위 잇기 줄 가림
|
||||
@@ -37,7 +38,7 @@
|
||||
};
|
||||
const listItems = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
|
||||
const ITEMS = [
|
||||
{ typeId: "pavement_concrete", group: "G", at: [4, 6], list: /콘크리트 포장/ },
|
||||
{ typeId: "pavement_concrete", group: "G", at: [4, 6], to: [5, 6], list: /콘크리트 포장/ },
|
||||
{ typeId: "etc", group: "호환", at: [5, 2], list: new RegExp(NAME) },
|
||||
];
|
||||
const typeSummary = () =>
|
||||
@@ -76,6 +77,16 @@
|
||||
: null,
|
||||
paveSpec: specCtl("spec")?.value ?? null,
|
||||
height: specCtl("height")?.value ?? null,
|
||||
itemOrder: boxes[0]
|
||||
? [...boxes[0].querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem)
|
||||
: [],
|
||||
lengthOut:
|
||||
spec()?.querySelector('[data-spec-item="detail.length"] output')?.textContent ?? null,
|
||||
anchorHidden: !!sec().querySelectorAll(".b05-structure__position-row > label")[2]?.hidden,
|
||||
startEndShown: [1, 3].every(
|
||||
(n) => !sec().querySelectorAll(".b05-structure__position-row > label")[n]?.hidden,
|
||||
),
|
||||
wideningShown: vis(sec().querySelector(".b05-structure__widening")),
|
||||
containerLink: !!boxes[0]?.querySelector(".b05-structure__summary-name.is-assigned"),
|
||||
containerLinks: boxes.map(
|
||||
(b) => !!b.querySelector(".b05-structure__summary-name.is-assigned"),
|
||||
@@ -139,11 +150,20 @@
|
||||
for (const item of ITEMS) {
|
||||
await pickType(item.group, item.typeId);
|
||||
const row = { lockedBeforePosition: shape(item.typeId).locked };
|
||||
const [no, dist] = sec()
|
||||
.querySelectorAll(".b05-structure__position-row > label")[2]
|
||||
.querySelectorAll("input");
|
||||
fire(no, String(item.at[0]));
|
||||
fire(dist, String(item.at[1]));
|
||||
const labels = sec().querySelectorAll(".b05-structure__position-row > label");
|
||||
if (item.to) {
|
||||
// 노면공 = 시작 · 종료 측점만(87-1) — 기준 측점 칸 없음
|
||||
const [s0, s1] = labels[1].querySelectorAll("input");
|
||||
const [e0, e1] = labels[3].querySelectorAll("input");
|
||||
fire(s0, String(item.at[0]));
|
||||
fire(s1, String(item.at[1]));
|
||||
fire(e0, String(item.to[0]));
|
||||
fire(e1, String(item.to[1]));
|
||||
} else {
|
||||
const [no, dist] = labels[2].querySelectorAll("input");
|
||||
fire(no, String(item.at[0]));
|
||||
fire(dist, String(item.at[1]));
|
||||
}
|
||||
await w(300);
|
||||
if (item.typeId === "pavement_concrete") {
|
||||
fire(specCtl("spec"), "콘크리트");
|
||||
@@ -226,13 +246,17 @@
|
||||
!pave.missing &&
|
||||
JSON.stringify(pave.containers) === '["detail"]' &&
|
||||
pave.legend[0] === "상세 내용" &&
|
||||
JSON.stringify(pave.paveChoices) === '["콘크리트"]' &&
|
||||
JSON.stringify(pave.paveChoices) === '["콘크리트","아스콘","쇄석"]' &&
|
||||
JSON.stringify(pave.itemOrder) === '["detail.spec","detail.length","detail.height"]' &&
|
||||
Number(pave.lengthOut) === 20 &&
|
||||
pave.anchorHidden &&
|
||||
pave.startEndShown &&
|
||||
pave.paveSpec === "콘크리트" &&
|
||||
Number(pave.height) === 0.35 &&
|
||||
pave.containerLink &&
|
||||
!pave.typeSummaryVisible &&
|
||||
!pave.options.some((l) => /두께|줄눈|철망|비닐|포장 폭|확폭/.test(l)) &&
|
||||
["길이", "기준측점 전", "기준측점 후"].every((l) => pave.options.some((o) => o.startsWith(l)));
|
||||
!pave.options.some((l) => /^(길이|기준측점 전|기준측점 후)/.test(l)); // 87-1 입력 칸 없음
|
||||
out.etcOk =
|
||||
!!etc &&
|
||||
!etc.missing &&
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
(async () => {
|
||||
const MODE = window.__m5215 || "check";
|
||||
const PIPE = /^22\+0 배관/;
|
||||
const PAVE = /^21\+15~22\+5 콘크리트 포장/; // 기준 22+0 · 길이 10 m
|
||||
const PAVE = /^21\+15~22\+5 콘크리트 포장/; // 시작 21+15 · 종료 22+5 (10 m)
|
||||
const OTHER = /^4\+5 배관/;
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 15000) => {
|
||||
@@ -85,13 +85,16 @@
|
||||
await w(300);
|
||||
fire(t, "pavement_concrete");
|
||||
await w(1500);
|
||||
const anchor = () =>
|
||||
sec().querySelectorAll(".b05-structure__position-row > label")[2].querySelectorAll("input");
|
||||
fire(anchor()[0], "22");
|
||||
// 노면공 = 시작 · 종료 측점만(87-1) — 시작 21+15 · 종료 22+5(10 m)
|
||||
const pos = (n) =>
|
||||
sec().querySelectorAll(".b05-structure__position-row > label")[n].querySelectorAll("input");
|
||||
fire(pos(1)[0], "21");
|
||||
fire(pos(1)[1], "15");
|
||||
fire(pos(3)[0], "22");
|
||||
await w(300);
|
||||
fire(anchor()[1], "0");
|
||||
fire(pos(3)[1], "5");
|
||||
await w(500);
|
||||
out.anchor = [...anchor()].map((i) => i.value);
|
||||
out.anchor = [...pos(1), ...pos(3)].map((i) => i.value);
|
||||
fire(specCtl("spec"), "콘크리트");
|
||||
await w(200);
|
||||
fire(specCtl("height"), "0.35");
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/* 83-3 — 면적산출 상세 오른쪽 「도면」 패널 [면적산출로 보내기] · 고른 범위 → 표 자료 → 새 시트
|
||||
* 시작 화면: M02 마스터 템플릿 · 면적산출 상세(47-6 스크립트로 연 뒤) · 「도면」 패널 펼침
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 하는 일: 패널 CAD 에 견본 도면(표 · 선 표 · 글만 · 글 없는 선 · 회전된 표)을 보기 전용으로 싣고(저장 안 됨)
|
||||
* 범위마다 끌어 고른 뒤 단추를 눌러 부모가 받은 표 자료(export-table)와 알림을 모음 · 빈 범위도.
|
||||
* 기대 수치:
|
||||
* - table = kind table · rows [["구분","수량",""],["",1500,"m2"]] · merges 2
|
||||
* - grid = kind grid · rows [["구분","수량",""],["흙깎기",1500,"m3"],["돌쌓기",2.5,"m2"]] · merges 1
|
||||
* - text = kind text · rows [["합계",3000,"비고"],["소계",1000,""]]
|
||||
* - empty · lines = 받은 것 없음(알림만) · rotated = kind text(글 둘)
|
||||
* - 끝에 원래 도면을 다시 실음(restored)
|
||||
*/
|
||||
(async () => {
|
||||
const W = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const $ = (s, root = document) => root.querySelector(s);
|
||||
const until = async (fn, ms = 8000) => {
|
||||
for (let t = 0; t < ms; t += 100) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await W(100);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const panel = $('.ui-side-panels__panel[data-panel="drawing"]');
|
||||
const frame = panel?.querySelector("iframe");
|
||||
const btn = $("[data-area-from-drawing]");
|
||||
if (!frame || !btn) return JSON.stringify({ error: "도면 패널 · 단추 없음" });
|
||||
const win = frame.contentWindow;
|
||||
const cad = win.__aisloCad;
|
||||
const L = "L83";
|
||||
let n = 0;
|
||||
const id = () => `t83-${(n += 1)}`;
|
||||
const base = { lineColor: "#fff", lineWidth: 1, layerId: L };
|
||||
const line = (x1, y1, x2, y2) => ({
|
||||
id: id(),
|
||||
type: "Line",
|
||||
...base,
|
||||
shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } },
|
||||
});
|
||||
const text = (label, x, y) => ({
|
||||
id: id(),
|
||||
type: "Text",
|
||||
...base,
|
||||
shapeData: {
|
||||
label,
|
||||
basePoint: { x, y },
|
||||
options: {
|
||||
textDirection: { x: 1, y: 0 },
|
||||
textAlign: "left",
|
||||
textColor: "#fff",
|
||||
fontSize: 2.5,
|
||||
fontFamily: "Noto Sans KR",
|
||||
},
|
||||
},
|
||||
});
|
||||
const rot = (x, y, ox, oy) => {
|
||||
const a = Math.PI / 6;
|
||||
return [ox + x * Math.cos(a) - y * Math.sin(a), oy + x * Math.sin(a) + y * Math.cos(a)];
|
||||
};
|
||||
const groups = {
|
||||
table: [
|
||||
{
|
||||
id: id(),
|
||||
type: "Table",
|
||||
...base,
|
||||
shapeData: {
|
||||
origin: { x: 0, y: 100 },
|
||||
columnWidths: [30, 20, 25],
|
||||
rowHeights: [8, 6],
|
||||
cells: [
|
||||
[{ text: "구분", rowSpan: 2 }, { text: "수량", colSpan: 2 }, null],
|
||||
[null, { text: "1,500" }, { text: "m2" }],
|
||||
],
|
||||
style: { fontSize: 2.5 },
|
||||
},
|
||||
},
|
||||
],
|
||||
grid: [
|
||||
line(100, 60, 200, 60),
|
||||
line(100, 30, 200, 30),
|
||||
line(100, 60, 100, 30),
|
||||
line(200, 60, 200, 30),
|
||||
line(100, 50, 155, 50),
|
||||
line(150, 50, 200, 50),
|
||||
line(100, 40, 200, 40),
|
||||
line(140, 60, 140, 30),
|
||||
line(170, 50, 170, 30),
|
||||
text("구분", 102, 53),
|
||||
text("수량", 145, 53),
|
||||
text("흙깎기", 102, 43),
|
||||
text("1,500", 142, 43),
|
||||
text("m3", 172, 43),
|
||||
text("돌쌓기", 102, 33),
|
||||
text("2.5", 142, 33),
|
||||
text("m2", 172, 33),
|
||||
],
|
||||
text: [
|
||||
text("합계", 250, 60),
|
||||
text("3,000", 290, 60.3),
|
||||
text("비고", 330, 59.8),
|
||||
text("소계", 250, 50),
|
||||
text("1,000", 290, 50),
|
||||
],
|
||||
lines: [
|
||||
line(0, 0, 40, 0),
|
||||
line(0, 20, 40, 20),
|
||||
line(0, 0, 0, 20),
|
||||
line(40, 0, 40, 20),
|
||||
line(20, 0, 20, 20),
|
||||
],
|
||||
rotated: [
|
||||
line(...rot(0, 0, 250, 0), ...rot(40, 0, 250, 0)),
|
||||
line(...rot(0, 10, 250, 0), ...rot(40, 10, 250, 0)),
|
||||
line(...rot(0, 0, 250, 0), ...rot(0, 10, 250, 0)),
|
||||
line(...rot(40, 0, 250, 0), ...rot(40, 10, 250, 0)),
|
||||
line(...rot(20, 0, 250, 0), ...rot(20, 10, 250, 0)),
|
||||
text("A", ...rot(2, 4, 250, 0)),
|
||||
text("B", ...rot(22, 4, 250, 0)),
|
||||
],
|
||||
};
|
||||
const all = Object.values(groups).flat();
|
||||
// 원래 도면 — 끝에 다시 실음
|
||||
const original = await new Promise((resolve) => {
|
||||
const on = (e) => {
|
||||
if (e.source !== win || e.data?.type !== "aislo:b08:save-response") return;
|
||||
window.removeEventListener("message", on);
|
||||
resolve(e.data.drawing);
|
||||
};
|
||||
window.addEventListener("message", on);
|
||||
win.postMessage({ type: "aislo:b08:save-request" }, location.origin);
|
||||
});
|
||||
const load = async (drawing) => {
|
||||
const done = new Promise((resolve) => {
|
||||
const on = (e) => {
|
||||
if (e.source !== win || e.data?.type !== "aislo:b08:drawing-loaded") return;
|
||||
window.removeEventListener("message", on);
|
||||
resolve(true);
|
||||
};
|
||||
window.addEventListener("message", on);
|
||||
});
|
||||
win.postMessage(
|
||||
{
|
||||
type: "aislo:b08:load-drawing",
|
||||
drawing,
|
||||
readOnly: true,
|
||||
panelsCollapsed: true,
|
||||
recoveryScope: "verify83",
|
||||
},
|
||||
location.origin,
|
||||
);
|
||||
return Promise.race([done, W(5000)]);
|
||||
};
|
||||
await load({
|
||||
format: 6,
|
||||
entities: all,
|
||||
layers: [{ id: L, name: "검증83", isVisible: true, isLocked: false }],
|
||||
});
|
||||
await W(400);
|
||||
|
||||
// 받은 표 자료 · 알림 모음
|
||||
const got = [];
|
||||
const onMsg = (e) =>
|
||||
e.source === win && e.data?.type === "aislo:b08:export-table" && got.push(e.data.table);
|
||||
window.addEventListener("message", onMsg);
|
||||
const toasts = () =>
|
||||
[...document.querySelectorAll(".ui-toast, .ui-toast__message, [class*=toast]")]
|
||||
.map((t) => t.innerText.trim())
|
||||
.filter(Boolean);
|
||||
const canvas = frame.contentDocument.querySelector("canvas");
|
||||
const box = (list) => {
|
||||
const xs = [],
|
||||
ys = [];
|
||||
for (const e of list) {
|
||||
const s = e.shapeData;
|
||||
if (e.type === "Line")
|
||||
(xs.push(s.startPoint.x, s.endPoint.x), ys.push(s.startPoint.y, s.endPoint.y));
|
||||
if (e.type === "Text")
|
||||
(xs.push(s.basePoint.x, s.basePoint.x + 6), ys.push(s.basePoint.y, s.basePoint.y + 3));
|
||||
if (e.type === "Table") {
|
||||
xs.push(s.origin.x, s.origin.x + s.columnWidths.reduce((a, b) => a + b, 0));
|
||||
ys.push(s.origin.y, s.origin.y - s.rowHeights.reduce((a, b) => a + b, 0));
|
||||
}
|
||||
}
|
||||
return [Math.min(...xs) - 5, Math.min(...ys) - 5, Math.max(...xs) + 5, Math.max(...ys) + 5];
|
||||
};
|
||||
const fire = (type, p) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const ev = new win.MouseEvent(type, {
|
||||
bubbles: true,
|
||||
clientX: r.left + p.x,
|
||||
clientY: r.bottom - p.y,
|
||||
button: 0,
|
||||
buttons: type === "mouseup" ? 0 : 1,
|
||||
});
|
||||
canvas.dispatchEvent(ev);
|
||||
};
|
||||
const drag = async ([x0, y0, x1, y1]) => {
|
||||
frame.contentDocument.dispatchEvent(
|
||||
new win.KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||
);
|
||||
await W(150);
|
||||
const a = cad.screen(x0, y0),
|
||||
b = cad.screen(x1, y1);
|
||||
fire("mousemove", a);
|
||||
await W(60);
|
||||
fire("mousedown", a);
|
||||
await W(60);
|
||||
fire("mousemove", { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
|
||||
await W(60);
|
||||
fire("mousemove", b);
|
||||
await W(60);
|
||||
fire("mouseup", b);
|
||||
await W(250);
|
||||
};
|
||||
const out = {};
|
||||
for (const [name, list] of Object.entries(groups)) {
|
||||
got.length = 0;
|
||||
await drag(box(list));
|
||||
out[name] = { selected: cad.selection().length };
|
||||
btn.click();
|
||||
await W(500);
|
||||
out[name].table = got[0] ?? null;
|
||||
out[name].toasts = toasts().slice(-2);
|
||||
}
|
||||
// 빈 범위 — 아무것도 없는 자리를 끌어 고름
|
||||
got.length = 0;
|
||||
await drag([210, 70, 240, 90]);
|
||||
btn.click();
|
||||
await W(500);
|
||||
out.empty = {
|
||||
selected: cad.selection().length,
|
||||
table: got[0] ?? null,
|
||||
toasts: toasts().slice(-1),
|
||||
};
|
||||
window.removeEventListener("message", onMsg);
|
||||
out.restored = await load(original);
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
Reference in New Issue
Block a user