테스트 파일 삭제
This commit is contained in:
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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;
|
|
||||||
@@ -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("<html>Bad Gateway</html>", { status: 502 }),
|
|
||||||
);
|
|
||||||
await assert.rejects(api.postRawJson("v1/test", {}), (error) => {
|
|
||||||
assert.equal(error.status, 502);
|
|
||||||
assert.equal(error.body, "<html>Bad Gateway</html>");
|
|
||||||
assert.match(error.message, /502/);
|
|
||||||
assert.doesNotMatch(error.message, /<html>/);
|
|
||||||
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("<html>login</html>"),
|
|
||||||
);
|
|
||||||
await assert.rejects(api.getData("v1/test"), {
|
|
||||||
status: 200,
|
|
||||||
body: "<html>login</html>",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
@@ -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*\+/);
|
|
||||||
});
|
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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/);
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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\)/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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\)\]/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user