diff --git a/resources/tester/test_est_attachments.py b/resources/tester/test_est_attachments.py new file mode 100644 index 000000000..4e4f2d50b --- /dev/null +++ b/resources/tester/test_est_attachments.py @@ -0,0 +1,34 @@ +from test_est_rea_parity import run +import subprocess +from pathlib import Path + + +def test_attachment_validation_keeps_valid_data_and_rejects_malformed_content(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.attachments=[{id:'one',name:'test.bin',type:'application/octet-stream',size:4,data:'AAECAw=='}]; + const valid=EstModel.validate(p).attachments[0];let rejected=0; + for(const item of [{...valid,data:'AAECAw=A'},{...valid,size:5},{...valid,id:''},{...valid,name:''}]) { + const q=EstNative.empty();q.attachments=[item];try{EstModel.validate(q)}catch{rejected++} + } + const dup=EstNative.empty();dup.attachments=[valid,valid];try{EstModel.validate(dup)}catch{rejected++} + const empty=EstNative.empty();empty.attachments=[{id:'zero',name:'empty',type:'',size:0,data:''}]; + return {valid,rejected,empty:EstModel.validate(empty).attachments[0].size}; + })()""", + ) + assert result["valid"]["data"] == "AAECAw==" + assert result["rejected"] == 5 + assert result["empty"] == 0 + + +def test_estx_export_rejects_web_attachments_instead_of_dropping_them(): + script = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',attachments:[{}]});process.exit(1)}catch(e){process.stdout.write(e.message)}""" + result = subprocess.run( + ["node", "-e", script], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + check=True, + timeout=10, + ) + assert "JSON" in result.stdout.decode("utf-8") diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 8fe4b1262..21cc30870 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -1,4 +1,7 @@ ## Codex · EST 독립 테스트 앱 (2026-11-04) +- [x] 로컬 첨부파일 추가·내려받기·삭제·JSON 보관 구현 → 파일 바이트·저장·취소 시험. + - 자체 검증: 입력 검증·ESTX 누락 차단 시험 2개 통과, Edge에서 파일 추가·JSON 저장·재열기·바이트 내려받기·삭제 취소 확인. 업무 회귀 시험 82개 통과(openpyxl 경고 7건). + - [x] 선택 행 자릿수·사사오입·절사·올림식 일괄 설정 → 선택 범위·재계산·취소 시험. - 자체 검증: 업무·수량 시험 21개 통과, Edge에서 두 행 절사 2자리 설정·다른 행 유지·재계산·실행 취소 확인. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 010b8421f..11ac6f6a0 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -16,6 +16,8 @@ 저장은 이 폴더의 `data/`에 작업을 보관하고 `.estweb.json` 파일도 내려받습니다. 브라우저 임시 저장은 같은 탭에서 복원하는 용도입니다. JSON은 모든 웹 업무와 원본 압축 파일·바이너리를 보관하는 전체 저장 형식입니다. ESTX 수정 저장은 아래 지원 조건을 따릅니다. +작업모드의 `첨부파일`에서 파일을 추가·내려받기·삭제할 수 있습니다. 파일은 작업 JSON에 포함되며 이 앱의 저장 요청은 전체 256MB 이하입니다. 첨부파일이 있는 작업은 JSON으로 저장해야 하며, 첨부파일이 빠진 ESTX가 만들어지지 않도록 ESTX 저장을 차단합니다. + ## 추가 입출력·문서 기능 (2026-11-06) - **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드, 직접 작성 수식표·예정공정표·계약심사 입력·연차 수식·미확정 기성과 수량산출서의 문서 정보·산출 행·항목을 내보내고 다시 읽어 해당 자료를 교체합니다. 수량산출서는 `QDocuments`, `QLines`, `QItems`, 미확정 기성은 `ProgressDrafts`, `ProgressInputs` 시트를 함께 가져옵니다. 확정 기성의 계약 자료와 누계는 Excel 가져오기 시 유지합니다. 수식표·수량산출서·공정표의 내역 행 연결도 보존합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 산출근거의 내부 표지는 Excel이 열 수 있는 형식으로 바꿔 기록하고 다시 읽을 때 복원합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·원본 글자 서식·확정 기성 자료·당초 사본은 넣지 않으므로 전체 백업은 JSON을 사용합니다. diff --git a/tools/est-test/export-estx.cjs b/tools/est-test/export-estx.cjs index 4b994e233..26f49c2dc 100644 --- a/tools/est-test/export-estx.cjs +++ b/tools/est-test/export-estx.cjs @@ -135,6 +135,8 @@ function updateBlob(file, start, id, bytes) { function exportEstx(project) { if (!project.originalArchive) throw Error("원본 ESTX를 가져온 작업에서만 ESTX 수정 저장이 가능합니다."); + if (project.attachments?.length) + throw Error("웹 첨부파일은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요."); const original = Buffer.from(project.originalArchive, "base64"), base = importEstx(original), files = unzip(original); diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 47c9f6c03..9d1231275 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -20,6 +20,7 @@ + @@ -118,6 +119,7 @@ > + diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index 483f16711..c75da4923 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -485,6 +485,7 @@ const EstActions = (() => { EstExchange.install(); EstBusinessActions.install(); EstIssues.install(); + EstAttachments.install(); document.addEventListener("click", async (event) => { try { const marker = event.target.closest("[data-marker]"); diff --git a/tools/est-test/native-app.js b/tools/est-test/native-app.js index 23cfd2541..098bf79ad 100644 --- a/tools/est-test/native-app.js +++ b/tools/est-test/native-app.js @@ -35,6 +35,7 @@ const EstApp = (() => { resources: "동원 집계", schedule: "예정공정표", issues: "자료 오류 검색", + attachments: "첨부파일", drawing: "도면·문서", }; const esc = (value) => diff --git a/tools/est-test/native-attachments.js b/tools/est-test/native-attachments.js new file mode 100644 index 000000000..fce488c7c --- /dev/null +++ b/tools/est-test/native-attachments.js @@ -0,0 +1,61 @@ +const EstAttachments = (() => { + function render() { + const { state, esc, number, byId } = EstApp; + const rows = state.project.attachments || []; + byId("work-content").innerHTML = + `
첨부파일${rows.length}개

첨부파일은 이 작업의 JSON 저장에 포함됩니다.

${rows.map((row) => ``).join("")}
파일명크기 (바이트)작업
${esc(row.name)}${number(row.size)}
`; + } + function read(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(Error(`파일 읽기 실패: ${file.name}`)); + reader.onload = () => + resolve({ + id: crypto.randomUUID(), + name: file.name, + type: file.type || "", + size: file.size, + data: String(reader.result).split(",", 2)[1], + }); + reader.readAsDataURL(file); + }); + } + function install() { + document.addEventListener("change", async (event) => { + const input = event.target; + if (input.dataset.attachmentAdd === undefined || !input.files.length) return; + const { state } = EstApp, + key = state.documentKey, + revision = state.revision; + try { + const files = await Promise.all([...input.files].map(read)); + if (key !== state.documentKey || revision !== state.revision) + throw Error("작업이 변경되었습니다. 첨부파일을 다시 추가하세요."); + EstApp.change(() => (state.project.attachments ||= []).push(...files), false); + } catch (error) { + state.notice = error.message; + EstApp.render(); + } finally { + input.value = ""; + } + }); + document.addEventListener("click", (event) => { + const node = event.target.closest("[data-attachment-download],[data-attachment-delete]"); + if (!node) return; + const { state } = EstApp, + id = node.dataset.attachmentDownload ?? node.dataset.attachmentDelete; + const index = (state.project.attachments || []).findIndex((item) => item.id === id); + if (index < 0) return; + if (node.dataset.attachmentDelete !== undefined) + EstApp.change(() => state.project.attachments.splice(index, 1), false); + else { + const item = state.project.attachments[index], + text = atob(item.data), + bytes = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i); + EstApp.download(item.name, bytes, "application/octet-stream"); + } + }); + } + return { render, install }; +})(); diff --git a/tools/est-test/native-engine.js b/tools/est-test/native-engine.js index 27069f8e5..c2a83412a 100644 --- a/tools/est-test/native-engine.js +++ b/tools/est-test/native-engine.js @@ -343,6 +343,7 @@ const EstNative = (() => { progress: [], annual: [], drawings: [], + attachments: [], }; } return { calculate, price, groups, empty, allTables, sourceTables, normalize, materialBase }; diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js index 8eb05a9cf..1a5c05615 100644 --- a/tools/est-test/native-extra.js +++ b/tools/est-test/native-extra.js @@ -24,6 +24,10 @@ const EstExtra = (() => { EstIssues.render(); return; } + if (state.view === "attachments") { + EstAttachments.render(); + return; + } const { project, view } = state, { esc, number, byId } = EstApp; let html = ""; diff --git a/tools/est-test/native-model.js b/tools/est-test/native-model.js index 891e2ff69..fc0d6989d 100644 --- a/tools/est-test/native-model.js +++ b/tools/est-test/native-model.js @@ -348,10 +348,39 @@ const EstModel = (() => { if (!/^(current|\d+)$/.test(key) || typeof value !== "string" || value.length > 100000) throw Error("문서 본문 형식 오류"); } - for (const key of ["quantities", "progress", "annual", "drawings"]) { + for (const key of ["quantities", "progress", "annual", "drawings", "attachments"]) { if (project[key] === undefined) project[key] = []; if (!Array.isArray(project[key])) throw Error(`${key} 형식 오류`); } + const attachmentIds = new Set(); + for (const item of project.attachments) { + if ( + !item || + typeof item.id !== "string" || + !item.id || + attachmentIds.has(item.id) || + typeof item.name !== "string" || + !item.name || + item.name.length > 1000 || + typeof item.type !== "string" || + item.type.length > 1000 || + !Number.isSafeInteger(item.size) || + item.size < 0 || + typeof item.data !== "string" || + item.data.length % 4 || + /[^A-Za-z0-9+/=]/.test(item.data) + ) + throw Error("첨부파일 형식 오류"); + const body = item.data.replace(/={1,2}$/, ""), + padding = item.data.length - body.length; + if ( + body.includes("=") || + body.length % 4 === 1 || + (item.data.length / 4) * 3 - padding !== item.size + ) + throw Error("첨부파일 데이터 오류"); + attachmentIds.add(item.id); + } const ids = new Set(); for (const round of project.progress) { if ( diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 14c635b26..b367694d1 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -54,6 +54,7 @@ const allowed = new Set([ "native-actions.js", "native-business-actions.js", "native-issues.js", + "native-attachments.js", "native-extra.js", "native-settings.js", "native-report.js",