Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into sub_laptop_1

This commit is contained in:
2026-11-09 20:10:05 +09:00
12 changed files with 416 additions and 47 deletions
+100 -1
View File
@@ -108,6 +108,35 @@ def test_jdl_basic_tables_use_native_separators_encoding_and_preserve_direct_mon
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');
@@ -228,7 +257,51 @@ def test_jdl_unit_price_preserves_other_source_costs_as_fixed_resources():
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
)
)
assert result == {"parts": 4, "links": [101, 101, 101, 101], "total": 40}
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():
@@ -437,6 +510,32 @@ def test_jdl_complete_unit_preserves_difference_from_saved_title():
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');
+93
View File
@@ -0,0 +1,93 @@
import json
import subprocess
from pathlib import Path
from test_est_rea_parity import run
ROOT = Path(__file__).resolve().parents[2]
def test_jdl_special_unit_detail_with_matching_totals_is_editable():
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_DES:'material',F_MPRICE:100}])],
['unit.txt',table('unit.txt',[{F_CODE:'U1',F_DES:'unit',F_MPRICE:300,F_LPRICE:50}])],
['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_SEQ:'1',F_CODE1:'M1',F_DES:'normal',F_QQTY:2,F_MPRICE:100,F_MCOST:200},
{F_CODE:'U1',F_SEQ:'2',F_CODE1:'M1',F_DES:'special',F_QQTY:1,F_MCOST:100,F_LCOST:50,F_SPROPER:'1'}])],
['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:300,F_LPRICE:50}])]]);
const p=j.read(a.pack(files),empty),before=structuredClone(p.tables.HeadMainDat);p.options.AutoCalc=true;p.options.SourceRefresh=true;
p.tables.HeadMainDat[2].GC=110;const q=s.calculate(p),out=j.output(q),back=j.read(out.bytes,blank);
process.stdout.write(JSON.stringify({before,title:q.tables.HeadTitleDat[0],rows:back.tables.HeadMainDat,
total:back.tables.EstMainDat.at(-1).TC,warnings:p.importWarnings,errors:q.calculation.errors}));"""
result = json.loads(
subprocess.check_output(
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
)
)
assert len(result["before"]) == 3
assert result["before"][2]["Name"] == "special"
assert result["before"][2]["CalcIndex"] == 1
assert result["title"]["TC"] == 360
assert result["rows"][2]["GC"] == 110
assert result["rows"][2]["NC"] == 50
assert result["total"] == 360
assert any("특수 구성" in warning for warning in result["warnings"])
assert not result["errors"]
def test_jdl_special_unit_detail_without_source_keeps_recorded_amount():
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 files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
['unit.txt',table('unit.txt',[{F_CODE:'U1',F_DES:'unit',F_MPRICE:70}])],
['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_SEQ:'1',F_CODE1:'missing',F_DES:'special',F_QQTY:1,F_MCOST:70,F_SPROPER:'1'}])]]);
const p=j.read(a.pack(files),empty);process.stdout.write(JSON.stringify({rows:p.tables.HeadMainDat,warnings:p.importWarnings}));"""
result = json.loads(
subprocess.check_output(
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
)
)
assert len(result["rows"]) == 2
assert result["rows"][1]["GC"] == 70
assert result["rows"][1]["LinkIndex"] == 0
assert result["rows"][1]["JdlSourceCode"] == "missing"
assert any("특수 구성" in warning for warning in result["warnings"])
def test_jdl_special_machine_detail_is_fixed_only_when_amounts_match():
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:20},
{F_CODE:'J2',F_DES:'mismatch',F_MPRICE:50},{F_CODE:'J3',F_DES:'percent',F_MPRICE:50},
{F_CODE:'J4',F_DES:'weighted',F_MPRICE:100}])],
['jpart.txt',table('jpart.txt',[{F_CODE:'J1',F_SEQ:'1',F_DES:'normal',F_QQTY:1,F_MPRICE:60},
{F_CODE:'J1',F_SEQ:'2',F_DES:'special',F_QQTY:1,F_MPRICE:40,F_LPRICE:20,F_SPROPER:'1'},
{F_CODE:'J2',F_SEQ:'1',F_DES:'mismatch',F_QQTY:1,F_MPRICE:10,F_SPROPER:'1'},
{F_CODE:'J3',F_SEQ:'1',F_DES:'percent',F_UNIT:'%',F_QQTY:1,F_MPRICE:50,F_SPROPER:'1'},
{F_CODE:'J4',F_SEQ:'1',F_DES:'weighted part',F_QQTY:2,F_MPRICE:50,F_SPROPER:'1'}])]]);
const p=j.read(a.pack(files),empty),before=structuredClone(p.tables.MachMainDat);p.options.AutoCalc=true;
p.tables.MachMainDat[2].GC=50;const q=s.calculate(p),out=j.output(q),back=j.read(out.bytes,blank);
process.stdout.write(JSON.stringify({before,titles:q.tables.MachTitleDat,back:back.tables.MachMainDat,
warnings:p.importWarnings,errors:q.calculation.errors}));"""
result = json.loads(
subprocess.check_output(
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
)
)
assert result["before"][2]["Name"] == "special"
assert result["before"][2]["CalcIndex"] == 1
assert result["titles"][0]["TC"] == 130
assert result["back"][2]["GC"] == 50
assert result["titles"][3]["TC"] == 100
assert "상세 산식 미변환" in result["before"][4]["Name"]
assert "상세 산식 미변환" in result["before"][6]["Name"]
assert any("특수 기계 상세" in warning for warning in result["warnings"])
assert not result["errors"]
+31
View File
@@ -18,6 +18,37 @@ def test_mean_sea_level_labels_do_not_break_variable_declarations():
assert result["checks"][0]["calculated"] == 7.27
def test_redundant_price_caption_keeps_quantity_and_source_updates():
result = run(
"math",
"""(() => {
const rows = [
{Index:1, Code:'D1'},
{Index:0, LineData:'Q = 7.35'},
{Index:0, SCheck:-2, LinkIndex:5,
LineData:'\\x0e황동주물\\x0e \\x0324,200\\x03C × Q = 24,200 × 7.35 = \\x12177,870\\x12'}
];
const initial=EstText.calculate(rows);
rows[1].LineData='Q = 4';
const changed=EstText.calculate(rows,{},()=>({GC:30000,TC:30000,Code:'M1',SourceKind:5}));
const repeat=EstText.calculate(changed.rows,{},()=>({GC:30000,TC:30000,Code:'M1',SourceKind:5}));
const conflict=structuredClone(rows);conflict.splice(1,0,{Index:0,LineData:'C = 2'});
const declared=EstText.calculate(conflict);
const mismatch=structuredClone(rows);mismatch[2].LineData=mismatch[2].LineData.replace('= 24,200','= 24,201');
return {initial,changed,repeat,declared,mismatch:EstText.calculate(mismatch)};
})()""",
)
assert result["initial"]["errors"] == []
assert result["initial"]["rows"][2]["TC"] == 177870
assert result["changed"]["errors"] == []
assert result["changed"]["rows"][2]["TC"] == 120000
assert result["changed"]["rows"][2]["ResourceFactor"] == 4
assert result["repeat"]["errors"] == []
assert result["repeat"]["rows"][2]["TC"] == 120000
assert result["declared"]["errors"]
assert result["mismatch"]["errors"]
def test_equals_in_description_is_not_a_formula_separator():
result = run(
"math",