Files
frontend/apps/sn/tests/sn-structure-extraction.test.cjs
T

625 lines
17 KiB
JavaScript

const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { test } = require("node:test");
const { loadTypeScript } = require("./load-typescript.cjs");
class Options {
constructor(options) {
this.options = options;
}
}
class Feature {
constructor(values = {}) {
this.values = values;
}
set(key, value) {
this.values[key] = value;
}
get(key) {
return this.values[key];
}
setStyle(style) {
this.style = style;
}
}
class Geometry {
constructor(type, coordinates) {
this.type = type;
this.coordinates = coordinates;
}
getType() {
return this.type;
}
getCoordinates() {
return this.coordinates;
}
setCoordinates(value) {
this.coordinates = value;
}
}
const olMocks = {
"ol/Feature.js": Feature,
"ol/geom/Point.js": class extends Geometry {
constructor(coordinates) {
super("Point", coordinates);
}
},
"ol/geom/Polygon.js": class extends Geometry {
constructor(coordinates) {
super("Polygon", coordinates);
}
},
"ol/style/Style.js": Options,
"ol/style/Fill.js": Options,
"ol/style/Stroke.js": Options,
"ol/style/Circle.js": Options,
"ol/style/Text.js": Options,
"ol/format/GeoJSON.js": class {
writeGeometryObject(geometry) {
return {
type: geometry.getType(),
coordinates: geometry.getCoordinates(),
};
}
},
};
const edit = loadTypeScript("src/features/gisEdit/lib/edit-model.ts");
const snapshot = loadTypeScript(
"src/features/gisEdit/lib/feature-snapshot.ts",
olMocks,
);
const mutations = loadTypeScript(
"src/features/gisEdit/lib/geometry-mutations.ts",
olMocks,
);
const handles = loadTypeScript(
"src/features/gisEdit/lib/vertex-handles.ts",
olMocks,
);
test("GIS 편집 레이어 제한·속성 whitelist·일자 정규화를 보존", () => {
const layers = edit.collectEditableLayers([
{
children: [
{ lyrTyNm: "tb_ms_swage_mhl" },
{ lyr_type_nm: "tb_ms_swage_ppl", line_colr: "#f00" },
],
},
]);
assert.equal(layers.length, 1);
assert.equal(layers[0].layerId, "tb_ms_swage_ppl");
assert.deepEqual(layers[0].allowedTools, [
"ADD",
"MOVE",
"VERTEX_EDIT",
"DELETE",
]);
assert.equal(layers[0].previewColors.lineColor, "rgba(255, 0, 0, 0.95)");
assert.deepEqual(
edit.buildPipeSaveAttributes({
fcltMngNo: "P1",
instlYmd: "2026-09-11",
instMngNo: " ",
arbitrary: "drop",
}),
{ fcltMngNo: "P1", instlYmd: "20260911", instMngNo: null },
);
});
test("feature 식별자와 LineString 저장 snapshot은 기존 계약 유지", () => {
const ref = snapshot.getFeatureRef({
values_: { fclt_mng_no: " P1 ", mng_inst_cd: " 123 " },
getId: () => "ws:tb_ms_swage_ppl.11",
});
assert.deepEqual(ref, { fcltMngNo: "P1", mngInstCd: "123", fid: "11" });
const geometry = {
type: "LineString",
coordinates: [
[0, 0],
[1, 1],
],
};
assert.deepEqual(
snapshot.normalizePipeGeometry("tb_ms_swage_ppl", geometry),
{
type: "MultiLineString",
coordinates: [
[
[0, 0],
[1, 1],
],
],
},
);
assert.equal(snapshot.normalizePipeGeometry("other", geometry), geometry);
assert.equal(
snapshot.buildBbox({
getSize: () => [10, 10],
getView: () => ({ calculateExtent: () => [1.23456, 2, 3, 4] }),
}),
"1.235,2.000,3.000,4.000",
);
});
test("꼭지점 이동·중간점 삽입과 폴리곤 폐합 유지", () => {
const line = new Geometry("LineString", [
[0, 0],
[10, 0],
]);
mutations.applyVertexHandleMove(
line,
{ kind: "midpoint", geometryType: "LineString", coordIndex: 0 },
[5, 2],
);
assert.deepEqual(line.getCoordinates(), [
[0, 0],
[5, 2],
[10, 0],
]);
const polygon = new Geometry("Polygon", [
[
[0, 0],
[5, 0],
[5, 5],
[0, 0],
],
]);
mutations.applyVertexHandleMove(
polygon,
{ kind: "vertex", geometryType: "Polygon", ringIndex: 0, coordIndex: 0 },
[1, 1],
);
assert.deepEqual(polygon.getCoordinates()[0], [
[1, 1],
[5, 0],
[5, 5],
[1, 1],
]);
});
test("최소 꼭지점·midpoint 삭제 차단 및 다중선 실패 시 원형 보존", () => {
const line = new Geometry("LineString", [
[0, 0],
[1, 1],
]);
assert.equal(
mutations.deleteSelectedVertexHandles(line, [
{ kind: "vertex", geometryType: "LineString", coordIndex: 0 },
]).ok,
false,
);
assert.equal(
mutations.deleteSelectedVertexHandles(line, [
{ kind: "midpoint", geometryType: "LineString", coordIndex: 0 },
]).ok,
false,
);
const multi = new Geometry("MultiLineString", [
[
[0, 0],
[1, 1],
[2, 2],
],
[
[3, 3],
[4, 4],
],
]);
const before = JSON.stringify(multi.getCoordinates());
const result = mutations.deleteSelectedVertexHandles(multi, [
{
kind: "vertex",
geometryType: "MultiLineString",
partIndex: 0,
coordIndex: 1,
},
{
kind: "vertex",
geometryType: "MultiLineString",
partIndex: 1,
coordIndex: 0,
},
]);
assert.equal(result.ok, false);
assert.equal(JSON.stringify(multi.getCoordinates()), before);
});
test("겹친 다중선 꼭지점 핸들은 메타를 합쳐 선택관계를 유지", () => {
const geometry = new Geometry("MultiLineString", [
[
[0, 0],
[10, 0],
],
[
[0, 0],
[20, 0],
],
]);
const features = handles.buildVertexHandleFeatures(geometry, {
getPixelFromCoordinate: ([x, y]) => [x * 10, y * 10],
});
assert.equal(features.length, 3);
assert.ok(
features.some((feature) => feature.get("handleMetaList").length === 2),
);
});
test("유하방향 대표 선분·중앙 위치·각도 계산 보존", () => {
const flow = loadTypeScript(
"src/features/mapLayers/lib/flow-direction-styles.ts",
olMocks,
);
const placement = flow.getArrowPlacementAtHalfLength([
[0, 0],
[4, 0],
[4, 8],
]);
assert.deepEqual(placement.coordinate, [4, 2]);
assert.equal(placement.angle, Math.PI / 2);
assert.equal(flow.withAlpha("#f00", 0.5), "rgba(255, 0, 0, 0.5)");
});
function downloadContext(t) {
const original = globalThis.window;
const delays = [];
globalThis.window = {
setTimeout: (callback, delay) => {
delays.push(delay);
callback();
return 1;
},
};
t.after(() => {
if (original === undefined) delete globalThis.window;
else globalThis.window = original;
});
t.mock.method(console, "warn", () => {});
return delays;
}
test("GIS 파일 연결 실패만 1회 재시도하고 HTTP 오류는 바로 전달", async (t) => {
const delays = downloadContext(t);
const { createGisDownloadClient } = loadTypeScript(
"src/features/gisManagement/api/gis-download.ts",
);
let count = 0;
const response = {
blob: new Blob(),
headers: new Headers(),
fileName: "map.gpkg",
};
const client = createGisDownloadClient({
getData: async () => null,
postRawJson: async () => null,
getBlobDataWithHeaders: async () => {
if (++count === 1) throw new TypeError("Failed to fetch");
return response;
},
});
assert.equal(await client.downloadGeoPackage("file"), response);
assert.equal(count, 2);
assert.deepEqual(delays, [500]);
let failedCalls = 0;
const failed = createGisDownloadClient({
getData: async () => null,
postRawJson: async () => null,
getBlobDataWithHeaders: async () => {
failedCalls++;
throw new Error("HTTP 500");
},
});
await assert.rejects(failed.downloadGeoPackage("file"), /HTTP 500/);
assert.equal(failedCalls, 1);
});
test("GIS 다운로드 작업 접수·상태 polling의 URL·순서·READY/FAILED 처리 유지", async (t) => {
const delays = downloadContext(t);
const { createGisDownloadClient } = loadTypeScript(
"src/features/gisManagement/api/gis-download.ts",
);
const calls = [],
statuses = [];
const jobs = [
{ status: "QUEUED", retryAfterMillis: 1 },
{ status: "READY", downloadId: "D1" },
];
const client = createGisDownloadClient({
getData: async (url) => {
calls.push(url);
return jobs.shift();
},
postRawJson: async () => ({ data: { jobId: "J/1" } }),
getBlobDataWithHeaders: async () => null,
});
assert.equal((await client.startDownloadJob("create")).jobId, "J/1");
assert.equal(
(await client.waitForDownloadJob("J/1", (job) => statuses.push(job.status)))
.downloadId,
"D1",
);
assert.deepEqual(calls, [
"v1/map/gisDownloadJobs/J%2F1",
"v1/map/gisDownloadJobs/J%2F1",
]);
assert.deepEqual(statuses, ["QUEUED", "READY"]);
assert.deepEqual(delays, [1000]);
const failed = createGisDownloadClient({
getData: async () => ({ status: "FAILED", message: "실패" }),
postRawJson: async () => ({}),
getBlobDataWithHeaders: async () => null,
});
await assert.rejects(failed.startDownloadJob("create"), /접수 정보/);
await assert.rejects(failed.waitForDownloadJob("id"), /실패/);
});
const exportOptions = () => ({
selectedTrtdstNm: "01. 덕풍",
defectGroupTitle: "관로",
isUnitMode: false,
defectTypeLabel: "전체",
unitRows: [],
rows: [],
recordTypeLabels: { DEFECT: "결함" },
pipeItemLabels: { CL: "균열" },
severityLabels: { L: "대", M: "중", S: "소" },
visibleDefectColumns: [{ code: "CL", label: "균열" }],
totals: {
extensionM: 10,
drivingDistanceM: 8,
structuralGrade: 2,
operationalGrade: 1,
},
defectSummary: {
anomalySubtotals: { L: 1, M: 0, S: 0 },
anomalyTotal: 1,
defectSubtotals: { CL: { L: 1, M: 0, S: 0 } },
defectTotals: { CL: 1 },
},
summaryLabelColumnCount: 7,
fixedColumnCount: 11,
setNotice: () => {},
});
test("조사 상세 Excel의 동적 결함열·소계·합계·병합영역 유지", async () => {
let output;
const { downloadExmnSurveyDetails } = loadTypeScript(
"src/features/exmn/lib/download-exmn-details.ts",
{
"@/shared/lib/download-excel": {
downloadExcelWithKoreanHeaders: async (value) => {
output = value;
},
},
},
);
const options = exportOptions();
options.rows = [
{
reportNo: "R1",
pipeNo: "P1",
upstreamManhole: "M1",
downstreamManhole: "M2",
drainageType: "D",
pipeType: "T",
specification: "600",
extensionM: 10,
drivingDistanceM: 8,
structuralGrade: 2,
operationalGrade: 1,
defectCounts: { CL: { L: 1, M: 0, S: 0 } },
},
];
await downloadExmnSurveyDetails(options);
assert.equal(output.data.length, 3);
assert.equal(output.data[0].CL_L, 1);
assert.equal(output.data[1].reportNo, "소계");
assert.equal(output.data[2].reportNo, "합계");
assert.equal(output.headerRange, "A1:Q1");
assert.deepEqual(output.mergeRanges, ["A3:G3", "A4:G4", "L4:N4", "O4:Q4"]);
});
test("본단위 Excel 표기와 빈 결과 안내는 유지하고 파일을 만들지 않음", async () => {
let output, notice;
const { downloadExmnSurveyDetails } = loadTypeScript(
"src/features/exmn/lib/download-exmn-details.ts",
{
"@/shared/lib/download-excel": {
downloadExcelWithKoreanHeaders: async (value) => {
output = value;
},
},
},
);
const options = {
...exportOptions(),
isUnitMode: true,
setNotice: (value) => {
notice = value;
},
};
await downloadExmnSurveyDetails(options);
assert.equal(output, undefined);
assert.match(notice.text, /본단위구간 데이터가 없습니다/);
options.unitRows = [
{
pipeNo: "P1",
recordType: "DEFECT",
code: "CL",
defectName: "(기존)균열",
severity: "L",
continuous: false,
structuralScore: 0,
},
];
await downloadExmnSurveyDetails(options);
assert.equal(output.headerRange, "A1:N1");
assert.equal(output.data[0].defectName, "(결함)균열");
assert.equal(output.data[0].continuous, "N");
assert.equal(output.data[0].structuralScore, 0);
});
test("준설 표시 컴포넌트는 현재 그룹·날짜·파일 조작을 상위 handler로 전달", () => {
const marker = () => null;
const ui = Object.fromEntries(
[
"Button",
"InputDatePicker",
"Table",
"TableBody",
"TableCell",
"TableHead",
"TableHeader",
"TableRow",
].map((name) => [
name,
function UiMarker() {
return null;
},
]),
);
const { DredgingRegistrationBody } = loadTypeScript(
"src/features/dredging/ui/DredgingRegistrationBody.tsx",
{
"@common/components": ui,
"@common/business_components/ui/file-upload": { FileUpload: marker },
},
);
const group = {
id: "G1",
registrationMode: "PHOTO",
photos: [],
manualLocation: null,
matchedPipe: null,
isMatching: false,
};
const calls = [];
const noop = () => {};
const tree = DredgingRegistrationBody({
presentation: "popup",
groups: [group],
activeGroup: group,
representativeDredgingDate: "",
setRepresentativeDredgingDate: (value) => calls.push(value),
parseLocalDate: (value) => new Date(value),
formatLocalDate: () => "2026-09-11",
getResolvedLocation: () => null,
addGroup: noop,
removeGroup: noop,
selectGroup: noop,
changeRegistrationMode: noop,
handleFilesChange: (id, files) => calls.push([id, files]),
removePhoto: noop,
registrationModeLabels: { PHOTO: "사진", MANUAL: "수동" },
photoStatusLabels: {},
locationSourceLabels: {},
isEditingActiveLocation: false,
locationDraft: null,
applyLocationDraft: noop,
cancelLocationEditing: noop,
startLocationEditing: noop,
activeResolvedLocation: null,
hasUnappliedLocationDraft: false,
statusMessage: "",
changeLocationDraft: noop,
LocationMap: marker,
});
const elements = [];
function visit(node) {
if (!node || typeof node !== "object") return;
if (Array.isArray(node)) return node.forEach(visit);
elements.push(node);
visit(node.props?.children);
}
visit(tree);
const upload = elements.find(
(node) => node.type === marker && node.props.maxFiles === 5,
);
const date = elements.find((node) => node.type === ui.InputDatePicker);
const files = [{ name: "photo.jpg" }];
upload.props.setFiles(files);
date.props.onChange(new Date());
assert.deepEqual(calls, [["G1", files], "2026-09-11"]);
});
test("불필요한 HTML 실행 파일은 없고 의도적 공개 barrel은 유지", () => {
const root = path.resolve(__dirname, "..");
const collect = (dir) =>
fs.existsSync(dir)
? fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) =>
entry.isDirectory()
? collect(path.join(dir, entry.name))
: [path.join(dir, entry.name)],
)
: [];
assert.deepEqual(collect(path.join(root, "app/html")), []);
assert.ok(
fs
.readFileSync(
path.join(root, "src/features/windowPopup/index.ts"),
"utf8",
)
.includes("openWindowPopup"),
);
assert.ok(
fs
.readFileSync(
path.join(root, "src/entities/menu-authority/@x/posting.ts"),
"utf8",
)
.includes("AuthorityButton"),
);
});
test("통수위험 영역 초기화는 GIS 편집 preview를 제거하지 않음", () => {
const pageSource = fs.readFileSync(
path.resolve(__dirname, "../app/(map-layout)/map/page.tsx"),
"utf8",
);
const areaLayerStart = pageSource.indexOf(
"const ensureWaterRiskAreaLayer = useCallback",
);
const pipeLayerStart = pageSource.indexOf(
"const ensureWaterRiskPipeLayer = useCallback",
areaLayerStart,
);
assert.ok(areaLayerStart >= 0 && pipeLayerStart > areaLayerStart);
const areaLayerBlock = pageSource.slice(areaLayerStart, pipeLayerStart);
assert.match(areaLayerBlock, /WATER_RISK_AREA_LAYER_ID/);
assert.doesNotMatch(areaLayerBlock, /GIS_EDIT_PREVIEW_LAYER_ID/);
assert.match(
areaLayerBlock,
/map\.addLayer\(waterRiskAreaLayerRef\.current\)/,
);
});
test("신규 관로 preview는 재표시되고 상세 패널 밖으로 최초 1회 focus", () => {
const pageSource = fs.readFileSync(
path.resolve(__dirname, "../app/(map-layout)/map/page.tsx"),
"utf8",
);
const previewLayerStart = pageSource.indexOf(
"const ensureGisEditPreviewLayer = useCallback",
);
const previewLayerEnd = pageSource.indexOf(
"const clearGisEditPreviewLayer = useCallback",
previewLayerStart,
);
const previewLayerBlock = pageSource.slice(
previewLayerStart,
previewLayerEnd,
);
assert.match(previewLayerBlock, /setVisible\?\.\(true\)/);
assert.match(
pageSource,
/gisEditAutoFocusedAddChangeIdRef\.current === focusedChange\.changeId/,
);
assert.match(
pageSource,
/focusGisEditChangeOnMap\(focusedChange\.changeId\)/,
);
});