Files
Aislo/resources/tester/test_est_jdl.py
T

605 lines
38 KiB
Python

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;i<n-1;i++)groups.set(String(i),[{LinkIndex:103,Code:String(i+1)}]);
const result=orderUnits(titles,groups);
process.stdout.write(JSON.stringify([result.length,result[0].Code,result.at(-1).Code]));"""
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT, timeout=30))
assert result == [15000, "14999", "0"]
def test_jdl_machine_saved_amount_is_editable_and_survives_recalculation():
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.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()