import json import subprocess from pathlib import Path from test_est_rea_parity import run ROOT = Path(__file__).resolve().parents[2] def test_jdl_duplicate_sources_and_internal_codes_do_not_change_estimate_amounts(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'); const empty=JSON.parse(fs.readFileSync(0,'utf8')); const duplicate=structuredClone(empty);duplicate.name='duplicate'; duplicate.tables.StuffcDat=[{Code:'M1',Name:'first',TC:100},{Code:'M1',Name:'second',TC:200},{Code:'M1',Name:'third',TC:300}]; duplicate.tables.EstMainDat=[{Code:'M1',Name:'item',LinkIndex:5,GN:2,GU:200,GC:400,TC:400},{LinkIndex:54}]; const first=j.output(duplicate),loaded=j.read(first.bytes,empty); const collision=structuredClone(empty);collision.name='collision'; collision.tables.EstiMateDat=[{Code:'LITERAL2',Name:'existing',GC:999,TC:999}]; collision.tables.EstMainDat=[{Code:'fixed',Name:'fixed',CalcIndex:1,GN:0,GC:80,TC:80},{LinkIndex:54}]; const second=j.output(collision),files=a.unpack(second.bytes),parts=j.parse('mainpart.txt',files.get('mainpart.txt')); const sources=j.parse('w.txt',files.get('w.txt')),target=sources.find(r=>r.F_CODE===parts[0].F_CODE1); process.stdout.write(JSON.stringify({duplicateTotal:loaded.tables.EstMainDat.at(-1).TC, duplicateSources:j.parse('m.txt',a.unpack(first.bytes).get('m.txt')).length, warning:first.warnings.some(x=>x.includes('중복')),fixedPrice:target.F_MPRICE,sourceCount:sources.length}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT ) ) assert result == { "duplicateTotal": 400, "duplicateSources": 3, "warning": True, "fixedPrice": 80, "sourceCount": 2, } def test_jdl_duplicate_unit_codes_do_not_select_a_wrong_unit_group(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'); const empty=JSON.parse(fs.readFileSync(0,'utf8')),p=structuredClone(empty);p.name='units'; p.tables.StuffcDat=[{Code:'M',Name:'material',TC:100}]; p.tables.HeadTitleDat=[{Code:'U',Name:'first',GC:200},{Code:'U',Name:'second',GC:300}]; p.tables.HeadMainDat=[{Index:1,Code:'U'},{Index:0,Code:'M',LinkIndex:5,GN:2,GU:100,GC:200}]; p.tables.EstMainDat=[{Code:'U',Name:'item',LinkIndex:103,GN:1,GU:300,GC:300,TC:300},{LinkIndex:54}]; const out=j.output(p),files=a.unpack(out.bytes),loaded=j.read(out.bytes,empty); process.stdout.write(JSON.stringify({units:j.parse('unit.txt',files.get('unit.txt')).length, total:loaded.tables.EstMainDat.at(-1).TC,warning:out.warnings.some(x=>x.includes('중복'))}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT ) ) assert result == {"units": 0, "total": 300, "warning": True} def test_jdl_duplicate_unit_detail_groups_do_not_select_the_last_group(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'); const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='groups'; p.tables.StuffcDat=[{Code:'M',Name:'material',TC:100}]; p.tables.HeadTitleDat=[{Code:'U',Name:'unit',GC:200,TC:200}]; p.tables.HeadMainDat=[{Index:1,Code:'U'},{Index:0,Code:'M',LinkIndex:5,GN:2,GU:100,GC:200}, {Index:1,Code:'U'},{Index:0,Code:'M',LinkIndex:5,GN:1,GU:100,GC:100}]; p.tables.EstMainDat=[{Code:'U',Name:'item',LinkIndex:103,GN:1,GU:200,GC:200,TC:200},{LinkIndex:54}]; const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty); process.stdout.write(JSON.stringify({units:j.parse('unit.txt',files.get('unit.txt')).length, total:q.tables.EstMainDat.at(-1).TC}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT ) ) assert result == {"units": 0, "total": 200} def test_jdl_basic_tables_use_native_separators_encoding_and_preserve_direct_money(): p = run( "math", """(()=>{ const p=EstNative.empty();p.name='JDL test';p.options.SourceRefresh=false; p.tables.StuffcDat=[{PkID:1,Code:'M',Name:'material',InputTC:100,ValueIndex:4}]; p.tables.EstMainDat=[{PkID:1,Code:'M',Name:'material',LinkIndex:5,GN:10,GU:100}, {PkID:2,Code:'FIX',Name:'fixed',GN:0,GC:80,TC:80,CalcIndex:1}, {PkID:3,Code:'SKIP',Name:'excluded',GN:1,GC:999,TC:999,CalcIndex:3},{PkID:4,LinkIndex:54}]; p.tables.Wonga=[{Gubun:'G',Name:'material',BaseField:'GC'},{Gubun:'T',Name:'total',VSusic:'G'}]; return EstNative.calculate(p); })()""", ) script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'); const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);for(const name of Object.keys(empty.tables))empty.tables[name]=[]; const out=j.output(p),files=a.unpack(out.bytes),next=j.read(out.bytes,empty); const raw=files.get('mainpart.txt'); process.stdout.write(JSON.stringify({next,files:[...files.keys()],tabs:raw.includes(9),terminator:raw.includes(5),warnings:out.warnings}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(p, ensure_ascii=False).encode("utf-8"), cwd=ROOT, ).decode("utf-8") ) assert result["tabs"] and result["terminator"] assert {"Global.ini", "Main.txt", "mainpart.txt", "CostAccount.txt"}.issubset(result["files"]) assert result["next"]["tables"]["EstMainDat"][-1]["TC"] == 1080 assert result["next"]["tables"]["Wonga"][-1]["Cost"] == 1080 assert result["next"]["options"]["AutoCalc"] is False assert result["warnings"] def test_large_jdl_import_keeps_group_order_and_rejects_orphans_and_duplicates(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'); const empty=JSON.parse(fs.readFileSync(0,'utf8')); const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join('')); const n=3000,groups=Array.from({length:n},(_,i)=>({F_CODE:String(i),F_DES:'group '+i})); const parts=groups.slice().reverse().flatMap(g=>[{F_CODE:g.F_CODE,F_SEQ:'1',F_CODE1:'M',F_DES:'first',F_QQTY:1,F_MPRICE:3}, {F_CODE:g.F_CODE,F_SEQ:'2',F_CODE1:'M',F_DES:'second',F_QQTY:2,F_MPRICE:3}]); function archive(gs,ps){return a.pack(new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')], ['m.txt',table('m.txt',[{F_CODE:'M',F_DES:'material',F_MPRICE:3}])],['gongjong.txt',table('gongjong.txt',gs)],['mainpart.txt',table('mainpart.txt',ps)]]))} const result=j.read(archive(groups,parts),structuredClone(empty));let orphan=false,duplicate=false; try{j.read(archive(groups.slice(0,1),parts.slice(0,1)),structuredClone(empty))}catch{orphan=true} try{j.read(archive([groups[0],groups[0]],[]),structuredClone(empty))}catch{duplicate=true} const rows=result.tables.EstMainDat; process.stdout.write(JSON.stringify({count:rows.length,first:rows.slice(0,3).map(r=>r.Name),total:rows.at(-1).TC,orphan,duplicate}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT, timeout=30 ) ) assert result == { "count": 9001, "first": ["group 0", "first", "second"], "total": 27000, "orphan": True, "duplicate": True, } def test_unchanged_import_preserves_original_jdl_and_edited_work_is_not_silently_discarded(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),s=require('./tools/est-test/server.cjs'); const empty=JSON.parse(fs.readFileSync(0,'utf8')); const original=a.pack(new Map([['Global.ini',Buffer.from('[BASIC]\\r\\n')], ['Main.txt',Buffer.from('0\\tExample\\t02\\x05\\r\\n')],['extension.bin',Buffer.from([1,2,3,4])]])); const p=j.markOriginal(s.calculate(j.read(original,empty)));p.id='0123456789abcdef01234567'; const unchanged=j.output(s.calculate(structuredClone(p))); p.name='Changed';const changed=j.output(s.calculate(p)),files=a.unpack(changed.bytes); process.stdout.write(JSON.stringify({unchanged:original.equals(unchanged.bytes),changed:!original.equals(changed.bytes), name:files.get('Main.txt').toString().includes('Changed'),warning:changed.warnings.length>0}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project, ensure_ascii=False).encode("utf-8"), cwd=ROOT, ).decode("utf-8") ) assert result == {"unchanged": True, "changed": True, "name": True, "warning": True} def test_basic_jdl_unit_price_parts_import_with_recorded_costs_and_warn_for_special_parts(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs'); const empty=JSON.parse(fs.readFileSync(0,'utf8')); const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join('')); const common={F_CODE:'U1',F_CODE1:'M1',F_DES:'material',F_QQTY:2,F_MPRICE:100,F_MCOST:200,F_LOADBM:'T',F_SAVEBM:'T',F_SPROPER:'0',F_KWAN:'0',F_CLASS:'0'}; const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')], ['m.txt',table('m.txt',[{F_CODE:'M1',F_DES:'material',F_MPRICE:100}])], ['unit.txt',table('unit.txt',[{F_CODE:'U1',F_DES:'unit',F_MPRICE:300}])], ['unitpart.txt',table('unitpart.txt',[{...common,F_SEQ:'1'},{...common,F_SEQ:'2',F_SPROPER:'1'}])]]); const out=s.calculate(j.read(a.pack(files),empty));out.options.AutoCalc=true;out.options.SourceRefresh=true; const recalculated=s.calculate(structuredClone(out));process.stdout.write(JSON.stringify({rows:out.tables.HeadMainDat,warnings:out.importWarnings,title:recalculated.tables.HeadTitleDat[0]}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project, ensure_ascii=False).encode("utf-8"), cwd=ROOT, ).decode("utf-8") ) header, detail, remainder = result["rows"] assert header["Index"] == 1 and header["TC"] == 300 assert detail["LinkIndex"] == 5 and detail["GN"] == 2 and detail["GC"] == 200 assert remainder["CalcIndex"] == 1 and remainder["GC"] == 100 assert result["title"]["TC"] == 300 assert any("특수 구성" in warning for warning in result["warnings"]) def test_basic_jdl_unit_price_parts_export_and_reimport(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'); const p=JSON.parse(fs.readFileSync(0,'utf8'));p.name='unit'; p.tables.StuffcDat=[{Code:'M1',Name:'material',TC:100}]; p.tables.HeadTitleDat=[{Code:'U1',Name:'unit',GC:200,NC:0,JC:0,SC:0}]; p.tables.HeadMainDat=[{Index:1,Code:'U1',Name:'unit',GC:200,TC:200}, {Index:0,Code:'M1',Name:'material',LinkIndex:5,GN:2,GU:100,GC:200,TC:200}]; p.tables.EstMainDat=[{Code:'U1',Name:'unit',LinkIndex:103,GN:1,GU:200,GC:200,TC:200},{LinkIndex:54}]; const out=j.output(p),files=a.unpack(out.bytes),empty=structuredClone(p);for(const k of Object.keys(empty.tables))empty.tables[k]=[]; const q=j.read(out.bytes,empty);p.tables.HeadMainDat[1].CalcIndex=1; const special=j.output(p),specialFiles=a.unpack(special.bytes); process.stdout.write(JSON.stringify({unit:j.parse('unit.txt',files.get('unit.txt')), parts:j.parse('unitpart.txt',files.get('unitpart.txt')),rows:q.tables.HeadMainDat,main:q.tables.EstMainDat, specialUnits:j.parse('unit.txt',specialFiles.get('unit.txt')).length,specialWarnings:special.warnings.length}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project, ensure_ascii=False).encode("utf-8"), cwd=ROOT, ).decode("utf-8") ) assert len(result["unit"]) == len(result["parts"]) == 1 assert result["parts"][0]["F_QQTY"] == 2 and result["parts"][0]["F_MCOST"] == 200 assert result["rows"][1]["LinkIndex"] == 5 and result["rows"][1]["GC"] == 200 assert result["main"][1]["LinkIndex"] == 103 assert result["specialUnits"] == 0 and result["specialWarnings"] > 0 def test_jdl_unit_price_with_machine_detail_survives_round_trip(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'); const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='machine unit'; p.tables.MachTitleDat=[{Code:'J',Name:'machine',GC:100,NC:50,TC:150}]; p.tables.MachMainDat=[{Index:1,Code:'J',Name:'machine',JdlSummary:true}, {Index:0,CalcIndex:1,JdlSummary:true,Name:'fuel',GC:100,NC:50,TC:150}]; p.tables.HeadTitleDat=[{Code:'U',Name:'unit',GC:100,NC:50,TC:150}]; p.tables.HeadMainDat=[{Index:1,Code:'U'}, {Index:0,Code:'J',Name:'machine',LinkIndex:105,GN:1,GU:100,NU:50,GC:100,NC:50,TC:150}]; p.tables.EstMainDat=[{Code:'U',Name:'unit',LinkIndex:103,GN:2,GU:100,NU:50,GC:200,NC:100,TC:300},{LinkIndex:54}]; const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty); process.stdout.write(JSON.stringify({unit:j.parse('unitpart.txt',files.get('unitpart.txt')), rows:q.tables.HeadMainDat,machine:q.tables.MachMainDat,total:q.tables.EstMainDat.at(-1).TC}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT ) ) assert result["unit"][0]["F_CODE1"].startswith("J") assert result["rows"][1]["LinkIndex"] == 105 assert result["machine"][1]["Name"] == "fuel" assert result["total"] == 300 def test_jdl_unit_price_preserves_other_source_costs_as_fixed_resources(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'); const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='sources'; for(const [table,code] of [['SilJukDat','S'],['EstiMateDat','E'],['CostTitleDat','D'],['MachTitleDat','J']]) p.tables[table]=[{Code:code,Name:code,GC:10,TC:10}]; p.tables.HeadTitleDat=[{Code:'U',Name:'unit',GC:40,TC:40}]; p.tables.HeadMainDat=[{Index:1,Code:'U'},...[['S',21],['E',101],['D',104],['J',105]].map(([Code,LinkIndex])=> ({Index:0,Code,Name:Code,LinkIndex,GN:1,GU:10,GC:10,TC:10}))]; p.tables.EstMainDat=[{Code:'U',Name:'unit',LinkIndex:103,GN:1,GU:40,GC:40,TC:40},{LinkIndex:54}]; const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty); process.stdout.write(JSON.stringify({parts:j.parse('unitpart.txt',files.get('unitpart.txt')).length, links:q.tables.HeadMainDat.slice(1).map(r=>r.LinkIndex),total:q.tables.EstMainDat.at(-1).TC}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT ) ) assert result == {"parts": 4, "links": [101, 101, 104, 101], "total": 40} def test_jdl_cost_title_survives_direct_and_unit_price_links(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'); const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='cost'; p.tables.CostTitleDat=[{Code:'D',Name:'cost',GC:100,NC:20,JC:5,TC:125}]; p.tables.HeadTitleDat=[{Code:'U',Name:'unit',GC:100,NC:20,JC:5,TC:125}]; p.tables.HeadMainDat=[{Index:1,Code:'U'}, {Index:0,Code:'D',Name:'cost',LinkIndex:104,GN:1,GU:100,NU:20,JU:5,GC:100,NC:20,JC:5,TC:125}]; p.tables.EstMainDat=[{Code:'D',Name:'direct',LinkIndex:104,GN:2,GU:100,NU:20,JU:5,GC:200,NC:40,JC:10,TC:250}, {Code:'U',Name:'unit',LinkIndex:103,GN:3,GU:100,NU:20,JU:5,GC:300,NC:60,JC:15,TC:375},{LinkIndex:54}]; const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty); process.stdout.write(JSON.stringify({danga:j.parse('danga.txt',files.get('danga.txt')).length, costs:q.tables.CostMainDat,unit:q.tables.HeadMainDat[1].LinkIndex, direct:q.tables.EstMainDat[1].LinkIndex,total:q.tables.EstMainDat.at(-1).TC,warnings:out.warnings}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT ) ) assert result["danga"] == 1 assert [row["TC"] for row in result["costs"][1:]] == [100, 20, 5] assert result["unit"] == result["direct"] == 104 assert result["total"] == 625 assert result["warnings"] def test_jdl_duplicate_cost_titles_fall_back_without_wrong_link(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'); const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='duplicates'; p.tables.CostTitleDat=[{Code:'D',Name:'one',GC:10},{Code:'D',Name:'two',GC:20}]; p.tables.EstMainDat=[{Code:'D',Name:'item',LinkIndex:104,GN:2,GU:20,GC:40,TC:40},{LinkIndex:54}]; const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty); process.stdout.write(JSON.stringify({danga:j.parse('danga.txt',files.get('danga.txt')).length, kind:q.tables.EstMainDat[1].LinkIndex,total:q.tables.EstMainDat.at(-1).TC, warning:out.warnings.some(x=>x.includes('중복'))}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT ) ) assert result == {"danga": 0, "kind": 101, "total": 40, "warning": True} def test_jdl_nested_units_preserve_detail_or_warn_on_forward_reference(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'); const blank=JSON.parse(fs.readFileSync(0,'utf8')); function convert(order,self=false,cycle=false){const p=structuredClone(blank);p.name='nested'; p.tables.StuffcDat=[{Code:'M',Name:'material',TC:10}]; const titles={A:{Code:'A',Name:'parent',GC:60,TC:60},B:{Code:'B',Name:'child',GC:20,TC:20}}; p.tables.HeadTitleDat=order.map(code=>titles[code]); p.tables.HeadMainDat=[{Index:1,Code:'B'}, {Index:0,Code:cycle?'A':'M',Name:'material',LinkIndex:cycle?103:5,GN:2,GU:10,GC:20}, {Index:1,Code:'A'}, {Index:0,Code:self?'A':'B',Name:'child',LinkIndex:103,GN:3,GU:20,GC:60}]; p.tables.EstMainDat=[{Code:'A',Name:'parent',LinkIndex:103,GN:2,GU:60,GC:120,TC:120},{LinkIndex:54}]; const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,structuredClone(blank)); return {parts:j.parse('unitpart.txt',files.get('unitpart.txt')),total:q.tables.EstMainDat.at(-1).TC, units:j.parse('unit.txt',files.get('unit.txt')),warnings:out.warnings};} process.stdout.write(JSON.stringify({ordered:convert(['B','A']),forward:convert(['A','B']), self:convert(['A','B'],true),cycle:convert(['A','B'],false,true)}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT ) ) assert result["ordered"]["total"] == result["forward"]["total"] == 120 assert result["ordered"]["parts"][-1]["F_CODE1"].startswith("U") assert result["forward"]["parts"][-1]["F_CODE1"].startswith("U") assert not any("하위 일위대가" in warning for warning in result["forward"]["warnings"]) assert len(result["self"]["units"]) == 1 assert result["cycle"]["total"] == 120 assert any("하위 일위대가" in warning for warning in result["cycle"]["warnings"]) def test_jdl_nested_unit_order_handles_deep_dependencies(): script = """const {orderUnits}=require('./tools/est-test/jdl-unit-order.cjs'),n=15000; const titles=Array.from({length:n},(_,i)=>({Code:String(i)})),groups=new Map(); for(let i=0;iBuffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join('')); const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')], ['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:100.25,F_LPRICE:50.12,F_EPRICE:25.03}])], ['jpart.txt',table('jpart.txt',[{F_CODE:'J1',F_SEQ:'1',F_CODE1:'M1'}])], ['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])], ['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'J1',F_DES:'machine',F_QQTY:2,F_MPRICE:100,F_LPRICE:50,F_EPRICE:25}])]]); const original=a.pack(files),p=j.markOriginal(s.calculate(j.read(original,empty))); const same=original.equals(j.output(s.calculate(structuredClone(p))).bytes); p.tables.MachMainDat[1].GC=120.25; p.options.AutoCalc=true;p.options.SourceRefresh=true; const q=s.calculate(JSON.parse(JSON.stringify(p))); process.stdout.write(JSON.stringify({same,rows:q.tables.MachMainDat,title:q.tables.MachTitleDat[0], estimate:q.tables.EstMainDat[1],warnings:p.importWarnings,errors:q.calculation.errors}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project, ensure_ascii=False).encode("utf-8"), cwd=ROOT, ).decode("utf-8") ) assert result["same"] assert result["rows"][1]["CalcIndex"] == 1 assert abs(result["rows"][1]["TC"] - 195.4) < 1e-8 assert abs(result["title"]["TC"] - 195.4) < 1e-8 assert result["estimate"]["TC"] == 390 assert result["warnings"] and not result["errors"] def test_jdl_machine_simple_detail_is_editable_and_mismatches_remain_summary(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs'); const empty=JSON.parse(fs.readFileSync(0,'utf8')); const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join('')); const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')], ['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:100,F_LPRICE:20,F_EPRICE:10}, {F_CODE:'J2',F_DES:'mismatch',F_MPRICE:50},{F_CODE:'J3',F_DES:'quantity',F_MPRICE:50}])], ['jpart.txt',table('jpart.txt',[{F_CODE:'J1',F_SEQ:'1',F_CODE1:'M1',F_DES:'fuel',F_QQTY:1,F_MPRICE:60,F_EPRICE:10}, {F_CODE:'J1',F_SEQ:'2',F_CODE1:'L1',F_DES:'operator',F_QQTY:1,F_MPRICE:40,F_LPRICE:20}, {F_CODE:'J2',F_SEQ:'1',F_DES:'incomplete',F_QQTY:1,F_MPRICE:10}, {F_CODE:'J3',F_SEQ:'1',F_DES:'unknown quantity semantics',F_QQTY:2,F_MPRICE:50}])]]); const p=j.read(a.pack(files),empty);p.options.AutoCalc=true;p.options.SourceRefresh=true; p.tables.MachMainDat[1].GC=70;const q=s.calculate(p); process.stdout.write(JSON.stringify({rows:q.tables.MachMainDat,titles:q.tables.MachTitleDat,errors:q.calculation.errors,warnings:p.importWarnings}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project, ensure_ascii=False).encode("utf-8"), cwd=ROOT, ).decode("utf-8") ) assert [row["Name"] for row in result["rows"][:3]] == ["machine", "fuel", "operator"] assert result["titles"][0]["TC"] == 140 assert result["titles"][1]["TC"] == 50 assert result["titles"][2]["TC"] == 50 assert "상세 산식 미변환" in result["rows"][-1]["Name"] assert result["warnings"] and not result["errors"] def test_jdl_machine_quantity_detail_uses_consistent_amounts_only(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs'); const empty=JSON.parse(fs.readFileSync(0,'utf8')),blank=structuredClone(empty); const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join('')); const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')], ['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:100,F_LPRICE:50}, {F_CODE:'J2',F_DES:'ambiguous',F_MPRICE:50},{F_CODE:'J3',F_DES:'both match',F_MPRICE:150}])], ['jpart.txt',table('jpart.txt',[{F_CODE:'J1',F_SEQ:'1',F_DES:'part',F_QQTY:2,F_MPRICE:50,F_LPRICE:25}, {F_CODE:'J2',F_SEQ:'1',F_DES:'uncertain',F_QQTY:2,F_MPRICE:50}, {F_CODE:'J3',F_SEQ:'1',F_DES:'first',F_QQTY:2,F_MPRICE:50}, {F_CODE:'J3',F_SEQ:'2',F_DES:'second',F_QQTY:0.5,F_MPRICE:100}])], ['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])], ['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'J1',F_DES:'machine',F_QQTY:2,F_MPRICE:100,F_LPRICE:50}])]]); const p=j.read(a.pack(files),empty),before=structuredClone(p.tables.MachMainDat); p.options.AutoCalc=true;p.options.SourceRefresh=true;p.tables.MachMainDat[1].GC=110;const q=s.calculate(p); const warnings=p.importWarnings.slice(),out=j.output(q),back=j.read(out.bytes,blank); process.stdout.write(JSON.stringify({before,rows:q.tables.MachMainDat,titles:q.tables.MachTitleDat, saved:back.tables.MachMainDat,total:back.tables.EstMainDat.at(-1).TC,warnings,errors:q.calculation.errors}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT ) ) assert result["before"][1]["GC"] == 100 assert result["before"][1]["NC"] == 50 assert result["before"][1]["JdlSourceQuantity"] == 2 assert result["titles"][0]["TC"] == 160 assert result["saved"][1]["Name"] == "part" assert result["saved"][1]["GC"] == 110 assert result["saved"][1]["NC"] == 50 assert result["total"] == 320 assert "상세 산식 미변환" in result["rows"][-3]["Name"] assert "상세 산식 미변환" in result["rows"][-1]["Name"] assert any("수량×단가" in warning for warning in result["warnings"]) assert not result["errors"] def test_jdl_machine_export_rejects_ambiguous_or_inconsistent_detail_groups(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'); const p=JSON.parse(fs.readFileSync(0,'utf8'));p.name='machine'; p.tables.MachTitleDat=[{Code:'J1',Name:'duplicate',GC:10},{Code:'J2',Name:'mismatch',GC:20}]; p.tables.MachMainDat=[{Index:1,Code:'J1'},{Index:0,CalcIndex:1,JdlSummary:true,GC:10}, {Index:1,Code:'J1'},{Index:0,CalcIndex:1,JdlSummary:true,GC:10}, {Index:1,Code:'J2'},{Index:0,CalcIndex:1,JdlSummary:true,GC:5}]; const out=j.output(p),files=a.unpack(out.bytes); process.stdout.write(JSON.stringify({units:j.parse('junit.txt',files.get('junit.txt')).length, parts:j.parse('jpart.txt',files.get('jpart.txt')).length}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT ) ) assert result == {"units": 0, "parts": 0} def test_jdl_cost_saved_amount_calculates_and_can_be_edited(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs'); const empty=JSON.parse(fs.readFileSync(0,'utf8')); const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join('')); const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')], ['danga.txt',table('danga.txt',[{F_CODE:'D1',F_DES:'cost',F_MPRICE:100.25,F_LPRICE:50.12,F_EPRICE:25.03,F_MEMO:'source'},{F_CODE:'D2',F_EPRICE:0.000000000001}])], ['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])], ['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'D1',F_DES:'cost',F_QQTY:2,F_MPRICE:100,F_LPRICE:50,F_EPRICE:25}])]]); const p=j.read(a.pack(files),empty);p.tables.CostMainDat[1].LineData='120.25 = \\x12100.25\\x12'; p.options.AutoCalc=true;p.options.SourceRefresh=true;const q=s.calculate(JSON.parse(JSON.stringify(p))); process.stdout.write(JSON.stringify({title:q.tables.CostTitleDat[0],tiny:q.tables.CostTitleDat[1],estimate:q.tables.EstMainDat[1],errors:q.calculation.errors,warnings:p.importWarnings}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project, ensure_ascii=False).encode("utf-8"), cwd=ROOT, ).decode("utf-8") ) assert abs(result["title"]["TC"] - 195.4) < 1e-8 assert result["tiny"]["JC"] == 0.000000000001 assert result["estimate"]["TC"] == 390 assert result["warnings"] and not result["errors"] def test_jdl_complete_unit_preserves_difference_from_saved_title(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs'); const empty=JSON.parse(fs.readFileSync(0,'utf8')); const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join('')); const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')], ['m.txt',table('m.txt',[{F_CODE:'M1',F_MPRICE:100}])], ['unit.txt',table('unit.txt',[{F_CODE:'U1',F_MPRICE:205.25},{F_CODE:'U2',F_LPRICE:30.12}])], ['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_CODE1:'M1',F_QQTY:2,F_MPRICE:100,F_MCOST:200}])]]); const p=j.read(a.pack(files),empty);p.options.AutoCalc=true;p.options.SourceRefresh=true; const q=s.calculate(p);process.stdout.write(JSON.stringify({rows:q.tables.HeadMainDat,titles:q.tables.HeadTitleDat}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project, ensure_ascii=False).encode("utf-8"), cwd=ROOT, ).decode("utf-8") ) assert result["rows"][2]["GC"] == 5.25 assert [row["TC"] for row in result["titles"]] == [205.25, 30.12] def test_jdl_fixed_unit_detail_and_saved_difference_survive_edit_and_export(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs'); const empty=JSON.parse(fs.readFileSync(0,'utf8')),blank=structuredClone(empty); const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join('')); const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')], ['m.txt',table('m.txt',[{F_CODE:'M1',F_MPRICE:100}])], ['unit.txt',table('unit.txt',[{F_CODE:'U1',F_DES:'unit',F_MPRICE:205.25}])], ['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_SEQ:'1',F_CODE1:'M1',F_DES:'material',F_QQTY:2,F_MPRICE:100,F_MCOST:200}])], ['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])], ['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'U1',F_DES:'unit',F_QQTY:1,F_MPRICE:205.25}])]]); const p=j.read(a.pack(files),empty);p.options.AutoCalc=true;p.options.SourceRefresh=true; p.tables.HeadMainDat[2].GC=7;const q=s.calculate(p),out=j.output(q),entries=a.unpack(out.bytes),back=j.read(out.bytes,blank); process.stdout.write(JSON.stringify({parts:j.parse('unitpart.txt',entries.get('unitpart.txt')), rows:back.tables.HeadMainDat,total:back.tables.EstMainDat.at(-1).TC,errors:q.calculation.errors}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT ) ) assert len(result["parts"]) == 2 assert [row["GC"] for row in result["rows"][1:3]] == [200, 7] assert result["total"] == 207 assert not result["errors"] def test_jdl_unit_inconsistent_detail_keeps_recorded_amount_without_double_counting(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs'); const empty=JSON.parse(fs.readFileSync(0,'utf8')); const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join('')); const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')], ['m.txt',table('m.txt',[{F_CODE:'M1',F_MPRICE:100}])], ['unit.txt',table('unit.txt',[{F_CODE:'U1',F_MPRICE:200}])], ['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_CODE1:'M1',F_QQTY:2,F_MPRICE:100,F_MCOST:0}])]]); const p=j.read(a.pack(files),empty);p.options.AutoCalc=true;p.options.SourceRefresh=true; const q=s.calculate(p);process.stdout.write(JSON.stringify({rows:q.tables.HeadMainDat,title:q.tables.HeadTitleDat[0],warnings:p.importWarnings}));""" result = json.loads( subprocess.check_output( ["node", "-e", script], input=json.dumps(project, ensure_ascii=False).encode("utf-8"), cwd=ROOT, ).decode("utf-8") ) assert result["rows"][1]["CalcIndex"] == 1 assert result["rows"][2]["GC"] == 200 assert result["title"]["TC"] == 200 assert any("고정" in warning for warning in result["warnings"]) def test_edge_jdl_machine_amount_edit(): from playwright.sync_api import sync_playwright import pytest script = """const a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'); const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join('')); process.stdout.write(a.pack(new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')], ['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:100,F_LPRICE:50,F_EPRICE:25}])], ['danga.txt',table('danga.txt',[{F_CODE:'D1',F_DES:'cost',F_MPRICE:100,F_LPRICE:50,F_EPRICE:25}])]])));""" data = subprocess.check_output(["node", "-e", script], cwd=ROOT) with sync_playwright() as p: browser = p.chromium.launch(channel="msedge", headless=True) page = browser.new_page() try: page.goto("http://100.65.141.15:5175", timeout=5000) except Exception: browser.close() pytest.skip("EST server unavailable") page.on("dialog", lambda dialog: dialog.accept()) page.wait_for_function("() => Boolean(EstApp.state.project)") page.locator("#jdl-file").set_input_files( {"name": "sample.jdl", "mimeType": "application/zip", "buffer": data} ) page.wait_for_function("() => EstApp.state.project.tables.MachMainDat.length === 2") assert page.locator("#dialog").is_visible() assert "상세 산식" in page.locator("#dialog-content").inner_text() page.locator("#dialog").evaluate("(dialog) => dialog.close()") page.evaluate("""() => { EstApp.state.project.options.AutoCalc=true; EstApp.navigate('MachMainDat','J1'); }""") amount = page.locator('[data-table="MachMainDat"][data-row="1"][data-field="GC"]') amount.fill("120") amount.press("Tab") page.wait_for_function("() => EstApp.state.project.tables.MachTitleDat[0].TC === 195") assert page.evaluate("EstApp.state.project.tables.MachMainDat[1].TC") == 195 page.evaluate("EstApp.navigate('CostMainDat','D1')") formula = page.locator('[data-table="CostMainDat"][data-row="1"][data-field="LineData"]') formula.fill(formula.input_value().replace("100", "130.25", 1)) formula.press("Tab") page.wait_for_function("() => EstApp.state.project.tables.CostTitleDat[0].TC === 205.25") browser.close()