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\)/, ); });