From 49ace44c9b9992a0646de4799ee64deabd25b0b6 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Fri, 18 Sep 2026 16:35:21 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/scripts/audit-screen-inventory.mjs | 100 --- apps/sn/scripts/check-module-boundaries.cjs | 165 ----- apps/sn/scripts/run-refactor-tests.cjs | 18 - apps/sn/tests/api-transport.test.cjs | 189 ------ apps/sn/tests/api-url-contract.test.cjs | 146 ---- .../tests/dredging-module-boundary.test.cjs | 88 --- apps/sn/tests/dredging-registration.test.cjs | 163 ----- .../tests/dredging-response-adapters.test.cjs | 171 ----- apps/sn/tests/dredging-upload.test.cjs | 171 ----- apps/sn/tests/gis-upload-retry-guard.test.cjs | 130 ---- apps/sn/tests/load-typescript.cjs | 47 -- apps/sn/tests/map-layer-model.test.cjs | 166 ----- apps/sn/tests/module-boundary-policy.test.cjs | 76 --- .../sn/tests/sn-structure-extraction.test.cjs | 624 ------------------ apps/sn/tests/theme-contract.test.cjs | 87 --- 15 files changed, 2341 deletions(-) delete mode 100644 apps/sn/scripts/audit-screen-inventory.mjs delete mode 100644 apps/sn/scripts/check-module-boundaries.cjs delete mode 100644 apps/sn/scripts/run-refactor-tests.cjs delete mode 100644 apps/sn/tests/api-transport.test.cjs delete mode 100644 apps/sn/tests/api-url-contract.test.cjs delete mode 100644 apps/sn/tests/dredging-module-boundary.test.cjs delete mode 100644 apps/sn/tests/dredging-registration.test.cjs delete mode 100644 apps/sn/tests/dredging-response-adapters.test.cjs delete mode 100644 apps/sn/tests/dredging-upload.test.cjs delete mode 100644 apps/sn/tests/gis-upload-retry-guard.test.cjs delete mode 100644 apps/sn/tests/load-typescript.cjs delete mode 100644 apps/sn/tests/map-layer-model.test.cjs delete mode 100644 apps/sn/tests/module-boundary-policy.test.cjs delete mode 100644 apps/sn/tests/sn-structure-extraction.test.cjs delete mode 100644 apps/sn/tests/theme-contract.test.cjs diff --git a/apps/sn/scripts/audit-screen-inventory.mjs b/apps/sn/scripts/audit-screen-inventory.mjs deleted file mode 100644 index dd935efff..000000000 --- a/apps/sn/scripts/audit-screen-inventory.mjs +++ /dev/null @@ -1,100 +0,0 @@ -import { readdir, readFile } from "node:fs/promises"; -import path from "node:path"; - -const APP_DIR = path.resolve("apps/sn/app"); -const POPUP_DEFINITIONS = path.resolve( - "apps/sn/src/features/windowPopup/model/popupDefinitions.ts", -); -const POPUP_REGISTRY = path.resolve( - "apps/sn/src/features/windowPopup/registry.tsx", -); - -const routeAliases = new Set([ - "/map/agedPipePopup", - "/map2", -]); -const developmentRoutes = new Set([ - "/ii/fileupload", - "/ii/test", - "/oper/operStore", -]); -const infrastructureRoutes = new Set(["/", "/popup/[popupKey]"]); - -async function collectPageFiles(directory) { - const entries = await readdir(directory, { withFileTypes: true }); - const nested = await Promise.all( - entries.map(async (entry) => { - const absolutePath = path.join(directory, entry.name); - if (entry.isDirectory()) return collectPageFiles(absolutePath); - return entry.name === "page.tsx" ? [absolutePath] : []; - }), - ); - return nested.flat(); -} - -function toRoute(filePath) { - const relative = path.relative(APP_DIR, path.dirname(filePath)); - const segments = relative - .split(path.sep) - .filter(Boolean) - .filter((segment) => !/^\(.+\)$/.test(segment)); - return `/${segments.join("/")}`.replace(/\/$/, "") || "/"; -} - -function extractDefinitionKeys(source) { - const objectStart = source.indexOf("export const windowPopupDefinitions = {"); - const objectEnd = source.indexOf("} as const satisfies", objectStart); - return [...source.slice(objectStart, objectEnd).matchAll(/^ ([A-Za-z][A-Za-z0-9]*): \{/gm)] - .map((match) => match[1]); -} - -function extractRegistryKeys(source) { - const objectStart = source.indexOf("export const windowPopupRegistry = {"); - const objectEnd = source.indexOf("} satisfies", objectStart); - return [...source.slice(objectStart, objectEnd).matchAll(/^ ([A-Za-z][A-Za-z0-9]*):/gm)] - .map((match) => match[1]); -} - -const pageFiles = await collectPageFiles(APP_DIR); -const routes = pageFiles.map(toRoute).sort(); -const definitions = extractDefinitionKeys( - await readFile(POPUP_DEFINITIONS, "utf8"), -); -const registry = extractRegistryKeys(await readFile(POPUP_REGISTRY, "utf8")); - -const popupMissingInRegistry = definitions.filter((key) => !registry.includes(key)); -const registryMissingInDefinitions = registry.filter( - (key) => !definitions.includes(key), -); -const routeCategory = (route) => { - if (route === "/auth/login") return "authentication"; - if (routeAliases.has(route)) return "alias-or-preview"; - if (developmentRoutes.has(route)) return "development"; - if (infrastructureRoutes.has(route)) return "infrastructure"; - return "main-content"; -}; - -const routeCounts = routes.reduce((groups, route) => { - const category = routeCategory(route); - groups[category] ??= []; - groups[category].push(route); - return groups; -}, {}); -const report = { - routeFileCount: routes.length, - routeCounts: Object.fromEntries( - Object.entries(routeCounts).map(([key, value]) => [key, value.length]), - ), - routes: routes.map((route) => ({ route, category: routeCategory(route) })), - popupDefinitionCount: definitions.length, - popupRegistryCount: registry.length, - popupMissingInRegistry, - registryMissingInDefinitions, - popupKeys: definitions, -}; - -console.log(JSON.stringify(report, null, 2)); - -if (popupMissingInRegistry.length || registryMissingInDefinitions.length) { - process.exitCode = 1; -} diff --git a/apps/sn/scripts/check-module-boundaries.cjs b/apps/sn/scripts/check-module-boundaries.cjs deleted file mode 100644 index 96bd69ef1..000000000 --- a/apps/sn/scripts/check-module-boundaries.cjs +++ /dev/null @@ -1,165 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const ts = require("typescript"); - -// 2~4차가 아직 사용하는 기존 기반 helper만 파일·모듈 쌍으로 좁게 허용한다. -const LEGACY_DEPENDENCIES = { - "src/features/dredging/api/dredging-api.ts": ["@/components/lib/api"], - "src/features/dredging/api/photo-upload.ts": ["@/components/lib/utils"], - "src/features/dredging/lib/use-dredging-common-codes.ts": [ - "@/components/lib/common/apiHelpers", - ], -}; -const LEGACY_DIRECTORY = "app/(with-base-layout)/oper/dredging/lib/"; -const LEGACY_FILES = [ - "client", - "types", - "uuid", - "date", - "exif", - "registration-request", - "useDredgingCommonCodes", -].map((name) => `${LEGACY_DIRECTORY}${name}.ts`); - -/** 정적·동적 import, export-from, require의 문자열 모듈 참조를 수집한다. */ -function moduleSpecifiers(sourceFile) { - const modules = []; - function visit(node) { - if ( - (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && - node.moduleSpecifier && - ts.isStringLiteral(node.moduleSpecifier) - ) { - modules.push(node.moduleSpecifier.text); - } - if ( - ts.isCallExpression(node) && - node.arguments.length && - ts.isStringLiteral(node.arguments[0]) && - (node.expression.kind === ts.SyntaxKind.ImportKeyword || - (ts.isIdentifier(node.expression) && - node.expression.text === "require")) - ) { - modules.push(node.arguments[0].text); - } - ts.forEachChild(node, visit); - } - visit(sourceFile); - return modules; -} - -/** 새 feature의 모델·기반과 legacy 진입점에만 검증 범위를 한정한다. */ -function validateSource(relativePath, content) { - const sourceFile = ts.createSourceFile( - relativePath, - content, - ts.ScriptTarget.Latest, - true, - ); - const errors = []; - if (LEGACY_FILES.includes(relativePath)) { - if ( - !sourceFile.statements.length || - sourceFile.statements.some( - (statement) => - !ts.isExportDeclaration(statement) || - !statement.moduleSpecifier || - !ts.isStringLiteral(statement.moduleSpecifier) || - !statement.moduleSpecifier.text.startsWith("@/features/dredging/"), - ) - ) { - errors.push( - `${relativePath}: legacy 파일은 준설 feature 재수출만 허용합니다.`, - ); - } - } - for (const specifier of moduleSpecifiers(sourceFile)) { - const resolved = specifier.startsWith("@/") - ? `src/${specifier.slice(2)}` - : specifier.startsWith(".") - ? path.posix.normalize( - path.posix.join(path.posix.dirname(relativePath), specifier), - ) - : specifier; - if ( - /^src\/features\/(dredging|mapLayers|gisManagement|gisEdit)\//.test(relativePath) - ) { - if (specifier.startsWith("@/app/") || resolved.startsWith("app/")) { - errors.push(`${relativePath}: app 라우트 역참조 금지 (${specifier})`); - } - if ( - specifier.startsWith("@/components/") && - !(LEGACY_DEPENDENCIES[relativePath] ?? []).includes(specifier) - ) { - errors.push( - `${relativePath}: 승인되지 않은 legacy 기반 의존 (${specifier})`, - ); - } - if ( - relativePath.includes("/model/") && - /(?:^|\/)(?:api|ui|app|components)(?:\/|$)/.test(resolved) - ) { - errors.push( - `${relativePath}: model이 API/UI/라우트에 의존할 수 없습니다 (${specifier})`, - ); - } - } - if ( - relativePath === "src/shared/api/http-transport.ts" && - /^(?:src\/)?(?:features|entities|app|components)\//.test(resolved) - ) { - errors.push( - `${relativePath}: transport의 상위 계층 역참조 금지 (${specifier})`, - ); - } - } - return errors; -} - -/** 신규 feature만 재귀 검사한다. 타 앱·공용 패키지의 부채는 이 게이트에 혼합하지 않는다. */ -function sourceFiles(directory) { - return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const filename = path.join(directory, entry.name); - return entry.isDirectory() - ? sourceFiles(filename) - : /\.tsx?$/.test(entry.name) - ? [filename] - : []; - }); -} - -function checkWorkspace(root) { - const files = [ - ...LEGACY_FILES.map((relative) => path.join(root, relative)), - ...sourceFiles(path.join(root, "src/features/dredging")), - ...sourceFiles(path.join(root, "src/features/mapLayers")), - ...sourceFiles(path.join(root, "src/features/gisManagement")), - ...sourceFiles(path.join(root, "src/features/gisEdit")), - path.join(root, "src/shared/api/http-transport.ts"), - ]; - const errors = files.flatMap((filename) => - validateSource( - path.relative(root, filename).split(path.sep).join("/"), - fs.readFileSync(filename, "utf8"), - ), - ); - return { - checkedFiles: files.length, - legacyDependencies: LEGACY_DEPENDENCIES, - errors, - }; -} - -if (require.main === module) { - const result = checkWorkspace(path.resolve(__dirname, "..")); - console.log( - `SN module boundaries: ${result.checkedFiles} files, ${result.errors.length} violations`, - ); - console.log( - "Retained legacy bridges: dredging-api -> API utility; photo-upload -> TUS utility; common-code hook -> CMS helper", - ); - for (const error of result.errors) console.error(error); - if (result.errors.length) process.exitCode = 1; -} - -module.exports = { validateSource, checkWorkspace }; diff --git a/apps/sn/scripts/run-refactor-tests.cjs b/apps/sn/scripts/run-refactor-tests.cjs deleted file mode 100644 index a84e74aba..000000000 --- a/apps/sn/scripts/run-refactor-tests.cjs +++ /dev/null @@ -1,18 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); - -// Windows 셸의 glob 확장 여부와 무관하게 실제 테스트 파일을 Node runner에 전달한다. -const root = path.resolve(__dirname, ".."); -const files = fs - .readdirSync(path.join(root, "tests")) - .filter((name) => name.endsWith(".test.cjs")) - .sort() - .map((name) => path.join(root, "tests", name)); -if (!files.length) throw new Error("No refactor tests found"); -const result = spawnSync(process.execPath, ["--test", ...files], { - cwd: root, - stdio: "inherit", -}); -if (result.error) throw result.error; -process.exitCode = result.status ?? 1; diff --git a/apps/sn/tests/api-transport.test.cjs b/apps/sn/tests/api-transport.test.cjs deleted file mode 100644 index 2e637a721..000000000 --- a/apps/sn/tests/api-transport.test.cjs +++ /dev/null @@ -1,189 +0,0 @@ -const assert = require("node:assert/strict"); -const { test } = require("node:test"); -const { loadTypeScript } = require("./load-typescript.cjs"); - -// Legacy facade를 실제로 실행하고 인증 외부 의존성만 고정한다. -const transport = loadTypeScript("src/shared/api/http-transport.ts"); -const api = loadTypeScript("app/components/lib/api/index.ts", { - "@/shared/api/http-transport": transport, - "@/shared/config": { SITE_ID: "SN-TEST", CLAIM_NAME: "Authorization" }, - "@/entities/auth/lib/token-manager": { tokenManager: {} }, - "@common/auth": { getRemainingMs: () => 0 }, -}); - -for (const method of [ - "getData", - "postData", - "putData", - "deleteData", - "getRawJson", - "postRawJson", -]) { - test(`${method}: HTTP 500 JSON 오류를 status/body와 함께 reject`, async (t) => { - const body = { message: "저장 실패", data: null }; - t.mock.method(globalThis, "fetch", async () => - Response.json(body, { status: 500 }), - ); - await assert.rejects(api[method]("v1/test", {}), (error) => { - assert.equal(error.status, 500); - assert.equal(error.message, "저장 실패"); - assert.deepEqual(error.body, body); - return true; - }); - }); -} - -test("HTML 502 오류가 JSON 파싱 오류로 바뀌지 않는다", async (t) => { - t.mock.method( - globalThis, - "fetch", - async () => new Response("Bad Gateway", { status: 502 }), - ); - await assert.rejects(api.postRawJson("v1/test", {}), (error) => { - assert.equal(error.status, 502); - assert.equal(error.body, "Bad Gateway"); - assert.match(error.message, /502/); - assert.doesNotMatch(error.message, //); - return true; - }); -}); - -test("nested 오류 메시지와 비어 있는 오류 응답", async (t) => { - const fetchMock = t.mock.method(globalThis, "fetch", async () => - Response.json({ error: { message: "권한 없음" } }, { status: 403 }), - ); - await assert.rejects(api.deleteData("v1/test"), { - message: "권한 없음", - status: 403, - }); - fetchMock.mock.mockImplementation( - async () => new Response(null, { status: 503 }), - ); - await assert.rejects(api.postData("v1/test", {}), { - status: 503, - body: null, - }); -}); - -test("POST 성공 래퍼를 해제하고 URL·사이트 헤더·JSON body 유지", async (t) => { - const calls = []; - t.mock.method(globalThis, "fetch", async (url, init) => { - calls.push({ url, init }); - return Response.json({ status: 201, data: { id: 9 } }, { status: 201 }); - }); - assert.deepEqual(await api.postData("v1/test", { fcltMngNo: "P-1" }), { - id: 9, - }); - assert.equal(calls[0].url, "/sewerage-service/api/v1/test"); - assert.equal(calls[0].init.headers.get("X-Site-Id"), "SN-TEST"); - assert.equal(calls[0].init.headers.get("Content-Type"), "application/json"); - assert.deepEqual(JSON.parse(calls[0].init.body), { fcltMngNo: "P-1" }); - await api.postData("/attach-service/custom", {}); - assert.equal(calls[1].url, "/attach-service/custom"); -}); - -test("204 정상: JSON 함수 null, DELETE true", async (t) => { - t.mock.method( - globalThis, - "fetch", - async () => new Response(null, { status: 204 }), - ); - for (const method of [ - "getData", - "postData", - "putData", - "getRawJson", - "postRawJson", - ]) { - assert.equal(await api[method]("v1/test", {}), null); - } - assert.equal(await api.deleteData("v1/test"), true); -}); - -test("DELETE 200 성공 Response는 본문을 읽지 않은 채 반환", async (t) => { - t.mock.method(globalThis, "fetch", async () => - Response.json({ deleted: true }), - ); - const result = await api.deleteData("v1/test"); - assert.ok(result instanceof Response); - assert.equal(result.status, 200); - assert.equal(result.bodyUsed, false); - assert.deepEqual(await result.json(), { deleted: true }); -}); - -test("실패한 삭제 이후 성공 알림·두 번째 삭제를 실행하지 않는다", async (t) => { - let calls = 0; - let success = false; - t.mock.method(globalThis, "fetch", async () => { - calls++; - return new Response(null, { status: 500 }); - }); - await assert.rejects(async () => { - await api.deleteData("v1/first"); - await api.deleteData("v1/second"); - success = true; - }); - assert.equal(calls, 1); - assert.equal(success, false); -}); - -test("raw GeoJSON·필드 이름 및 PUT raw/wrapper 계약 유지", async (t) => { - const geojson = { - type: "FeatureCollection", - features: [], - fclt_mng_no: "P", - fcltMngNo: "Q", - }; - const fetchMock = t.mock.method(globalThis, "fetch", async () => - Response.json(geojson), - ); - assert.deepEqual(await api.getRawJson("/geoserver/test"), geojson); - assert.deepEqual(await api.putData("v1/test", {}), geojson); - fetchMock.mock.mockImplementation(async () => Response.json({ data: null })); - assert.equal(await api.putData("v1/test", {}), null); -}); - -test("정상 HTTP에서 HTML 본문은 성공 데이터로 취급하지 않는다", async (t) => { - t.mock.method( - globalThis, - "fetch", - async () => new Response("login"), - ); - await assert.rejects(api.getData("v1/test"), { - status: 200, - body: "login", - }); -}); - -test("Headers 객체, 사이트 헤더 및 AbortSignal을 보존하고 취소 예외를 유지", async (t) => { - const controller = new AbortController(); - const abort = new DOMException("cancelled", "AbortError"); - t.mock.method(globalThis, "fetch", async (_url, init) => { - assert.equal(init.signal, controller.signal); - assert.equal(init.headers.get("X-Custom"), "retained"); - assert.equal(init.headers.get("X-Site-Id"), "SN-TEST"); - throw abort; - }); - await assert.rejects( - api.getRawJson("v1/test", { - signal: controller.signal, - headers: new Headers({ "X-Custom": "retained" }), - }), - (error) => error === abort, - ); -}); - -test("401에서 주입한 인증 callback을 한 번 실행한 뒤 reject", async (t) => { - let count = 0; - const client = transport.createHttpTransport({ - baseUrl: "/api", - onUnauthorized: () => count++, - }); - t.mock.method( - globalThis, - "fetch", - async () => new Response(null, { status: 401 }), - ); - await assert.rejects(client.postData("test", {}), { status: 401 }); - assert.equal(count, 1); -}); diff --git a/apps/sn/tests/api-url-contract.test.cjs b/apps/sn/tests/api-url-contract.test.cjs deleted file mode 100644 index 260db97ff..000000000 --- a/apps/sn/tests/api-url-contract.test.cjs +++ /dev/null @@ -1,146 +0,0 @@ -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"); - -const root = path.resolve(__dirname, ".."); -// 제거된 서버 alias가 실제 SN 호출 코드·URL 주석에 다시 들어오지 않도록 검증한다. -const retiredSegments = [ - "gis-upload", - "ppl-analysis", - "flood-dngr-map", - "sbmrg-dngr-map", - "rain-anls", - "depth-map", - "grid-map", - "aged-status-by-ctpv", - "swage-prcs-zones", - "repair-histories", - "savewithfile", - "fcltppl", - "fcltmhl", - "fcltpump", - "fcltstofcbr", -]; - -function sourceFiles(directory) { - return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const filename = path.join(directory, entry.name); - return entry.isDirectory() - ? sourceFiles(filename) - : /\.(?:tsx?|jsx?)$/.test(entry.name) - ? [filename] - : []; - }); -} - -test("SN 실서비스와 business_components 호출은 단일 API 경로만 사용", () => { - const violations = []; - const sources = []; - for (const filename of [ - ...sourceFiles(path.join(root, "app")), - ...sourceFiles(path.join(root, "src")), - ...sourceFiles( - path.resolve(root, "../../packages/common/business_components/src"), - ), - ]) { - const source = fs.readFileSync(filename, "utf8"); - sources.push(source); - for (const segment of retiredSegments) { - if (source.includes(`/${segment}`)) - violations.push(`${path.relative(root, filename)}: ${segment}`); - } - } - assert.deepEqual(violations, []); - const allSources = sources.join("\n"); - for (const segment of [ - "pplAnalysis", - "floodDngrMap", - "sbmrgDngrMap", - "rainAnls", - "depthMap", - "gridMap", - "agedStatusByCtpv", - "saveWithFile", - "fcltPpl", - "fcltMhl", - "fcltPump", - "fcltStofcbr", - ]) { - assert.ok( - allSources.includes(`/${segment}`), - `canonical API missing: ${segment}`, - ); - } -}); - -test("침수 상세 API는 rainAnls/depthMap을 호출하고 기존 응답 필드를 보존", async () => { - const calls = []; - const client = loadTypeScript( - "app/(map-layout)/map/components/fldngHstry/fldngHstryClient.ts", - { - "@/components/lib/api": { - getData: async (url) => { - calls.push(url); - return url.includes("/rainAnls/") - ? { rain_bgng_tm: "2026-09-11 01:00", rain_tot: 0 } - : { - depthMaps: [{ fid: "depth" }], - waterLevelMaps: [{ fid: "level" }], - }; - }, - }, - }, - ); - const result = await client.loadFldngHstryDetail("F/1"); - assert.deepEqual(calls, [ - "v1/fldng/hstry/rainAnls/F%2F1", - "v1/fldng/hstry/depthMap/F%2F1", - ]); - assert.equal(result.rainAnalysis.rain_bgng_tm, "2026-09-11 01:00"); - assert.equal(result.rainAnalysis.rainBgngTm, "2026-09-11 01:00"); - assert.equal(result.rainAnalysis.rain_tot, 0); - assert.equal(result.rainAnalysis.rainTot, 0); - assert.deepEqual(result.fldngHstryDepthRows, [{ fid: "depth" }]); - assert.deepEqual(result.fldngHstryWtlvRows, [{ fid: "level" }]); - assert.equal(result.complete, true); -}); - -test("SN proxy는 gisUpload 단일 route이며 BE GET 목록·POST·하위 작업을 구분", () => { - const screen = fs.readFileSync( - path.join( - root, - "app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx", - ), - "utf8", - ); - const proxy = fs.readFileSync( - path.join(root, "app/api/gisUpload/route.ts"), - "utf8", - ); - assert.ok(screen.includes("v1/map/gisUploadHistories?")); - assert.ok(screen.includes('"/api/gisUpload"')); - assert.ok(!screen.includes('"/api/gis-upload"')); - assert.equal(fs.existsSync(path.join(root, "app/api/gis-upload/route.ts")), false); - assert.ok(!screen.includes("v1/map/gisUploads?")); - assert.ok(screen.includes("v1/map/gisUploads/swagePpl/downloadJobs?")); - assert.ok(proxy.includes('"/sewerage-service/api/v1/map/gisUploads"')); - assert.ok(proxy.includes("export async function POST")); -}); - -test("서비스 식별자·CMS 계약은 보존되고 fclt+suffix 동적 조합이 없다", () => { - const api = fs.readFileSync( - path.join(root, "app/components/lib/api/index.ts"), - "utf8", - ); - const helpers = fs.readFileSync( - path.join(root, "app/components/lib/common/apiHelpers.ts"), - "utf8", - ); - assert.ok(api.includes("/sewerage-service/api")); - assert.ok(api.includes("/cms-service")); - assert.ok(api.includes("/geoserver")); - assert.ok(helpers.includes("/api/v1/code/cache/")); - assert.doesNotMatch(helpers, /fclt\$\{|["']fclt["']\s*\+/); -}); diff --git a/apps/sn/tests/dredging-module-boundary.test.cjs b/apps/sn/tests/dredging-module-boundary.test.cjs deleted file mode 100644 index 061c71116..000000000 --- a/apps/sn/tests/dredging-module-boundary.test.cjs +++ /dev/null @@ -1,88 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const ts = require("typescript"); -const { test } = require("node:test"); -const { loadTypeScript } = require("./load-typescript.cjs"); -const root = path.resolve(__dirname, ".."); -const legacy = "app/(with-base-layout)/oper/dredging/lib/"; - -test("기존 준설 lib 7파일은 feature 구현을 재수출하며 중복 구현을 보유하지 않는다", () => { - for (const name of [ - "client", - "types", - "uuid", - "date", - "exif", - "registration-request", - "useDredgingCommonCodes", - ]) { - const source = ts.createSourceFile( - name, - fs.readFileSync(path.join(root, legacy, `${name}.ts`), "utf8"), - ts.ScriptTarget.Latest, - true, - ); - assert.ok(source.statements.length > 0); - for (const statement of source.statements) { - assert.ok( - ts.isExportDeclaration(statement), - `${name} contains an implementation`, - ); - assert.match(statement.moduleSpecifier.text, /^@\/features\/dredging\//); - } - } -}); - -test("feature 내부는 옛 준설 라우트·UI 모듈을 역참조하지 않는다", () => { - const featureRoot = path.join(root, "src/features/dredging"); - for (const entry of fs.readdirSync(featureRoot, { - recursive: true, - withFileTypes: true, - })) { - if (!entry.isFile() || !entry.name.endsWith(".ts")) continue; - const filename = path.join(entry.parentPath, entry.name); - const source = fs.readFileSync(filename, "utf8"); - assert.doesNotMatch( - source, - /(?:@\/app\/|oper\/dredging|DredgingRegisterModal|DredgingDetailPopupPage)/, - filename, - ); - } - const popup = fs.readFileSync( - path.join(root, "src/features/windowPopup/ui/dredgingPopupRecord.ts"), - "utf8", - ); - assert.match(popup, /from "@\/features\/dredging"/); -}); - -test("기존 date/uuid/EXIF 경로에서 이동된 구현을 호출할 수 있다", async () => { - const date = loadTypeScript(`${legacy}date.ts`); - assert.equal( - date.formatDredgingCapturedDate("2024-02-29T10:11:12"), - "2024-02-29", - ); - assert.equal(date.formatDredgingCapturedDate("2025-02-29"), null); - const uuid = loadTypeScript(`${legacy}uuid.ts`); - assert.match( - uuid.createDredgingClientUuid(), - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ); - const exif = loadTypeScript(`${legacy}exif.ts`); - assert.equal( - (await exif.readPhotoMetadata({ name: "test.png", type: "image/png" })) - .status, - "UNSUPPORTED", - ); - assert.equal( - ( - await exif.readPhotoMetadata({ - name: "test.jpg", - type: "image/jpeg", - arrayBuffer: async () => - Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]).buffer, - }) - ).status, - "MISSING_BOTH", - ); -}); diff --git a/apps/sn/tests/dredging-registration.test.cjs b/apps/sn/tests/dredging-registration.test.cjs deleted file mode 100644 index b037c6f8a..000000000 --- a/apps/sn/tests/dredging-registration.test.cjs +++ /dev/null @@ -1,163 +0,0 @@ -const assert = require("node:assert/strict"); -const { test } = require("node:test"); -const { loadTypeScript } = require("./load-typescript.cjs"); -const base = "app/(with-base-layout)/oper/dredging/lib/"; -const { createDredgingRegistrationSession } = loadTypeScript( - `${base}registration-request.ts`, -); - -/** 실제 wire 필드 외에 화면 전용 ID·관경·거리까지 포함해 구분을 검증한다. */ -const fixture = () => [ - { - localId: "ui-group", - registrationMode: "PHOTO", - dredgingDate: "2026-09-01", - locationSource: "EXIF", - location: { longitude: 127.1, latitude: 37.1 }, - pipe: { - fid: "F-1", - mngInstCd: "4173000", - fcltMngNo: "P-1", - pipeDiameterMm: 600, - distanceMeters: 1, - }, - photos: [ - { - key: "photo-1", - file: { name: "관로.jpg" }, - metadata: { - capturedAt: "2026-09-01T09:10:00", - location: { longitude: 127.1, latitude: 37.1 }, - }, - }, - ], - }, -]; -const attachments = () => new Map([["photo-1", { atflCd: "A", atflId: "B" }]]); - -test("HTTP 응답 유실 후 재시도는 동일 payload와 clientRequestId를 전송", async () => { - let issued = 0; - const session = createDredgingRegistrationSession( - () => `request-${++issued}`, - ); - const requests = []; - const client = loadTypeScript(`${base}client.ts`, { - "@/components/lib/api": { - postRawJson: async (url, body) => { - requests.push({ url, body }); - if (requests.length === 1) throw new TypeError("network lost"); - return { alreadyRegisteredCount: 1 }; - }, - }, - "@/components/lib/utils": {}, - "@/shared/lib/fetch-all-pages": {}, - }); - await assert.rejects( - client.saveDredgingRegistrations(session.prepare(fixture(), attachments())), - ); - await client.saveDredgingRegistrations( - session.prepare(fixture(), attachments()), - ); - assert.equal(issued, 1); - assert.deepEqual(requests[0], requests[1]); - assert.equal(requests[0].url, "v1/dredging/registrations"); - assert.deepEqual(requests[0].body.groups[0].photos[0], { - atflCd: "A", - atflId: "B", - originalFileName: "관로.jpg", - clientCapturedAt: "2026-09-01T09:10:00", - clientLongitude: 127.1, - clientLatitude: 37.1, - }); -}); - -test("날짜·위치·관로·사진 변경은 새 ID, 원래 입력 복원은 기존 ID", () => { - let issued = 0; - const session = createDredgingRegistrationSession( - () => `request-${++issued}`, - ); - const first = session.prepare(fixture(), attachments()).clientRequestId; - const edits = [ - (group) => { - group.dredgingDate = "2026-09-02"; - }, - (group) => { - group.location.latitude = 37.2; - }, - (group) => { - group.pipe.fid = "F-2"; - }, - (group) => { - group.photos[0].metadata.capturedAt = "2026-09-01T10:00:00"; - }, - ]; - for (const edit of edits) { - const groups = fixture(); - edit(groups[0]); - assert.notEqual( - session.prepare(groups, attachments()).clientRequestId, - first, - ); - } - const changedAttachments = attachments(); - changedAttachments.set("photo-1", { atflCd: "C", atflId: "D" }); - assert.notEqual( - session.prepare(fixture(), changedAttachments).clientRequestId, - first, - ); - assert.equal( - session.prepare(fixture(), attachments()).clientRequestId, - first, - ); -}); - -test("화면 전용 그룹 ID·관경·거리 변화는 중복방지 키를 바꾸지 않는다", () => { - let issued = 0; - const session = createDredgingRegistrationSession( - () => `request-${++issued}`, - ); - const first = session.prepare(fixture(), attachments()); - const groups = fixture(); - groups[0].localId = "new-ui"; - groups[0].pipe.pipeDiameterMm = 900; - groups[0].pipe.distanceMeters = 3; - assert.deepEqual(session.prepare(groups, attachments()), first); -}); - -test("새 등록 세션은 같은 입력이라도 새 UUID를 발급", () => { - const first = createDredgingRegistrationSession().prepare( - fixture(), - attachments(), - ).clientRequestId; - const second = createDredgingRegistrationSession().prepare( - fixture(), - attachments(), - ).clientRequestId; - assert.notEqual(first, second); - assert.match( - first, - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ); -}); - -test("첨부 객체의 속성 순서가 달라도 같은 등록 ID를 재사용", () => { - let issued = 0; - const session = createDredgingRegistrationSession(() => `request-${++issued}`); - const first = session.prepare(fixture(), attachments()); - const reordered = new Map([["photo-1", { atflId: "B", atflCd: "A" }]]); - assert.deepEqual(session.prepare(fixture(), reordered), first); - assert.equal(issued, 1); -}); - -test("실제 저장의 누락 첨부는 키 발급 전 거부하며 선행 모의 모드는 허용", () => { - let issued = 0; - const session = createDredgingRegistrationSession( - () => `request-${++issued}`, - ); - assert.throws(() => session.prepare(fixture(), new Map()), /첨부 식별자/); - assert.equal(issued, 0); - assert.equal( - session.prepare(fixture(), new Map(), false).clientRequestId, - "request-1", - ); -}); diff --git a/apps/sn/tests/dredging-response-adapters.test.cjs b/apps/sn/tests/dredging-response-adapters.test.cjs deleted file mode 100644 index 984e91a3b..000000000 --- a/apps/sn/tests/dredging-response-adapters.test.cjs +++ /dev/null @@ -1,171 +0,0 @@ -const assert = require("node:assert/strict"); -const { test } = require("node:test"); -const { loadTypeScript } = require("./load-typescript.cjs"); -const apiPath = "src/features/dredging/api/dredging-api.ts"; -const adapters = loadTypeScript( - "src/features/dredging/api/response-adapters.ts", -); - -test("준설 페이지 raw/wrapper의 page0·total0·items 참조와 필드를 보존", () => { - const page = { items: [], total: 0, page: 0, size: 2000, extra: "retained" }; - assert.equal(adapters.toDredgingRecordPage(page), page); - assert.equal(adapters.toDredgingRecordPage({ data: page }), page); - assert.deepEqual(page, { - items: [], - total: 0, - page: 0, - size: 2000, - extra: "retained", - }); -}); - -test("준설사진의 원본 촬영일시·첨부필드는 유지하고 기존 null fallback 적용", () => { - const photos = [ - { photoSn: 1, capturedAt: "2026-09-01T12:13:14", atflCd: "A", atflId: "B" }, - ]; - assert.equal(adapters.toDredgingPhotos(photos), photos); - assert.equal(adapters.toDredgingPhotos({ data: photos }), photos); - assert.deepEqual(adapters.toDredgingPhotos(null), []); - assert.deepEqual(adapters.toDredgingPhotos(undefined), []); -}); - -test("최근접관로의 식별자·위치·0값·nullable 기본값과 raw/wrapper를 유지", () => { - const row = { - locationKey: "photo-1", - selected: { - fid: "F-1", - mngInstCd: "4173000", - fcltMngNo: "P-1", - distanceMeters: 0, - snappedLongitude: 127, - snappedLatitude: 37, - }, - }; - const expected = { - fid: "F-1", - mngInstCd: "4173000", - fcltMngNo: "P-1", - pipeDiameterMm: 0, - pipeLengthM: 0, - pipeMaterial: "", - installationYear: null, - swtrmtZoneFid: null, - swtrmtZoneNm: "", - distanceMeters: 0, - snappedLongitude: 127, - snappedLatitude: 37, - isMock: false, - }; - assert.deepEqual( - adapters.toNearestPipeMatches([row]).get("photo-1"), - expected, - ); - assert.deepEqual( - adapters.toNearestPipeMatches({ data: [row] }).get("photo-1"), - expected, - ); - assert.equal(adapters.toNearestPipeMatches(null).size, 0); -}); - -test("최근접관로 부분 실패는 성공 Map을 반환하지 않고 중복 안내를 합친다", () => { - const rows = [ - { locationKey: "A", failureCode: "PIPE_NOT_FOUND" }, - { locationKey: "B", failureCode: "PIPE_NOT_FOUND" }, - ]; - assert.throws(() => adapters.toNearestPipeMatches(rows), { - message: - "50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요.", - }); - assert.throws( - () => - adapters.toNearestPipeMatches([ - { locationKey: "A", failureMessage: " 원인 안내 " }, - ]), - { message: "원인 안내" }, - ); -}); - -test("실제 API 목록/사진이 기존 URL과 query를 사용하고 adapter로 응답 반환", async () => { - const calls = []; - const page = { items: [{ id: "one" }], page: 0, size: 10, total: 1 }; - const api = loadTypeScript(apiPath, { - "@/components/lib/api": { - getRawJson: async (url) => { - calls.push(url); - return { data: url.includes("/photos?") ? [] : page }; - }, - }, - "@/shared/lib/fetch-all-pages": {}, - }); - assert.equal( - await api.fetchDredgingRecords({ - fromDate: "2026-09-01", - toDate: "2026-09-10", - ctpv: " 경기 ", - page: 0, - size: 10, - }), - page, - ); - assert.equal( - calls[0], - "v1/dredging/records?fromDate=2026-09-01&toDate=2026-09-10&page=0&size=10&ctpv=%EA%B2%BD%EA%B8%B0", - ); - assert.deepEqual(await api.fetchDredgingPhotos("job/1", "pipe 1"), []); - assert.equal(calls[1], "v1/dredging/records/job%2F1/photos?fid=pipe%201"); -}); - -test("실제 전체페이지 조회는 page0부터 순회하며 row·total을 유지", async () => { - const pages = []; - const api = loadTypeScript(apiPath, { - "@/components/lib/api": { - getRawJson: async (url) => { - const query = new URL(url, "http://fixture").searchParams; - const page = Number(query.get("page")); - pages.push(page); - return { - data: { - items: page === 0 ? [{ id: "A" }, { id: "B" }] : [{ id: "C" }], - total: 3, - page, - size: 2000, - }, - }; - }, - }, - "@/shared/lib/fetch-all-pages": loadTypeScript( - "src/shared/lib/fetch-all-pages.ts", - ), - }); - const result = await api.fetchAllDredgingRecords({ - fromDate: "2026-09-01", - toDate: "2026-09-10", - }); - assert.deepEqual(result, { - items: [{ id: "A" }, { id: "B" }, { id: "C" }], - total: 3, - }); - assert.deepEqual(pages, [0, 1]); -}); - -test("BE409 충돌은 자동 재시도하지 않고 기존 준설 실패 경로로 전달", async (t) => { - t.mock.method(console, "warn", () => {}); - let calls = 0; - const api = loadTypeScript(apiPath, { - "@/components/lib/api": { - postRawJson: async () => { - calls++; - throw Object.assign(new Error("conflict"), { - status: 409, - body: { error: { code: "DREDGING_REQUEST_CONFLICT" } }, - }); - }, - }, - "@/shared/lib/fetch-all-pages": {}, - }); - await assert.rejects( - api.saveDredgingRegistrations({ clientRequestId: "same-key", groups: [] }), - { message: "준설결과를 저장하지 못했습니다. 잠시 후 다시 시도해주세요." }, - ); - assert.equal(calls, 1); -}); diff --git a/apps/sn/tests/dredging-upload.test.cjs b/apps/sn/tests/dredging-upload.test.cjs deleted file mode 100644 index c697766ab..000000000 --- a/apps/sn/tests/dredging-upload.test.cjs +++ /dev/null @@ -1,171 +0,0 @@ -const assert = require("node:assert/strict"); -const { test } = require("node:test"); -const { loadTypeScript } = require("./load-typescript.cjs"); - -const { uploadPhotosWithReferences } = loadTypeScript( - "src/features/dredging/lib/photo-upload-queue.ts", -); -const photos = () => - ["one", "two", "three"].map((key) => ({ key, file: { name: `${key}.jpg` } })); - -test("부분 업로드 성공 참조는 실패 후에도 원본 Map에 남고 재시도는 실패 사진부터 진행", async () => { - const references = new Map(); - const calls = []; - let failSecond = true; - const upload = async (file) => { - calls.push(file.name); - if (file.name === "two.jpg" && failSecond) { - failSecond = false; - throw new Error("upload failed"); - } - return { atflCd: file.name, atflId: `id-${file.name}` }; - }; - await assert.rejects( - uploadPhotosWithReferences(photos(), references, upload), - ); - assert.deepEqual([...references.keys()], ["one"]); - assert.equal( - await uploadPhotosWithReferences(photos(), references, upload), - references, - ); - assert.deepEqual(calls, ["one.jpg", "two.jpg", "two.jpg", "three.jpg"]); - assert.deepEqual([...references.keys()], ["one", "two", "three"]); -}); - -test("기존 성공·빈 목록·같은 사진 키의 중복은 추가 업로드를 발생시키지 않는다", async () => { - let calls = 0; - const references = new Map([["one", { atflCd: "A", atflId: "B" }]]); - const upload = async () => { - calls++; - return { atflCd: "C", atflId: "D" }; - }; - assert.equal( - await uploadPhotosWithReferences([], references, upload), - references, - ); - await uploadPhotosWithReferences( - [photos()[0], photos()[1], photos()[1]], - references, - upload, - ); - assert.equal(calls, 1); - assert.deepEqual(references.get("one"), { atflCd: "A", atflId: "B" }); -}); - -/** 실제 브라우저 API 경계만 모의해 TUS callback과 타이머 정리를 검증한다. */ -function browserStubs(t) { - const originalWindow = globalThis.window; - const originalTransfer = globalThis.DataTransfer; - const timers = new Map(); - let timerId = 0; - globalThis.window = { - setTimeout(callback, delay) { - assert.equal(delay, 90_000); - timers.set(++timerId, callback); - return timerId; - }, - clearTimeout(id) { - timers.delete(id); - }, - }; - globalThis.DataTransfer = class { - constructor() { - this.files = []; - this.items = { add: (file) => this.files.push(file) }; - } - }; - t.after(() => { - if (originalWindow === undefined) delete globalThis.window; - else globalThis.window = originalWindow; - if (originalTransfer === undefined) delete globalThis.DataTransfer; - else globalThis.DataTransfer = originalTransfer; - }); - t.mock.method(console, "warn", () => {}); - return timers; -} - -test("실제 photo adapter의 TUS 비동기 오류는 즉시 reject하고 성공 참조·재시도를 보존", async (t) => { - const timers = browserStubs(t); - const calls = []; - let failSecond = true; - const { uploadDredgingPhotoDrafts } = loadTypeScript( - "src/features/dredging/api/photo-upload.ts", - { - "@/components/lib/utils": { - handleUpload(files, options) { - const name = files[0].name; - calls.push(name); - queueMicrotask(() => { - if (name === "two.jpg" && failSecond) { - failSecond = false; - options.handleError(new Error("tus failure")); - return; - } - options.handleResponse({ - getHeader: () => - JSON.stringify({ atflCd: name, atflId: `id-${name}` }), - }); - }); - }, - }, - }, - ); - const references = new Map(); - await assert.rejects( - uploadDredgingPhotoDrafts(photos(), references), - /준설사진을 업로드하지 못했습니다/, - ); - assert.deepEqual([...references.keys()], ["one"]); - assert.equal(timers.size, 0); - assert.equal( - await uploadDredgingPhotoDrafts(photos(), references), - references, - ); - assert.deepEqual(calls, ["one.jpg", "two.jpg", "two.jpg", "three.jpg"]); - assert.equal(timers.size, 0); -}); - -test("잘못된 X-File-Info는 성공 참조를 만들지 않고 타이머를 해제", async (t) => { - const timers = browserStubs(t); - const { uploadDredgingPhotoDrafts } = loadTypeScript( - "src/features/dredging/api/photo-upload.ts", - { - "@/components/lib/utils": { - handleUpload(_files, options) { - options.handleResponse({ getHeader: () => '{"atflCd":"A"}' }); - }, - }, - }, - ); - const references = new Map(); - await assert.rejects(uploadDredgingPhotoDrafts([photos()[0]], references)); - assert.equal(references.size, 0); - assert.equal(timers.size, 0); -}); - -test("FE 선행 모의 모드에서는 TUS를 호출하거나 참조를 추가하지 않는다", async () => { - const previous = process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED; - process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED = "false"; - try { - const { uploadDredgingPhotoDrafts } = loadTypeScript( - "src/features/dredging/api/photo-upload.ts", - { - "@/components/lib/utils": { - handleUpload() { - assert.fail("mock mode must not upload"); - }, - }, - }, - ); - const references = new Map(); - assert.equal( - await uploadDredgingPhotoDrafts(photos(), references), - references, - ); - assert.equal(references.size, 0); - } finally { - if (previous === undefined) - delete process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED; - else process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED = previous; - } -}); diff --git a/apps/sn/tests/gis-upload-retry-guard.test.cjs b/apps/sn/tests/gis-upload-retry-guard.test.cjs deleted file mode 100644 index 1c96295f6..000000000 --- a/apps/sn/tests/gis-upload-retry-guard.test.cjs +++ /dev/null @@ -1,130 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const vm = require("node:vm"); -const ts = require("typescript"); -const { test } = require("node:test"); -const { loadTypeScript } = require("./load-typescript.cjs"); -const model = loadTypeScript( - "src/features/gisManagement/model/upload-retry-guard.ts", -); -const partialBody = { - success: false, - status: 500, - code: "GIS_UPLOAD_HISTORY_SAVE_FAILED", - uploadId: "GUP-1", - msApplied: true, - retryAllowed: false, -}; - -test("부분 성공 body는 transport/Axios/wrapper 경계에서 구분되고 일반500은 제외", () => { - for (const error of [ - { body: partialBody }, - { response: { data: partialBody } }, - { body: { data: partialBody } }, - ]) { - assert.deepEqual(model.readGisPartialSuccess(error), { uploadId: "GUP-1" }); - } - for (const error of [ - new Error("500"), - { body: { ...partialBody, msApplied: false } }, - { body: { ...partialBody, retryAllowed: true } }, - { body: { code: partialBody.code } }, - ]) { - assert.equal(model.readGisPartialSuccess(error), null); - } -}); - -test("기관 전환·파일명과 무관하게 차단 유지, 같은/빈 downloadId는 해제하지 않음", () => { - const guard = model.createGisUploadRetryGuard(); - const block = guard.recordFailure("4173000", "D-1", { body: partialBody }); - assert.equal(guard.getBlock("other"), null); - assert.equal(guard.getBlock("4173000"), block); - assert.equal(guard.acceptFreshDownload("4173000", ""), null); - assert.equal(guard.acceptFreshDownload("4173000", "D-1"), null); - assert.equal(guard.acceptFreshDownload("other", "D-2"), null); - assert.equal(guard.getBlock("4173000"), block); - assert.match(model.getGisPartialSuccessNotice(block), /GUP-1/); - assert.match(model.getGisPartialSuccessNotice(block), /관리자/); -}); - -test("같은 기관의 새 현재본 downloadId만 새 작업을 허용하며 이전 운영확인ID를 반환", () => { - const guard = model.createGisUploadRetryGuard(); - guard.recordFailure("4173000", "D-1", { body: partialBody }); - const previous = guard.acceptFreshDownload("4173000", "D-2"); - assert.deepEqual(previous, { uploadId: "GUP-1", downloadId: "D-1" }); - assert.equal(guard.getBlock("4173000"), null); -}); - -test("일반 오류는 기존 차단을 해제하거나 새 차단을 만들지 않음", () => { - const guard = model.createGisUploadRetryGuard(); - assert.equal(guard.recordFailure("scope", "D-1", new Error("500")), null); - guard.recordFailure("scope", "D-1", { body: partialBody }); - assert.equal(guard.recordFailure("scope", "D-2", new Error("network")), null); - assert.equal(guard.getBlock("scope").uploadId, "GUP-1"); -}); - -/** 실제 JSX 컴포넌트의 업로드 handler만 추출해 외부 HTTP를 모의하고 재호출 차단을 검증한다. */ -test("실제 GIS 업로드 handler는 부분 성공 후 같은 세션 두 번째 POST를 보내지 않음", async () => { - const filename = path.resolve( - __dirname, - "../app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx", - ); - const source = fs.readFileSync(filename, "utf8"); - const ast = ts.createSourceFile( - filename, - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX, - ); - let initializer; - function visit(node) { - if ( - ts.isVariableDeclaration(node) && - node.name.getText(ast) === "handleUpload" - ) - initializer = node.initializer.getText(ast); - ts.forEachChild(node, visit); - } - visit(ast); - assert.ok(initializer); - const output = ts.transpileModule(`globalThis.run = ${initializer};`, { - compilerOptions: { - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.CommonJS, - }, - }).outputText; - const alerts = []; - let posts = 0; - const guard = model.createGisUploadRetryGuard(); - const context = { - console: { error() {} }, - FormData, - uploadRetryGuard: guard, - getGisPartialSuccessNotice: model.getGisPartialSuccessNotice, - selectedScopeKey: "scope", - latestDownloadId: "D-1", - selectedMngInstCd: "4173000", - isManagementSelected: true, - selectedFile: new Blob(["test"]), - facility: { layerId: "tb_ms_swage_ppl" }, - SCREEN_ID: "SN_MN010302", - FACILITY_TYPE_CODE: "PPL", - LAYER_ID: "tb_ms_swage_ppl", - alert: (message) => alerts.push(message), - setIsLoading() {}, - setNotice() {}, - postMutiPart: async () => { - posts++; - throw { body: partialBody }; - }, - }; - vm.runInNewContext(output, context); - await context.run(); - context.selectedFile = new Blob(["renamed-file"]); - await context.run(); - assert.equal(posts, 1); - assert.equal(alerts.length, 2); - assert.match(alerts[1], /GUP-1/); -}); diff --git a/apps/sn/tests/load-typescript.cjs b/apps/sn/tests/load-typescript.cjs deleted file mode 100644 index ee91cb7d5..000000000 --- a/apps/sn/tests/load-typescript.cjs +++ /dev/null @@ -1,47 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const vm = require("node:vm"); -const { createRequire } = require("node:module"); -const ts = require("typescript"); - -/** 설치된 TypeScript로 실제 소스를 메모리에서만 변환한다. 앱·.next 파일은 생성하지 않는다. */ -exports.loadTypeScript = function loadTypeScript(relativePath, mocks = {}) { - const filename = path.resolve(__dirname, "..", relativePath); - const nativeRequire = createRequire(filename); - const source = fs.readFileSync(filename, "utf8"); - const output = ts.transpileModule(source, { - compilerOptions: { - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.CommonJS, - jsx: ts.JsxEmit.ReactJSX, - esModuleInterop: true, - }, - fileName: filename, - }).outputText; - const module = { exports: {} }; - const localRequire = (specifier) => { - if (Object.hasOwn(mocks, specifier)) return mocks[specifier]; - if ( - specifier.startsWith(".") || - specifier.startsWith("@/features/dredging") - ) { - const dependency = path.resolve( - specifier.startsWith("@/") - ? path.resolve(__dirname, "../src") - : path.dirname(filename), - `${specifier.startsWith("@/") ? specifier.slice(2) : specifier}.ts`, - ); - if (fs.existsSync(dependency)) { - return loadTypeScript( - path.relative(path.resolve(__dirname, ".."), dependency), - mocks, - ); - } - } - return nativeRequire(specifier); - }; - vm.runInThisContext(`(function (require, module, exports) { ${output}\n})`, { - filename, - })(localRequire, module, module.exports); - return module.exports; -}; diff --git a/apps/sn/tests/map-layer-model.test.cjs b/apps/sn/tests/map-layer-model.test.cjs deleted file mode 100644 index 5878eeb6c..000000000 --- a/apps/sn/tests/map-layer-model.test.cjs +++ /dev/null @@ -1,166 +0,0 @@ -const assert = require("node:assert/strict"); -const { test } = require("node:test"); -const { loadTypeScript } = require("./load-typescript.cjs"); -const layer = loadTypeScript("src/features/mapLayers/lib/layer-info.ts"); -const { subscribeFlowDirectionMoveEnd } = loadTypeScript( - "src/features/mapLayers/lib/flow-direction-listener.ts", -); - -test("레이어 camel/snake 별칭과 기존 우선순위 유지", () => { - assert.equal( - layer.getLayerTypeName({ - lyrTyNm: " A ", - lyrTypeNm: "B", - lyr_type_nm: "C", - }), - "A", - ); - assert.equal( - layer.getLayerTypeName({ lyr_type_nm: " tb_il_swage_prcs_fclt " }), - "tb_il_swage_prcs_fclt", - ); - assert.equal( - layer.getLayerFilterSql({ filter_sql: "FCLT_CPCT >= 500" }), - "fclt_cpct>=500", - ); - assert.equal(layer.getLayerTypeName(null), ""); -}); - -test("처리시설 500 경계·동시체크·해제 모드 유지", () => { - const large = { - lyrTyNm: "tb_il_swage_prcs_fclt", - filtrSql: "FCLT_CPCT >= 500", - }; - const small = { lyr_type_nm: "tb_il_swage_prcs_fclt", lyrNm: "500톤 미만" }; - assert.equal(layer.getSwagePrcsFcltLayerMode([]), "none"); - assert.equal(layer.getSwagePrcsFcltLayerMode([{ data: large }]), "gte500"); - assert.equal(layer.getSwagePrcsFcltLayerMode([small]), "lt500"); - assert.equal(layer.getSwagePrcsFcltLayerMode([large, small]), "all"); - assert.equal( - layer.getSwagePrcsFcltCapacityKind({ ...large, lyrTyNm: "other" }), - null, - ); -}); - -test("중첩 유하방향·관측소 정규화는 원본을 변경하지 않고 계약 플래그 유지", () => { - const original = [ - { - lyrNm: "root", - children: [ - { lyrNm: "유하방향", minZoom: 0, clickPsblYn: "Y" }, - { lyr_type_nm: "tb_ms_rain_obsvtr_pstn_info", clickPsblYn: "Y" }, - { lyrTyNm: "other", clickPsblYn: "Y" }, - ], - }, - ]; - const before = JSON.stringify(original); - const normalized = layer.normalizeRainObservationLayerInteraction( - layer.normalizeFlowDirectionLayerInfos(original), - ); - const [flow, rain, other] = normalized[0].children; - assert.equal(flow.lyrTyNm, "tb_sn_swage_ppl_flow_direction"); - assert.equal(flow.srvcNm, "api"); - assert.equal(flow.cmncNm, "bbox"); - assert.equal(flow.minViewSize, 0); - assert.equal(flow.clickPsblYn, "N"); - assert.equal(rain.clickPsblYn, "N"); - assert.equal(other.clickPsblYn, "Y"); - assert.equal(JSON.stringify(original), before); -}); - -test("map/map2·줌·zIndex·BBOX 계산의 0과 빈값 구분", () => { - assert.equal( - layer.getFlowDirectionModeByPathname("/map2/"), - "PIPE_START_END", - ); - assert.equal(layer.getFlowDirectionModeByPathname("/map"), "PIPE_DEPTH"); - assert.equal(layer.getFiniteNumber(" "), null); - assert.equal(layer.getFiniteNumber("NaN"), null); - assert.equal(layer.getFiniteNumber(0), 0); - assert.equal(layer.getFlowDirectionMinZoom({}), 13); - assert.equal(layer.getFlowDirectionMinZoom({ minViewSize: 0 }), 0); - assert.equal(layer.getFlowDirectionZIndex({ drawOrdr: 0 }), 0); - assert.equal(layer.getFlowDirectionZIndex({}), 9998); - assert.deepEqual( - layer.expandFlowDirectionExtent([0, 10, 100, 110]), - [-35, -25, 135, 145], - ); - assert.equal( - layer.formatFlowDirectionBbox([1.23456, -4, 5, 6]), - "1.235,-4.000,5.000,6.000", - ); -}); - -test("관로 캐시 키는 같은 시설이라도 geometry가 다르면 구분", () => { - const feature = (coords) => ({ - getId: () => null, - get: (name) => (name === "fclt_mng_no" ? " P-1 " : undefined), - getGeometry: () => ({ getCoordinates: () => coords }), - }); - assert.equal( - layer.getFlowDirectionFeatureCacheKey( - feature([ - [0, 0], - [1, 1], - ]), - ), - "fclt:P-1:[[0,0],[1,1]]", - ); - assert.notEqual( - layer.getFlowDirectionFeatureCacheKey( - feature([ - [0, 0], - [1, 1], - ]), - ), - layer.getFlowDirectionFeatureCacheKey( - feature([ - [0, 0], - [2, 2], - ]), - ), - ); -}); - -test("moveend는 최신 체크·레이어를 읽고 구독 해제·재구독 뒤 중복 호출하지 않는다", () => { - const listeners = new Map(); - const map = { - on(event, handler) { - assert.equal(event, "moveend"); - const key = {}; - listeners.set(key, handler); - return key; - }, - }; - const unsubscribe = (key) => listeners.delete(key); - let state = { checked: false, layerInfo: { id: "A" } }; - const calls = []; - const move = () => [...listeners.values()].forEach((handler) => handler()); - const cleanup = subscribeFlowDirectionMoveEnd( - map, - () => state, - (info) => calls.push(info.id), - unsubscribe, - ); - move(); - state = { checked: true, layerInfo: { id: "B" } }; - move(); - state = { checked: true, layerInfo: null }; - move(); - assert.deepEqual(calls, ["B"]); - cleanup(); - state = { checked: true, layerInfo: { id: "C" } }; - move(); - assert.deepEqual(calls, ["B"]); - const cleanupNext = subscribeFlowDirectionMoveEnd( - map, - () => state, - (info) => calls.push(info.id), - unsubscribe, - ); - move(); - assert.deepEqual(calls, ["B", "C"]); - assert.equal(listeners.size, 1); - cleanupNext(); - assert.equal(listeners.size, 0); -}); diff --git a/apps/sn/tests/module-boundary-policy.test.cjs b/apps/sn/tests/module-boundary-policy.test.cjs deleted file mode 100644 index 2b7796d97..000000000 --- a/apps/sn/tests/module-boundary-policy.test.cjs +++ /dev/null @@ -1,76 +0,0 @@ -const assert = require("node:assert/strict"); -const path = require("node:path"); -const { test } = require("node:test"); -const { - validateSource, - checkWorkspace, -} = require("../scripts/check-module-boundaries.cjs"); - -test("현재 신규 feature·transport·legacy bridge가 구조 정책을 통과", () => { - const result = checkWorkspace(path.resolve(__dirname, "..")); - assert.ok(result.checkedFiles >= 20); - assert.deepEqual(result.errors, []); -}); - -test("model의 app·API 의존은 type-only여도 실패", () => { - assert.ok( - validateSource( - "src/features/dredging/model/new.ts", - 'import type { X } from "@/app/route";', - ).length, - ); - assert.ok( - validateSource( - "src/features/dredging/model/new.ts", - 'import type { X } from "../api/client";', - ).length, - ); -}); - -test("명시한 legacy helper만 기존 adapter에서 허용", () => { - assert.deepEqual( - validateSource( - "src/features/dredging/api/dredging-api.ts", - 'import { getData } from "@/components/lib/api";', - ), - [], - ); - assert.ok( - validateSource( - "src/features/dredging/lib/new.ts", - 'import { getData } from "@/components/lib/api";', - ).length, - ); - assert.ok( - validateSource( - "src/features/dredging/api/dredging-api.ts", - 'import { X } from "@/components/ui/new";', - ).length, - ); -}); - -test("legacy 진입점에 구현을 다시 추가하면 실패", () => { - const file = "app/(with-base-layout)/oper/dredging/lib/date.ts"; - assert.deepEqual( - validateSource(file, 'export * from "@/features/dredging/lib/date";'), - [], - ); - assert.ok( - validateSource(file, "export const formatDate = () => null;").length, - ); -}); - -test("transport 상위 계층 의존과 동적 app import도 실패", () => { - assert.ok( - validateSource( - "src/shared/api/http-transport.ts", - 'import { token } from "@/entities/auth";', - ).length, - ); - assert.ok( - validateSource( - "src/features/mapLayers/lib/new.ts", - 'const route = import("@/app/map");', - ).length, - ); -}); diff --git a/apps/sn/tests/sn-structure-extraction.test.cjs b/apps/sn/tests/sn-structure-extraction.test.cjs deleted file mode 100644 index bb13c446f..000000000 --- a/apps/sn/tests/sn-structure-extraction.test.cjs +++ /dev/null @@ -1,624 +0,0 @@ -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\)/, - ); -}); diff --git a/apps/sn/tests/theme-contract.test.cjs b/apps/sn/tests/theme-contract.test.cjs deleted file mode 100644 index 455a5741e..000000000 --- a/apps/sn/tests/theme-contract.test.cjs +++ /dev/null @@ -1,87 +0,0 @@ -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"); - -const root = path.resolve(__dirname, ".."); -const popupTheme = loadTypeScript( - "src/features/windowPopup/model/popupTheme.ts", -); - -test("SN의 5개 테마와 기존 dark 별칭을 WindowPopup 계약으로 제한", () => { - for (const theme of ["white", "blue", "green", "red", "black"]) { - assert.equal(popupTheme.resolveWindowPopupTheme(theme), theme); - } - assert.equal(popupTheme.resolveWindowPopupTheme("dark"), "black"); - assert.equal(popupTheme.resolveWindowPopupTheme("unknown"), undefined); -}); - -test("분석도구·SN 폼 컨트롤·페이징의 테마 계약 유지", () => { - const analysisMenu = fs.readFileSync( - path.join( - root, - "app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx", - ), - "utf8", - ); - const standardTableCss = fs.readFileSync( - path.resolve( - root, - "../../packages/common/business_components/src/ui/standard_data_table/standard-data-table.css", - ), - "utf8", - ); - const popupCss = fs.readFileSync( - path.join(root, "src/app/styles/components.css"), - "utf8", - ); - const procSearchBar = fs.readFileSync( - path.resolve( - root, - "../../packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx", - ), - "utf8", - ); - const buttonCss = fs.readFileSync( - path.resolve( - root, - "../../packages/common/components/src/ui/button/button.css", - ), - "utf8", - ); - - assert.doesNotMatch(analysisMenu, /bg-white/); - assert.doesNotMatch(analysisMenu, /ring-\[#0279FA\]/); - assert.match(analysisMenu, /bg-background text-label/); - assert.match(analysisMenu, /ring-primary/); - assert.match(standardTableCss, /background: var\(--background\) !important/); - assert.match(standardTableCss, /color: var\(--foreground\) !important/); - assert.doesNotMatch( - popupCss, - /\.standard-popup-layout button\[role="button"\]\.white/, - ); - assert.doesNotMatch(procSearchBar, /!bg-white/); - assert.match(procSearchBar, /!bg-form/); - assert.match(procSearchBar, /!text-foreground/); - assert.match( - popupCss, - /\[data-theme="black"\][\s\S]*button\[role="combobox"\][\s\S]*background-color: var\(--form\) !important/, - ); - assert.match( - popupCss, - /button\[role="combobox"\]:disabled[\s\S]*background-color: var\(--field\) !important/, - ); - assert.match( - popupCss, - /input\[type="text"\][\s\S]*textarea[\s\S]*background-color: var\(--form\) !important/, - ); - assert.match( - popupCss, - /input\[readonly\][\s\S]*textarea\[readonly\][\s\S]*background-color: var\(--field\) !important/, - ); - assert.match( - buttonCss, - /button\[role="button"\]\.white[\s\S]*bg-\[rgba\(255,255,255,1\)\]/, - ); -});