wip(est-test): 개발 중 저장 3 — 깃 싱크(11-08)

This commit is contained in:
2026-11-08 17:44:13 +09:00
parent d39eece45e
commit db71cdbf7c
28 changed files with 1027 additions and 202 deletions
+2
View File
@@ -12,6 +12,7 @@ for (const file of [
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-row-match.js",
"native-escalation.js",
"native-progress.js",
"native-business.js",
@@ -29,6 +30,7 @@ for (const file of [
"native-c3r.js",
"native-exchange.js",
"native-verify.js",
"native-block-actions.js",
"native-extra.js",
"native-business-actions.js",
"native-forms.js",
+126
View File
@@ -1,6 +1,132 @@
from test_est_rea_parity import run
def test_machine_restore_group_uses_saved_baseline_without_changing_other_groups():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.MachMainDat=[{PkID:1,Index:1,Code:'M1',Name:'장비1'},{PkID:2,Index:0,GN:2},
{PkID:3,Index:1,Code:'M2',Name:'장비2'},{PkID:4,Index:0,GN:3}];
p.tables.MachTitleDat=[{Code:'M1',Name:'장비1'},{Code:'M2',Name:'장비2'}];
const q=EstModel.validate(JSON.parse(JSON.stringify(EstBusiness.baseline(p))));
q.tables.MachMainDat[1].GN=9;q.tables.MachMainDat[3].GN=7;
const before=JSON.stringify(q), rows=EstMachine.restoreGroup(q,'M1');
const originalUntouched=JSON.stringify(q)===before;
let missing=false,duplicate=false,collision=false;
const legacy=structuredClone(q);delete legacy.baseline.tables.MachMainDat;
try { EstMachine.restoreGroup(legacy,'M1'); } catch { missing=true; }
const conflict=structuredClone(q);conflict.tables.MachMainDat[3].PkID=2;
try { EstMachine.restoreGroup(conflict,'M1'); } catch { collision=true; }
q.tables.MachMainDat.push({Index:1,Code:'M1'});
try { EstMachine.restoreGroup(q,'M1'); } catch { duplicate=true; }
return {restored:rows[1].GN,other:rows[3].GN,originalUntouched,
baseline:q.baseline.tables.MachMainDat[1].GN,missing,duplicate,collision};
})()""",
)
assert result == {
"restored": 2,
"other": 7,
"originalUntouched": True,
"baseline": 2,
"missing": True,
"duplicate": True,
"collision": True,
}
def test_machine_change_includes_new_changed_and_deleted_titles():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.MachTitleDat=[{Code:'M1',Name:'장비1',TC:150},{Code:'M3',Name:'신규',TC:50}];
p.baseline={tables:{EstMainDat:[],MachTitleDat:[
{Code:'M1',Name:'장비1',TC:100},{Code:'M2',Name:'삭제',TC:200}]}};
return EstMachine.changes(p).map(({Code,originalTC,TC,difference,status,index})=>
({Code,originalTC,TC,difference,status,index}));
})()""",
)
assert result == [
{
"Code": "M1",
"originalTC": 100,
"TC": 150,
"difference": 50,
"status": "변경",
"index": 0,
},
{
"Code": "M3",
"originalTC": None,
"TC": 50,
"difference": None,
"status": "신규",
"index": 1,
},
{
"Code": "M2",
"originalTC": 200,
"TC": 0,
"difference": -200,
"status": "삭제",
"index": None,
},
]
def test_recover_deleted_machine_group_and_reject_id_collision():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.MachMainDat=[{PkID:1,Index:1,Code:'M1',Name:'first'},
{PkID:2,Index:-2,GN:2,TU:100},{PkID:3,Index:1,Code:'M2',Name:'second'},
{PkID:4,Index:-2,GN:3,TU:100}];
p.tables.MachTitleDat=[{PkID:10,Code:'M1',Name:'first',TC:200},
{PkID:11,Code:'M2',Name:'second',TC:300}];
const q=EstBusiness.baseline(p);
q.tables.MachMainDat=q.tables.MachMainDat.slice(2);
q.tables.MachTitleDat=q.tables.MachTitleDat.slice(1);
const before=JSON.stringify(q), restored=EstMachine.recoverGroup(q,'M1');
const unchanged=JSON.stringify(q)===before;
const collision=structuredClone(q);collision.tables.MachMainDat[1].PkID=2;
let rejected=false;try { EstMachine.recoverGroup(collision,'M1'); } catch { rejected=true; }
q.tables.MachMainDat=restored.rows;q.tables.MachTitleDat=restored.titles;
return {codes:q.tables.MachTitleDat.map(x=>x.Code),quantity:q.tables.MachMainDat[1].GN,
original:EstMachine.usage(q).find(x=>x.Code==='M1').originalPrice,unchanged,rejected};
})()""",
)
assert result == {
"codes": ["M1", "M2"],
"quantity": 2,
"original": 200,
"unchanged": True,
"rejected": True,
}
def test_recovered_machine_group_recalculates_linked_estimate_after_reopen():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=true;
p.tables.MachMainDat=[{PkID:1,Index:1,Code:'M1',Name:'machine'},
{PkID:2,Index:-2,Name:'fuel',GN:2,TU:100}];
p.tables.MachTitleDat=[{PkID:10,Code:'M1',Name:'machine'}];
p.tables.EstMainDat=[{PkID:20,Code:'M1',Name:'machine',LinkIndex:105,GN:2}];
const q=EstBusiness.baseline(EstNative.calculate(p));
q.tables.MachMainDat=[];q.tables.MachTitleDat=[];
const restored=EstMachine.recoverGroup(JSON.parse(JSON.stringify(q)),'M1');
q.tables.MachMainDat=restored.rows;q.tables.MachTitleDat=restored.titles;
const out=EstNative.calculate(EstModel.validate(JSON.parse(JSON.stringify(q))));
return {machine:out.tables.MachTitleDat[0].TC, estimate:out.tables.EstMainDat[0].TC,
errors:out.calculation.errors};
})()""",
)
assert result == {"machine": 200, "estimate": 400, "errors": []}
def test_basic_prices_changes_for_material_and_labor():
result = run(
"math",
+9
View File
@@ -665,3 +665,12 @@ def test_vat_calculation_exclude_include_and_unit_rounding_for_materials():
# M03: 12345 / 1.1 = 11222.72... -> 십원 단위 사사오입(11220), 절상(11230)
assert result["m3RoundCost"] == 11220
assert result["m3UpCost"] == 11230
def test_vat_round_up_handles_small_remainders_and_exact_boundaries():
result = run(
"math",
"""[EstVat.convertPrice(100.1, 0, 1, 2),
EstVat.convertPrice(100, 0, 1, 2)]""",
)
assert result == [120, 110]
+78 -1
View File
@@ -1,6 +1,84 @@
from test_est_rea_parity import run
def test_change_comparison_matches_reordered_duplicate_codes():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80},
{Code:'A',Name:'second',GN:20,GU:50}];
const q=EstBusiness.baseline(EstNative.calculate(p));
[q.tables.EstMainDat[0],q.tables.EstMainDat[1]]=[q.tables.EstMainDat[1],q.tables.EstMainDat[0]];
q.tables.EstMainDat[0].GN=22;
return EstBusiness.changes(JSON.parse(JSON.stringify(q))).map(({Name,originalGN,GN,status})=>({Name,originalGN,GN,status}));
})()""",
)
assert result == [
{"Name": "second", "originalGN": 20, "GN": 22, "status": "changed"},
{"Name": "first", "originalGN": 10, "GN": 10, "status": "unchanged"},
]
def test_change_comparison_tracks_duplicate_code_additions_and_deletions():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80},
{Code:'A',Name:'second',GN:20,GU:50}];
const q=EstBusiness.baseline(EstNative.calculate(p));
q.tables.EstMainDat=[q.tables.EstMainDat[1],{Code:'A',Name:'new',GN:3,GU:10},
{Code:'A',Name:'another',GN:4,GU:10}];
return EstBusiness.changes(q).map(({Name,originalGN,status})=>({Name,originalGN,status}));
})()""",
)
assert result == [
{"Name": "second", "originalGN": 20, "status": "unchanged"},
{"Name": "new", "originalGN": 0, "status": "new"},
{"Name": "another", "originalGN": 0, "status": "new"},
{"Name": "first", "originalGN": 10, "status": "deleted"},
]
def test_change_comparison_matches_duplicate_when_other_row_is_renamed():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80},
{Code:'A',Name:'second',GN:20,GU:50}];
const q=EstBusiness.baseline(EstNative.calculate(p));
[q.tables.EstMainDat[0],q.tables.EstMainDat[1]]=[q.tables.EstMainDat[1],q.tables.EstMainDat[0]];
q.tables.EstMainDat[1].Name='renamed';
return EstBusiness.changes(q).map(({Name,originalGN,status})=>({Name,originalGN,status}));
})()""",
)
assert result == [
{"Name": "second", "originalGN": 20, "status": "unchanged"},
{"Name": "renamed", "originalGN": 10, "status": "changed"},
]
def test_change_costs_match_reordered_duplicate_codes():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.Wonga=[{Gubun:'A',Name:'second',Cost:70},
{Gubun:'A',Name:'new',Cost:5},{Gubun:'A',Name:'other',Cost:7}];
p.baseline={name:'old',tables:{EstMainDat:[],Wonga:[
{Gubun:'A',Name:'first',Cost:100},{Gubun:'A',Name:'second',Cost:60}]}};
return EstBusiness.costChanges(p).map(({Name,originalCost,changedCost})=>({Name,originalCost,changedCost}));
})()""",
)
assert result == [
{"Name": "second", "originalCost": 60, "changedCost": 70},
{"Name": "new", "originalCost": 0, "changedCost": 5},
{"Name": "other", "originalCost": 0, "changedCost": 7},
{"Name": "first", "originalCost": 100, "changedCost": 0},
]
def test_estimate_move_preserves_row_identity_and_blocks_ambiguous_or_total_rows():
result = run(
"math",
@@ -712,4 +790,3 @@ def test_bid_rates_consecutive_apply_source_refresh_and_roundtrip_restore():
assert result["manualBidRow"]["GU"] == 105
assert result["manualResRow"]["NU"] == 100
assert result["manualResRow"]["GU"] == 150
@@ -20,6 +20,25 @@ def test_escalation_distinguishes_repeated_codes_without_row_ids():
assert result["excludedTotal"] == 410
def test_escalation_matches_reordered_duplicate_codes_to_original_rows():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000;
p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80},
{Code:'A',Name:'second',GN:20,GU:50},{LinkIndex:54}];
const q=EstBusiness.baseline(EstNative.calculate(p));
[q.tables.EstMainDat[0],q.tables.EstMainDat[1]]=[q.tables.EstMainDat[1],q.tables.EstMainDat[0]];
Object.assign(q.tables.EstMainDat[0],{AGU:100,GU:120,EscActual:5});
Object.assign(q.tables.EstMainDat[1],{AGU:100,GU:120,EscActual:2});
return EstEscalation.calculate(JSON.parse(JSON.stringify(q)));
})()""",
)
assert [row["ContractGU"] for row in result["rows"][:2]] == [50, 80]
assert [row["EscApplied"] for row in result["rows"][:2]] == [15, 8]
assert result["excludedTotal"] == 410
def test_progress_exclusions_preserve_new_rows_and_reject_ambiguous_keys():
result = run(
"math",
@@ -0,0 +1,21 @@
from test_est_row_actions import run_js
def test_change_reset_restores_machine_details_and_preserves_legacy_machine_rows():
result = run_js("""(() => {
const p=EstNative.empty();
p.tables.MachMainDat=[{PkID:1,Index:1,Code:'M1'}, {PkID:2,Index:-2,GN:2,GN_:123,TU:100}];
const q=EstBusiness.baseline(p);
q.tables.MachMainDat[1].GN=9;
const state={project:q};
globalThis.confirm=()=>true;globalThis.EstApp={change:fn=>fn()};
EstBlockActions.resetChangeAction(state);
const restored=q.tables.MachMainDat[1].GN, original=q.tables.MachMainDat[1].GN_;
const legacy=EstBusiness.baseline(p);
delete legacy.baseline.tables.MachMainDat;
legacy.tables.MachMainDat[1].GN=7;legacy.tables.MachMainDat[1].GN_=123;
EstBlockActions.resetChangeAction({project:legacy});
return {restored, original, legacy:legacy.tables.MachMainDat[1].GN,
legacyOriginal:legacy.tables.MachMainDat[1].GN_};
})()""")
assert result == {"restored": 2, "original": 123, "legacy": 7, "legacyOriginal": 123}
@@ -0,0 +1,47 @@
from test_est_row_actions import run_js
def test_progress_page_preserves_original_indices_and_clamps():
result = run_js("""(() => {
const rows = Array.from({length:205}, (_, i) => ({Code:'R'+i, Level:0}));
rows.splice(1, 0, {Code:'heading', Level:1});
const state = {progressPage:1};
const second = EstBlockActions.progressPage(state, rows);
state.progressPage = 99;
const last = EstBlockActions.progressPage(state, rows);
return {secondCount:second.rows.length, secondIndex:second.rows[0].index,
lastCount:last.rows.length, page:state.progressPage, pages:last.pages};
})()""")
assert result == {"secondCount": 100, "secondIndex": 101, "lastCount": 5, "page": 2, "pages": 3}
def test_escalation_page_excludes_summary_and_preserves_original_indices():
result = run_js("""(() => {
const rows = Array.from({length:201}, (_, i) => ({Code:'R'+i, Level:0}));
rows.splice(2, 0, {Name:'소계', LinkIndex:54});
const state = {escalationPage:1};
const page = EstBlockActions.escalationPage(state, rows);
return {count:page.rows.length, index:page.rows[0].index, total:page.total, pages:page.pages};
})()""")
assert result == {"count": 100, "index": 101, "total": 201, "pages": 3}
def test_change_page_includes_deleted_and_new_rows():
result = run_js("""(() => {
const rows = Array.from({length:200}, (_, i) => ({Code:'R'+i, status:'unchanged'}));
rows.push({Code:'old',status:'deleted'}, {Code:'new',status:'new'});
const state = {changePage:2};
const page = EstBlockActions.changePage(state, rows);
return {statuses:page.rows.map(x=>x.row.status), total:page.total, pages:page.pages};
})()""")
assert result == {"statuses": ["deleted", "new"], "total": 202, "pages": 3}
def test_business_paging_filters_without_changing_original_indices():
result = run_js("""(() => {
const rows = [{Code:'A',Name:'첫 항목'}, {Code:'B',Name:'둘째 항목',Size:'특수 규격'}];
const state = {query:'특수', progressPage:2};
const page = EstBlockActions.progressPage(state, rows);
return {total:page.total, index:page.rows[0].index, page:state.progressPage};
})()""")
assert result == {"total": 1, "index": 1, "page": 0}
@@ -27,6 +27,25 @@ def test_progress_cannot_lock_when_previous_result_is_missing():
assert result["rejected"]
def test_progress_cannot_lock_when_previous_costs_are_missing():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{LinkIndex:54}];
p.tables.Wonga=[{Gubun:'D',BaseField:'TC'},{LinkIndex:55,VSusic:'D'}];
p.progress=[{id:'one',locked:true,inputs:{},result:{rows:[]}},
{id:'two',inputs:{'1':{Gum:2}}}];
const calculated=EstProgress.calculate(p,'two');
let rejected=false;
try { EstProgress.lock(p,'two'); } catch { rejected=true; }
return {errors:calculated.errors,rejected};
})()""",
)
assert any("전회 기성 원가 결과가 없습니다" in error for error in result["errors"])
assert result["rejected"]
def test_progress_rejects_ambiguous_inputs_without_applying_them_to_multiple_rows():
result = run(
"math",
@@ -1,6 +1,44 @@
from test_est_rea_parity import run
def test_quantity_totals_handles_large_estimate_and_ambiguous_targets():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.EstMainDat=Array.from({length:5000},(_,i)=>({PkID:i+1,Code:'C'+i,Level:0}));
p.quantities=Array.from({length:5000},(_,i)=>({Code:'Q'+i,formula:'1',TargetKey:'id:'+(i+1)}));
const totals=EstQuantity.totals(p,'manual');
p.tables.EstMainDat.push({PkID:1,Code:'other',Level:0});
let duplicate=false;
try { EstQuantity.totals(p,'manual'); } catch(e) { duplicate=e.message.includes('중복'); }
return {count:totals.size,first:totals.get(0),last:totals.get(4999),duplicate};
})()""",
)
assert result == {"count": 5000, "first": 1, "last": 1, "duplicate": True}
def test_quantity_target_options_are_loaded_when_focused():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.EstMainDat=[{PkID:1,Code:'A',Name:'첫 행'},
{PkID:2,Code:'B',Name:'둘째 행'},{PkID:3,Code:'C',Level:1}];
globalThis.EstApp={esc:v=>String(v),state:{project:p}};
const handlers={}; globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn};
const html=EstQuantity.targetSelect(p,{TargetKey:'id:2',TargetCode:'B'},'manual',0);
EstQuantity.install();
const select={dataset:{quantitySelect:'manual'},value:'id:2',innerHTML:''};
handlers.focusin({target:select});
return {initialOptions:(html.match(/<option/g)||[]).length,
loadedOptions:(select.innerHTML.match(/<option/g)||[]).length,
selected:select.value,loaded:select.dataset.quantityOptionsLoaded};
})()""",
)
assert result == {"initialOptions": 2, "loadedOptions": 3, "selected": "id:2", "loaded": "1"}
def test_excel_quantity_cell_rejects_invalid_address_or_non_numeric_value():
result = run(
"math",
+20
View File
@@ -37,6 +37,7 @@ def run_js(code: str) -> dict:
'tools/est-test/native-machine.js',
'tools/est-test/native-wonga.js',
'tools/est-test/native-engine.js',
'tools/est-test/native-row-match.js',
'tools/est-test/native-rows.js',
'tools/est-test/native-business.js',
'tools/est-test/native-extra.js',
@@ -1154,6 +1155,25 @@ def test_reset_wonga_action():
assert result["handledTotal"] == 1270500
def test_reset_wonga_error_preserves_rows_and_options():
result = run_js("""(() => {
const p = EstNative.empty();
p.options.TotalFix = true;
p.tables.Wonga = [
{Gubun:'A', Name:'A', VSusic:'1', VSusic_:'[A]', Cost:10, Cost_:20}
];
const state = {project:p, view:'Wonga', selection:new Set()};
globalThis.confirm = () => true;
globalThis.EstApp = {change(fn) { fn(); }};
const before = JSON.stringify(p);
let rejected = false;
try { EstBlockActions.resetWongaAction(state); } catch { rejected = true; }
return {rejected, unchanged: JSON.stringify(p) === before};
})()""")
assert result["rejected"]
assert result["unchanged"]
def test_reset_progress_inputs_action():
code = """(() => {
const p = EstNative.empty();
+60
View File
@@ -0,0 +1,60 @@
import os
from pathlib import Path
import shutil
import signal
import subprocess
import time
ROOT = Path(__file__).resolve().parents[2]
def test_watcher_detects_new_sources_but_ignores_data_directory(tmp_path):
shutil.copy(ROOT / "tools/est-test/watch-server.cjs", tmp_path / "watch-server.cjs")
(tmp_path / "server.cjs").write_text(
"require('node:fs').appendFileSync('starts.txt', process.pid + String.fromCharCode(10));\n"
"setInterval(() => {}, 1000);\n",
encoding="utf-8",
)
data = tmp_path / "data"
data.mkdir()
process = subprocess.Popen(
["node", "watch-server.cjs"],
cwd=tmp_path,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=os.name != "nt",
)
def starts():
file = tmp_path / "starts.txt"
return file.read_text(encoding="utf-8").splitlines() if file.exists() else []
def wait_for(count):
deadline = time.monotonic() + 8
while len(starts()) < count and time.monotonic() < deadline:
time.sleep(0.05)
assert len(starts()) == count
try:
wait_for(1)
source = tmp_path / "new-feature.js"
source.write_text("// first\n", encoding="utf-8")
wait_for(2)
source.write_text("// second\n", encoding="utf-8")
wait_for(3)
(data / "work.js").write_text("// saved data\n", encoding="utf-8")
time.sleep(0.8)
assert len(starts()) == 3
assert len(set(starts())) == 3
finally:
if os.name == "nt":
subprocess.run(
["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
else:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=5)