Files
Aislo/resources/tester/test_est_issues.py

537 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from test_est_rea_parity import run
def test_data_issues_detect_duplicate_missing_and_mismatched_links_without_modifying_data():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.StuffcDat=[{Code:'A',Name:'sand',Size:'large'},
{Code:'B',Name:'first'},{Code:'B',Name:'second'}];
p.tables.HeadMainDat=[{Index:1,Code:'H',Name:'group'},{Index:0,Code:'A',LinkIndex:5,Name:'sand',Size:'large'}];
p.tables.EstMainDat=[{Code:'A',LinkIndex:5,Name:'stone',Size:'small'},
{Code:'B',LinkIndex:5,Name:'ambiguous'},{Code:'missing',LinkIndex:5,Name:'missing'},
{Code:'H',LinkIndex:103,Name:'group'}];
const before=JSON.stringify(p),issues=EstIssues.scan(p);
return {issues,same:before===JSON.stringify(p)};
})()""",
)
assert result["same"]
items = {(row["table"], row["index"], row["message"]) for row in result["issues"]}
assert ("StuffcDat", 1, "코드 중복") in items
assert ("StuffcDat", 2, "코드 중복") in items
assert ("EstMainDat", 0, "연결 품명 차이") in items
assert ("EstMainDat", 0, "연결 규격 차이") in items
assert ("EstMainDat", 1, "연결 코드 중복") in items
assert ("EstMainDat", 2, "연결 자료 없음") in items
assert len(items) == 6
def test_data_issues_detect_cost_main_dat_missing_and_duplicate_links():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.StuffcDat = [{Code:'M_GOOD', Name:'자재1'}, {Code:'M_DUP', Name:'자재2'}, {Code:'M_DUP', Name:'자재2-복사'}];
p.tables.CostMainDat = [
{Index:1, Code:'D001', Name:'단가산출호표1'},
{Index:0, Code:'M_GOOD', LinkIndex:5, LineData:'정상 자재행'},
{Index:0, Code:'M_MISSING', LinkIndex:5, LineData:'미연결 자재행'},
{Index:0, Code:'M_DUP', LinkIndex:5, LineData:'중복 자재행'}
];
const issues = EstIssues.scan(p);
return {
costIssues: issues.filter(x => x.table === 'CostMainDat')
};
})()""",
)
items = {(row["index"], row["code"], row["message"]) for row in result["costIssues"]}
assert (2, "M_MISSING", "연결 자료 없음") in items
assert (3, "M_DUP", "연결 코드 중복") in items
assert len(items) == 2
def test_data_issues_filtering_by_table_and_type():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.StuffcDat = [{Code:'M_DUP', Name:'A'}, {Code:'M_DUP', Name:'B'}];
p.tables.EstMainDat = [{Code:'M_MISS', LinkIndex:5, Name:'없음'}];
const all = EstIssues.scan(p);
const stuffOnly = all.filter(i => (i.table || '') === 'StuffcDat');
const dupOnly = all.filter(i => i.message === '코드 중복');
return {
total: all.length,
stuffCount: stuffOnly.length,
dupCount: dupOnly.length
};
})()""",
)
assert result["total"] == 3
assert result["stuffCount"] == 2
assert result["dupCount"] == 2
def test_cost_error_location_requires_unique_group_and_valid_row():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.CostMainDat=[{Index:1,Code:'A'}, {Index:0}, {Index:0},
{Index:1,Code:'B'}, {Index:0}, {Index:1,Code:'B'}, {Index:0}];
p.calculation={errors:['A:2 bad formula','A:8 invalid row','B:2 duplicate','other error']};
return EstIssues.scan(p).filter(x=>x.message.includes('formula') ||
x.message.includes('row') || x.message.includes('duplicate') || x.message==='other error');
})()""",
)
assert result[0]["table"] == "CostMainDat"
assert result[0]["index"] == 1
assert all(row["table"] == "" for row in result[1:])
def test_data_issues_detect_quantity_target_missing_and_duplicate():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.EstMainDat = [
{ PkID: 101, Code: 'E1', Name: '단일 대상' },
{ PkID: 201, Code: 'DUP', Name: '중복 대상 1' },
{ PkID: 202, Code: 'DUP', Name: '중복 대상 2' }
];
p.quantitySheets = [
{
Code: 'Q1',
items: [
{ Code: 'r1', TargetCode: 'E1' },
{ Code: 'r2', TargetCode: 'MISSING' },
{ Code: 'r3', TargetCode: 'DUP' },
// 두 행이 코드 중복('DUP')이지만 TargetKey('id:201')로 고유하게 연결된 정상 사례
{ Code: 'r4', TargetCode: 'DUP', TargetKey: 'id:201' },
// LinkCode fallback으로 정상 연결된 사례
{ Code: 'r5', LinkCode: 'E1' },
// 잘못된 TargetKey 사례
{ Code: 'r6', TargetKey: 'id:999' }
]
}
];
p.quantities = [
{ Code: 'm1', TargetKey: 'id:202' },
{ Code: 'm2', TargetCode: 'MISSING_M' }
];
return EstIssues.scan(p).filter(x => x.table === 'quantity');
})()""",
)
items = {(row["code"], row["message"], row["sheetCode"]) for row in result}
# r1: 정상 (E1 단일 매칭) -> 오류 없음
# r4: 정상 (DUP이지만 TargetKey id:201 지정) -> 오류 없음
# r5: 정상 (LinkCode E1 지정) -> 오류 없음
# m1: 정상 (수식표 id:202 지정) -> 오류 없음
assert ("r2", "수량 대상 내역 없음: MISSING", "Q1") in items
assert ("r3", "수량 대상 내역 중복: DUP", "Q1") in items
assert ("r6", "수량 대상 내역 없음: id:999", "Q1") in items
assert ("m2", "수량 대상 내역 없음: MISSING_M", "manual") in items
assert len(items) == 4
def test_edge_issues_click_quantity_navigates_to_sheet():
from playwright.sync_api import sync_playwright
import pytest
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:
pytest.skip("EST 5175 server not reachable")
page.wait_for_function("() => Boolean(EstApp.state.project)")
# 오류 검색 화면으로 전환 및 인위적 오류 수량산출서 주입
page.evaluate("""() => {
EstApp.change(p => {
p.quantitySheets ||= [];
p.quantitySheets.push({
Code: 'TEST_ERR_Q',
Name: '오류 테스트 산출서',
rows: [{ LineData: '1 = \\x120\\x12', markers: ['e_r1'] }],
items: [{ Code: 'e_r1', TargetCode: 'NON_EXISTENT_CODE_XYZ', GN: 10 }]
});
}, false);
EstApp.navigate('issues');
}""")
# 오류 검색 화면에서 '산출서 열기' 버튼 탐색 및 클릭
btn = page.locator("button[data-issue-quantity='TEST_ERR_Q']")
assert btn.count() >= 1
btn.first.click()
# 수량산출 화면으로 전환되었고 해당 산출서가 활성화되었는지 확인
page.wait_for_function("() => EstApp.state.view === 'quantity'")
current_view = page.evaluate("EstApp.state.view")
current_sheet = page.evaluate("EstApp.state.quantitySheet")
assert current_view == "quantity"
assert current_sheet == "TEST_ERR_Q"
# 다시 오류 검색 화면으로 돌아가서 바로 내역 연결 드롭다운 선택 시험
page.evaluate("""() => {
EstApp.navigate('issues');
}""")
page.wait_for_function("() => EstApp.state.view === 'issues'")
# select[data-issue-target-select='TEST_ERR_Q'] 요소 확인 및 옵션 선택
select = page.locator("select[data-issue-target-select='TEST_ERR_Q']")
assert select.count() >= 1
# 첫 번째 유효 내역 행의 value를 선택
options = select.locator("option")
assert options.count() > 1
target_val = options.nth(1).get_attribute("value")
select.select_option(target_val)
# 상태 변경이 작업본(project)에 즉시 반영되었는지 확인
linked_key = page.evaluate("""() => {
const sheet = EstApp.state.project.quantitySheets.find(s => s.Code === 'TEST_ERR_Q');
return sheet ? sheet.items[0].TargetKey : null;
}""")
assert linked_key == target_val
browser.close()
def test_data_issues_detect_wonga_formula_syntax_and_missing_reference_errors():
result = run(
"math",
"""(()=>{
const p = structuredClone(project);
const normalIssues = EstIssues.scan(p).filter(x => x.table === 'Wonga');
// 원가 계산서 항목에 잘못된 참조 및 잘못된 범위 산식 주입
const badP = structuredClone(project);
badP.tables.Wonga[5].VSusic = '4 * 0.165 + UNKNOWN_VAR';
badP.tables.Wonga[21].VSusic = '(6:INVALID_RANGE_END)';
const badIssues = EstIssues.scan(badP).filter(x => x.table === 'Wonga');
return {
normalCount: normalIssues.length,
badIssues
};
})()""",
)
assert result["normalCount"] == 0
items = {(row["index"], row["code"], row["message"]) for row in result["badIssues"]}
assert (5, "5", "참조 항목 없음: UNKNOWN_VAR") in items
assert (21, "C", "범위 참조 없음: 6:INVALID_RANGE_END") in items
assert len(items) == 2
def test_wonga_scan_handles_division_zero_values_cycles_ranges_and_basefield():
result = run(
"math",
"""(()=>{
// 1) 실제 값이 다른 A, B의 유효한 1/(A-B) 나눗셈 (scan에서 1로 단순 치환하지 않고 실제 값으로 평가해 0으로 나눔 오탐 없음)
const w1 = [{Gubun:'A',Cost:10},{Gubun:'B',Cost:5},{Gubun:'C',VSusic:'1/(A-B)'}];
const sc1 = EstWonga.scan(w1);
// 2) 실제 0값 참조 계산 (A가 0이고 A + 10 정상 평가)
const w2 = [{Gubun:'A',Cost:0},{Gubun:'B',VSusic:'A + 10'}];
const sc2 = EstWonga.scan(w2);
// 3) 상호 순환 참조 감지 (A -> B -> A)
const w3 = [{Gubun:'A',VSusic:'B'},{Gubun:'B',VSusic:'A'}];
const sc3 = EstWonga.scan(w3);
// 4) BaseField 우선: VSusic에 유효하지 않은 식이어도 BaseField가 우선 적용되어 오류 없음
const w4 = [{Gubun:'A',BaseField:'NC',VSusic:'INVALID_IF_EVALUATED',Cost:0}];
const sc4 = EstWonga.scan(w4, {}, {NC: 500});
// 5) 정상 범위 참조 (A:B 역순/정순 포함)
const w5 = [{Gubun:'B',Cost:10},{Gubun:'A',Cost:20},{Gubun:'C',VSusic:'(A:B)'}];
const sc5 = EstWonga.scan(w5);
// 6) 유효하지 않은 범위 참조
const w6 = [{Gubun:'A',Cost:10},{Gubun:'B',VSusic:'(A:INVALID)'}];
const sc6 = EstWonga.scan(w6);
return {
sc1, sc2, sc3, sc4, sc5, sc6
};
})()""",
)
# 1) A!=B 나눗셈: 오탐 0건
assert result["sc1"] == []
# 2) 0값 참조: 오탐 0건
assert result["sc2"] == []
# 3) 순환 참조: calculate와 동일하게 순환 탐지
assert any("원가 수식 순환 참조" in item["message"] for item in result["sc3"])
assert len(result["sc3"]) >= 1
# 4) BaseField 우선: 수식 무시되고 기본 비목 매핑되어 오탐 0건
assert result["sc4"] == []
# 5) 정상 범위 (A:B): 정상 합산되어 오탐 0건
assert result["sc5"] == []
# 6) 비정상 범위: 오류 정확 탐지
assert any("범위 참조 없음: A:INVALID" in item["message"] for item in result["sc6"])
assert len(result["sc6"]) == 1
def test_wonga_scan_parity_with_calculate_forward_refs_old_cost_bases_ranges_and_cycles():
result = run(
"math",
"""(()=>{
// 1) 전방 참조 및 오래된 Cost:
// B의 수식이 'A*2'로 실제 값은 20이지만 저장된 Cost=0 상태.
// C가 '100/B'를 계산할 때, 오래된 Cost(0)를 쓰면 0으로 나눔 오류이지만,
// calculate와 동일하게 재귀 평가 시 B=20이 되어 C=5 정상 계산되고 오탐 없어야 함.
const w1 = [
{Gubun:'B',Cost:0,VSusic:'A*2'},
{Gubun:'A',Cost:10},
{Gubun:'C',VSusic:'100/B'}
];
const calc1 = EstWonga.calculate(w1);
const scan1 = EstWonga.scan(w1);
// 2) 기초 비목 0 및 양수 전달:
// NC=1000이 전달되면 BaseField: 'NC' 또는 식 내 'NC' 참조가 정상 작동.
// scan에서 bases가 전달되지 않아 0으로 판정되는 문제가 없어야 함.
const w2 = [
{Gubun:'A',BaseField:'NC',Cost:0},
{Gubun:'B',VSusic:'NC * 0.1'},
{Gubun:'C',VSusic:'1000 / B'}
];
const bases = { NC: 1000, 직접공사비: 1000 };
const calc2 = EstWonga.calculate(w2, {}, bases);
const scan2 = EstWonga.scan(w2, {}, bases);
// 3) 구간 제외 행(CalcIndex: 2 또는 3):
// 범위 (A:B)에서 A가 CalcIndex: 2(제외)인 경우 합산에서 제외되나,
// 순환 참조 및 범위 유효성 규칙은 calculate와 일치.
const w3 = [
{Gubun:'A',Cost:10,CalcIndex:2},
{Gubun:'B',Cost:20,CalcIndex:0},
{Gubun:'C',VSusic:'(A:B)'}
];
const calc3 = EstWonga.calculate(w3);
const scan3 = EstWonga.scan(w3);
// 4) 순환 참조 일치성:
// calculate에서 발생하는 순환 오류와 scan의 오류 메시지가 동일
const w4 = [
{Gubun:'X',VSusic:'Y'},
{Gubun:'Y',VSusic:'X'}
];
const calc4 = EstWonga.calculate(w4);
const scan4 = EstWonga.scan(w4);
// 5) issues.scan(project)에서 project.calculation.direct 기초금액 전달 검증
const project = EstNative.empty();
project.tables.Wonga = [
{Gubun:'M',BaseField:'NC',Cost:0},
{Gubun:'N',VSusic:'NC * 0.5'},
{Gubun:'O',VSusic:'500 / N'}
];
project.calculation = {
direct: { NC: 1000, GC: 0, JC: 0, SC: 0, TC: 1000 }
};
const projectIssues = EstIssues.scan(project).filter(i => i.table === 'Wonga');
return {
calc1Errors: calc1.errors,
scan1,
calc1CostB: calc1.rows[0].Cost,
calc1CostC: calc1.rows[2].Cost,
calc2Errors: calc2.errors,
scan2,
calc3Errors: calc3.errors,
scan3,
calc3CostC: calc3.rows[2].Cost,
calc4Errors: calc4.errors,
scan4,
projectIssues
};
})()""",
)
# 1) 전방 참조 & 오래된 Cost(0): calculate와 scan 모두 오류 0건, 정확한 값 평가
assert result["calc1Errors"] == []
assert result["scan1"] == []
assert result["calc1CostB"] == 20
assert result["calc1CostC"] == 5
# 2) 기초 비목 양수 전달: bases 전달로 0 나눔 없이 정상 평가
assert result["calc2Errors"] == []
assert result["scan2"] == []
# 3) 구간 제외 행(CalcIndex: 2): A=10 제외, B=20만 합산되어 C=20, scan 오류 0건
assert result["calc3Errors"] == []
assert result["scan3"] == []
assert result["calc3CostC"] == 20
# 4) 순환 참조: calculate와 scan 메시지 일치
assert "원가 수식 순환 참조: X" in result["calc4Errors"][0]
assert any("원가 수식 순환 참조" in item["message"] for item in result["scan4"])
# 5) project.calculation.direct를 통한 issues.scan 연동: 오탐 0건
assert result["projectIssues"] == []
def test_data_issues_detect_unite_mismatch_and_sync_source_names():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.LaborcDat = [
{ Code: "L001", Name: "보통인부", Size: "공통", Unite: "인" },
{ Code: "L002", Name: "특별인부", Size: "숙련(원천)", Unite: "인" },
{ Code: "L003", Name: "용접공", Size: "1급", Unite: "인" }
];
p.tables.EstMainDat = [
// 1) 품명 불일치 (원천 규격, 단위와 동일)
{ Code: "L001", LinkIndex: 6, Name: "보통 인부(오타)", Size: "공통", Unite: "인" },
// 2) 규격 불일치
{ Code: "L002", LinkIndex: 6, Name: "특별인부", Size: "숙련(내역)", Unite: "인" },
// 3) 단위 불일치 (일괄 반영 대상에서 제외되어야 함)
{ Code: "L003", LinkIndex: 6, Name: "용접공", Size: "1급", Unite: "시간" }
];
const beforeScan = EstIssues.scan(p);
const nameIssue = beforeScan.find(i => i.message === "연결 품명 차이");
const sizeIssue = beforeScan.find(i => i.code === "L002" && i.message === "연결 규격 차이");
const uniteIssue = beforeScan.find(i => i.message === "연결 단위 차이");
return {
hasNameIssue: !!nameIssue,
hasSizeIssue: !!sizeIssue,
hasUniteIssue: !!uniteIssue,
nameSource: nameIssue?.sourceName,
sizeSource: sizeIssue?.sourceSize,
uniteSource: uniteIssue?.sourceUnite
};
})()""",
)
assert result["hasNameIssue"] is True
assert result["hasSizeIssue"] is True
assert result["hasUniteIssue"] is True
assert result["nameSource"] == "보통인부"
assert result["sizeSource"] == "숙련(원천)"
assert result["uniteSource"] == "인"
def test_data_issues_selective_sync_preserves_unit_and_non_empty_size():
from playwright.sync_api import sync_playwright
import pytest
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:
pytest.skip("EST 5175 server not reachable")
page.wait_for_selector("body")
page.wait_for_function("() => EstApp && EstApp.state && EstApp.state.project !== null")
# 테스트용 데이터 주입
page.evaluate("""() => {
EstApp.change(p => {
p.tables.LaborcDat ||= [];
p.tables.LaborcDat.push(
{ Code: "TEST_L1", Name: "원천 보통인부", Size: "원천 규격A", Unite: "인" },
{ Code: "TEST_L2", Name: "원천 특별인부", Size: "원천 규격B(신규)", Unite: "인" },
{ Code: "TEST_L3", Name: "원천 용접공", Size: "원천 규격C", Unite: "인" }
);
p.tables.EstMainDat ||= [];
p.tables.EstMainDat.push(
// 1) 품명만 다름 (내역 규격은 동일하므로 품명만 동기화)
{ Code: "TEST_L1", LinkIndex: 6, Name: "내역 보통인부(오타)", Size: "원천 규격A", Unite: "인" },
// 2) 규격 차이 (원천 규격으로 동기화)
{ Code: "TEST_L2", LinkIndex: 6, Name: "원천 특별인부", Size: "내역 구규격", Unite: "인" },
// 3) 단위 차이 (일괄 반영 대상에서 제외되어야 함)
{ Code: "TEST_L3", LinkIndex: 6, Name: "원천 용접공", Size: "원천 규격C", Unite: "시간" }
);
}, false);
EstApp.navigate('issues');
}""")
page.wait_for_function("() => EstApp.state.view === 'issues'")
# 1) '불일치 원천 명칭/규격 일괄 반영' 버튼 클릭
sync_all_btn = page.locator("button[data-issue-sync-all='1']")
assert sync_all_btn.count() >= 1
sync_all_btn.click()
# 일괄 반영 후 결과 점검
state1 = page.evaluate("""() => {
const est = EstApp.state.project.tables.EstMainDat;
const r1 = est.find(r => r.Code === "TEST_L1");
const r2 = est.find(r => r.Code === "TEST_L2");
const r3 = est.find(r => r.Code === "TEST_L3");
return {
r1: { Name: r1.Name, Size: r1.Size, Unite: r1.Unite },
r2: { Name: r2.Name, Size: r2.Size, Unite: r2.Unite },
r3: { Name: r3.Name, Size: r3.Size, Unite: r3.Unite }
};
}""")
# TEST_L1: 품명은 원천으로 동기화, 규격/단위는 일치 유지
assert state1["r1"]["Name"] == "원천 보통인부"
assert state1["r1"]["Size"] == "원천 규격A"
assert state1["r1"]["Unite"] == "인"
# TEST_L2: 유효한 원천 규격으로 동기화됨
assert state1["r2"]["Size"] == "원천 규격B(신규)"
# TEST_L3: 단위 불일치는 일괄 반영에서 제외되어 "시간" 그대로 유지됨
assert state1["r3"]["Unite"] == "시간"
# 2) TEST_L3의 '원천단위 반영' 개별 버튼 명시적 클릭 확인
sync_unit_btn = page.locator("button[data-issue-sync-msg='연결 단위 차이']")
assert sync_unit_btn.count() >= 1
assert "원천단위 반영" in sync_unit_btn.first.inner_text()
sync_unit_btn.first.click()
# 개별 반영 후 단위가 동기화되었는지 확인
r3_unite_after = page.evaluate("""() => {
const est = EstApp.state.project.tables.EstMainDat;
const r3 = est.find(r => r.Code === "TEST_L3");
return r3.Unite;
}""")
assert r3_unite_after == "인"
# 3) 실행 취소(Undo)로 이전 복원 검증
page.evaluate("EstActions.undo()")
r3_unite_undone = page.evaluate("""() => {
const est = EstApp.state.project.tables.EstMainDat;
const r3 = est.find(r => r.Code === "TEST_L3");
return r3.Unite;
}""")
assert r3_unite_undone == "시간"
browser.close()
def test_data_issues_detect_percentage_unit_mismatch():
result = run(
"math",
"""(()=>{
const p = structuredClone(project);
p.tables.HeadMainDat.push(
{ Index: 0, Level: 0, LinkIndex: 58, Code: "R1", Name: "비율항목1", Unite: "식", GN: 5 },
{ Index: 0, Level: 0, LinkIndex: 62, Code: "R2", Name: "구간비율2", Unite: "M", GN: 10 },
{ Index: 0, Level: 0, LinkIndex: 52, Code: "R3", Name: "낙찰률3", Unite: "%", GN: 87.745 },
{ Index: 0, Level: 0, LinkIndex: 67, Code: "R4", Name: "제잡비4", Unite: "%", GN: 3 }
);
p.tables.EstMainDat.push(
{ Level: 0, LinkIndex: 57, Code: "R5", Name: "조달수수료5", Unite: "원", GN: 1 }
);
const issues = EstIssues.scan(p).filter(x => x.message === "단위 백분율(%) 불일치");
return issues.map(x => ({ table: x.table, code: x.code, message: x.message, rowUnite: x.rowUnite }));
})()""",
)
assert len(result) == 3
items = {(x["table"], x["code"], x["rowUnite"]) for x in result}
assert ("HeadMainDat", "R1", "식") in items
assert ("HeadMainDat", "R2", "M") in items
assert ("EstMainDat", "R5", "원") in items