625 lines
17 KiB
JavaScript
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\)/,
|
|
);
|
|
});
|