diff --git a/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionParityTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionParityTest.kt index 0c1b2e4..ffee954 100644 --- a/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionParityTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionParityTest.kt @@ -15,7 +15,10 @@ import org.junit.Test * * 케이스: `app/src/test/resources/anchor_cases.tsv` * `bestcm,nch,ch3,hit,tot,cap|...` · best='-' 는 재부착(선택 불가) - * 규칙이 바뀌면 Python 으로 파일을 다시 생성해야 한다(생성 절차는 커밋 메시지 참조). + * 규칙이 바뀌면 Python 으로 **정답 열만** 다시 뽑는다: `tools/gen_anchor_cases.py ` + * (입력 200개는 그대로 두고 piezo-phantom-test 의 `select_supine_anchor` 로 답을 다시 쓴다). + * 2026-10-14: ⓪ CH3 전무 폴백(no_ch3_max_nch)을 Python 에도 넣고 다시 뽑았다 — 재부착 19 → 3, + * 폴백 16. 나머지 181개는 답이 그대로다. * * JSON 대신 TSV 인 이유: `org.json` 은 Android 단위테스트에서 스텁이라 호출하면 * "not mocked" 로 죽는다. 임상 일정 중이라 테스트 의존성을 늘리지 않고 형식을 낮췄다. @@ -51,24 +54,16 @@ class AnchorSelectionParityTest { .filter { it.isNotBlank() && !it.startsWith("#") }.toList() var nullBest = 0 + var fallbackCases = 0 lines.forEachIndexed { i, line -> val (want, recs) = parse(line) val d = AnchorSelection.selectBest(recs) - if (want == null) { - // 2026-10-13 임시: Python 정답은 "재부착"이지만 앱은 ⓪ CH3 전무 폴백(nch 최대 → - // cap 최소 → 낮은 cm)을 탄다. Python 에 같은 폴백이 들어가 이 파일을 다시 뽑으면 - // 이 분기를 지우고 위 assertEquals 하나로 돌아간다. - val maxNch = recs.maxOf { it.nch } - val expect = if (maxNch <= 0) null else recs.filter { it.nch == maxNch } - .minWith(compareBy({ if (it.capFrac.isFinite()) it.capFrac else Double.POSITIVE_INFINITY }, { it.cm })) - .cm - assertEquals("case #$i no-CH3 fallback (records=$line)", expect, d.bestCm) - assertEquals("case #$i rule", if (expect == null) AnchorSelectRule.NONE else AnchorSelectRule.NO_CH3_MAX_NCH, d.rule) - nullBest++ - } else { - assertEquals("case #$i best (records=$line)", want, d.bestCm) - } + assertEquals("case #$i best (records=$line)", want, d.bestCm) + if (want == null) nullBest++ + if (d.rule == AnchorSelectRule.NO_CH3_MAX_NCH) fallbackCases++ } + // ⓪ 폴백 가지도 레퍼런스로 잠겨 있어야 한다 — 0 이면 파일이 폴백 이전 것이다. + assertEquals("no_ch3_max_nch 가지를 타는 레퍼런스 케이스", 16, fallbackCases) assertEquals("케이스 수", 200, lines.size) // 2026-10-07 ①′(nch=4 후보 3곳 이상 → 가운데)는 Python 에 아직 없다. 이 파일에는 그 가지를 // 타는 케이스가 없어 대조가 그대로 유효하다. 파일을 다시 뽑아 이 가정이 깨지면 Python 쪽에도 diff --git a/app/src/test/resources/anchor_cases.tsv b/app/src/test/resources/anchor_cases.tsv index 32c4a47..0f51f83 100644 --- a/app/src/test/resources/anchor_cases.tsv +++ b/app/src/test/resources/anchor_cases.tsv @@ -5,7 +5,7 @@ 1 0,4,X,5,6,0.5|1,3,O,0,1,0.5|2,1,O,0,1,nan 0 0,4,O,0,0,0.3646|1,1,O,0,0,0.5|2,4,X,6,6,0.8704|3,1,X,5,6,0.5 1 0,3,O,6,11,0.1022|1,3,O,9,11,0.6024|2,1,X,6,11,0.5 -- 0,1,X,4,11,0.5009|1,0,X,2,6,0.2937 +0 0,1,X,4,11,0.5009|1,0,X,2,6,0.2937 1 0,3,X,0,0,nan|1,2,O,0,1,0.1872|2,1,O,4,6,0.5|3,0,O,4,11,0.5|4,4,X,6,11,0.5 0 0,4,O,1,6,0.5 0 0,3,O,4,11,0.4301 @@ -14,7 +14,7 @@ 2 0,1,O,1,1,0.5|1,4,O,0,1,0.6467|2,2,O,1,1,0.5|3,0,O,7,11,0.5595|4,3,O,0,0,0.3441 2 0,1,X,1,1,nan|1,1,X,11,11,0.5|2,0,O,2,6,0.5 0 0,4,O,6,6,0.7561|1,3,X,0,1,nan|2,0,O,2,6,0.5 -- 0,0,X,6,11,0.5|1,4,X,0,0,0.8669|2,3,X,1,1,0.5 +1 0,0,X,6,11,0.5|1,4,X,0,0,0.8669|2,3,X,1,1,0.5 2 0,1,O,0,0,nan|1,4,O,2,6,0.7495|2,2,O,10,11,0.2726|3,4,O,0,0,0.5 0 0,2,O,6,11,0.5|1,0,X,1,1,0.3374|2,0,X,0,1,0.5|3,2,O,4,6,0.7666 3 0,0,O,11,11,0.5|1,1,O,7,11,0.5|2,0,O,1,6,nan|3,1,O,1,1,0.5|4,4,O,0,0,0.5 @@ -23,10 +23,10 @@ 0 0,4,O,3,6,0.212|1,4,O,0,11,0.2505|2,3,O,2,11,nan|3,4,X,2,6,0.2563 1 0,1,X,1,1,0.5|1,3,O,0,0,0.1525 2 0,3,O,0,1,0.5|1,4,X,11,11,0.5|2,1,O,10,11,0.5|3,2,O,1,11,0.5|4,3,O,2,6,0.5 -- 0,3,X,5,6,0.7826|1,4,X,1,11,nan +1 0,3,X,5,6,0.7826|1,4,X,1,11,nan 0 0,4,O,0,11,0.5 1 0,2,X,0,0,0.5|1,3,O,5,6,0.5|2,3,O,0,0,0.4853|3,1,X,1,1,0.5 -- 0,3,X,1,1,0.5 +0 0,3,X,1,1,0.5 0 0,2,O,9,11,0.5715|1,2,O,0,0,0.5 2 0,0,O,0,1,0.5|1,0,X,5,6,0.5|2,4,O,0,11,nan 3 0,3,X,0,0,0.5|1,0,O,0,0,0.5|2,4,X,0,0,nan|3,2,O,0,1,0.5|4,0,X,0,11,0.5 @@ -39,7 +39,7 @@ 1 0,1,O,1,1,0.5|1,3,O,6,6,nan 0 0,0,O,4,11,0.5|1,4,X,1,1,0.5 0 0,1,O,0,0,0.5|1,0,O,0,0,nan -- 0,3,X,0,6,0.5 +0 0,3,X,0,6,0.5 0 0,3,O,0,0,0.4641 1 0,2,O,4,6,0.5|1,3,O,0,0,0.5|2,0,X,0,0,nan 0 0,4,O,0,1,0.174|1,4,X,5,6,0.5 @@ -68,11 +68,11 @@ 1 0,2,O,0,0,nan|1,3,O,1,1,0.3858|2,4,O,0,1,nan 2 0,3,X,0,0,0.5|1,1,X,0,1,nan|2,1,O,0,0,nan|3,4,X,4,6,0.2745 - 0,0,X,0,0,0.5 -- 0,4,X,0,1,nan|1,2,X,4,6,nan +0 0,4,X,0,1,nan|1,2,X,4,6,nan 1 0,4,X,3,6,0.5|1,3,O,0,0,0.5 0 0,0,O,0,0,nan 2 0,1,O,1,6,0.0554|1,0,X,1,1,0.8507|2,2,O,0,0,nan -- 0,4,X,8,11,nan +0 0,4,X,8,11,nan 0 0,4,O,0,6,0.5|1,3,O,0,0,0.2693|2,1,X,0,0,nan|3,0,O,0,1,nan 1 0,1,O,2,11,nan|1,2,O,0,0,nan|2,4,X,0,0,nan|3,3,X,1,6,0.5 0 0,1,O,1,1,0.5|1,3,O,2,6,nan|2,3,X,4,11,0.5 @@ -95,7 +95,7 @@ 1 0,2,X,0,0,0.7831|1,1,O,0,0,0.7691 0 0,2,O,0,0,0.4112 1 0,0,X,0,0,0.5|1,1,O,4,11,nan -- 0,3,X,8,11,0.5 +0 0,3,X,8,11,0.5 2 0,0,X,0,1,0.5|1,4,O,0,0,nan|2,4,O,1,1,0.5|3,0,X,1,1,0.5 0 0,1,O,4,11,0.3517 0 0,0,O,0,0,0.5 @@ -103,9 +103,9 @@ 3 0,1,O,0,1,0.5|1,2,O,0,1,nan|2,0,O,4,6,0.5|3,2,O,2,6,0.5|4,4,X,7,11,0.5 0 0,3,O,10,11,nan|1,3,X,0,0,nan|2,4,O,1,11,0.8167|3,2,O,1,1,0.5 0 0,2,O,1,1,nan|1,1,O,0,6,0.5|2,2,X,0,1,0.5|3,4,X,7,11,0.4087 -- 0,3,X,0,0,0.5 -- 0,3,X,8,11,0.5|1,4,X,3,6,nan -- 0,2,X,3,11,0.0733|1,4,X,0,0,nan|2,4,X,1,1,nan|3,3,X,4,6,nan +0 0,3,X,0,0,0.5 +1 0,3,X,8,11,0.5|1,4,X,3,6,nan +1 0,2,X,3,11,0.0733|1,4,X,0,0,nan|2,4,X,1,1,nan|3,3,X,4,6,nan 1 0,0,X,1,1,0.5|1,4,O,1,1,0.5|2,4,X,0,11,0.8716|3,1,O,6,11,nan 0 0,2,O,0,1,0.5|1,0,O,0,6,0.8683|2,2,O,1,6,nan 4 0,3,O,4,6,0.5|1,3,O,0,0,0.5|2,3,O,0,0,0.5|3,3,X,1,1,0.5|4,4,O,0,1,0.5 @@ -120,7 +120,7 @@ 1 0,4,X,0,0,0.5|1,4,O,0,1,0.4258|2,1,O,0,0,0.8755|3,3,O,0,1,0.5|4,0,X,0,6,nan 0 0,3,O,0,0,0.5 0 0,4,O,8,11,0.5|1,2,O,0,1,0.2788|2,3,O,0,1,nan|3,4,O,8,11,0.9269|4,2,O,0,6,nan -- 0,1,X,3,6,0.6197 +0 0,1,X,3,6,0.6197 0 0,4,O,1,1,nan|1,1,X,0,0,0.6865|2,0,O,5,11,0.5|3,0,O,6,6,0.5 1 0,0,O,0,0,0.6664|1,4,O,1,1,nan|2,1,O,0,0,nan 1 0,0,O,6,6,nan|1,0,O,11,11,0.3174|2,4,O,0,1,0.5 @@ -132,7 +132,7 @@ 1 0,1,X,0,0,nan|1,3,O,3,6,0.5|2,2,X,0,0,nan|3,2,O,0,1,0.5 2 0,1,O,3,11,0.5|1,0,O,4,6,0.5|2,3,O,9,11,nan|3,2,X,6,6,nan|4,0,O,7,11,0.5 2 0,2,O,8,11,0.7672|1,4,X,0,0,0.5|2,4,O,0,0,0.4113|3,4,X,0,0,0.5 -- 0,4,X,0,0,0.5 +0 0,4,X,0,0,0.5 0 0,4,O,5,6,0.5|1,1,O,1,1,0.5 0 0,3,O,1,1,0.5|1,3,X,1,1,0.748|2,1,X,1,6,0.1778 3 0,0,O,0,0,0.5|1,4,X,0,0,nan|2,4,O,1,11,0.5|3,0,O,11,11,0.5 @@ -143,7 +143,7 @@ 1 0,2,O,11,11,0.5564|1,4,O,9,11,0.5|2,4,X,0,1,0.4846 0 0,2,O,0,1,nan|1,2,X,1,6,nan|2,0,O,0,0,0.5 1 0,1,O,0,1,0.5|1,1,O,1,1,0.8336|2,4,O,4,6,0.5148|3,4,X,3,6,0.5|4,0,X,0,0,0.5 -- 0,3,X,5,6,0.3578 +0 0,3,X,5,6,0.3578 0 0,3,O,5,11,0.6709 1 0,2,O,0,0,0.5|1,2,O,6,6,0.8069|2,4,X,5,11,0.5 1 0,2,X,10,11,0.789|1,2,O,4,6,0.5 @@ -152,7 +152,7 @@ 1 0,4,O,0,0,0.5|1,4,O,1,1,nan|2,0,O,0,0,nan|3,3,O,5,11,0.5 2 0,4,X,0,1,0.5|1,1,X,0,0,0.5|2,2,O,6,11,nan|3,0,O,0,6,0.5 0 0,2,O,9,11,nan|1,3,O,0,0,nan|2,3,X,0,0,nan -- 0,3,X,5,6,0.5738|1,1,X,0,1,0.5 +0 0,3,X,5,6,0.5738|1,1,X,0,1,0.5 - 0,0,X,0,6,0.2474 0 0,0,O,0,0,0.175 0 0,2,O,6,6,0.5|1,2,X,1,1,0.5|2,4,X,0,0,0.7646 @@ -160,7 +160,7 @@ 3 0,0,O,2,6,nan|1,3,X,1,11,0.5356|2,1,X,1,6,0.5|3,0,O,1,6,0.5 0 0,3,O,4,6,0.5|1,4,X,1,11,0.5|2,4,X,0,1,0.5|3,4,X,0,0,0.5|4,0,O,7,11,0.5 1 0,2,O,0,1,0.1486|1,0,O,1,1,nan|2,2,O,4,6,nan -- 0,1,X,1,1,0.5|1,2,X,3,11,nan +1 0,1,X,1,1,0.5|1,2,X,3,11,nan 0 0,2,O,3,11,0.5 - 0,0,X,11,11,nan|1,0,X,0,1,0.5 1 0,0,O,0,0,nan|1,2,O,0,11,0.6383|2,2,O,0,0,nan|3,1,O,0,1,0.5 diff --git a/tools/gen_anchor_cases.py b/tools/gen_anchor_cases.py new file mode 100644 index 0000000..5ec7c5a --- /dev/null +++ b/tools/gen_anchor_cases.py @@ -0,0 +1,45 @@ +"""anchor_cases.tsv 정답 열 재생성 — 입력 200개는 그대로, 답만 Python select_supine_anchor 로 다시 뽑는다. + +사용: python gen_anchor_cases.py +행 형식: bestcm,nch,ch3,hit,tot,cap|... (best='-' 는 재부착) +""" +import io, sys, math, os, types + +# library/__init__.py 가 이 체크아웃에 없는 analysis 패키지를 import 해 깨진다 — 패키지 껍데기만 등록하고 +# 필요한 모듈만 상대 import 로 읽는다(runners 등은 실제 파일에서 로드). +_root = r"C:\Projects\piezophantomtest\piezo-phantom-test" +for _name, _path in (("vesiscan_test", os.path.join(_root, "vesiscan_test")), + ("vesiscan_test.library", os.path.join(_root, "vesiscan_test", "library"))): + _m = types.ModuleType(_name); _m.__path__ = [_path]; _m.__package__ = _name; sys.modules[_name] = _m +import vesiscan_test.library.alignment.alignment_selection as sel + +src, dst = sys.argv[1], sys.argv[2] +raw = io.open(src, encoding="utf-8", newline="").read() +nl = "\r\n" if "\r\n" in raw else "\n" # 원본 줄바꿈 유지 — diff 가 답 열만 보이게 +lines = raw.splitlines() +out, changed, null_before, null_after, fallback = [], 0, 0, 0, 0 +for line in lines: + if not line.strip() or line.startswith("#"): + out.append(line) + continue + best_old, recs_raw = line.split("\t", 1) + recs = [] + for f in recs_raw.split("|"): + cm, nch, ch3, hit, tot, cap = f.split(",") + capv = float(cap) if cap not in ("nan", "") else math.nan + recs.append(dict(align_cm=int(cm), nch=int(nch), ch3=ch3, ch3_hit=int(hit), ch3_tot=int(tot), cap_frac=capv)) + d = sel.select_supine_anchor(recs, tie="cap", offset_cm=0) + best_new = "-" if d.align_cm is None else str(d.align_cm) + if best_old == "-": + null_before += 1 + if best_new == "-": + null_after += 1 + if d.reasons and d.reasons[0] == sel.NO_CH3_FALLBACK_TAG: + fallback += 1 + if best_new != best_old: + changed += 1 + assert best_old == "-", f"non-reattach answer changed: {line}" + out.append(best_new + "\t" + recs_raw) +io.open(dst, "w", encoding="utf-8", newline="").write(nl.join(out) + nl) +print(f"cases={sum(1 for l in lines if l.strip() and not l.startswith('#'))} reattach before={null_before} after={null_after} " + f"fallback={fallback} changed={changed} -> {dst}")