commit cde49e592dbe98be95e9db353c8ca47999c8aa1b Author: Eiichi Shimazu Date: Sat Nov 14 23:19:49 2026 +0900 判定の画面で判定を選んでから「確定」で書く 2 段にする (依頼 192). index.html: y/n/u/x (キー・クリック) は選ぶだけ (塗る・書かない・選び直せる). 判定するの欄の下に確定のボタン (幅いっぱい・--ink の塗りに白い字・角丸 6px・手本の #cta a の強さ. 選ぶまでは薄く押せない). 確定 (ボタンか Enter) で今までと同じ 1 行を追記し, 確定しましたの知らせ・0.4 秒後に次の回 (2 回書かない守りはそのまま). 回を移ると選んだものは捨てる. ひとこと欄でも Enter は確定 (変換中は除く). 操作の説明に Enter 確定を足す. test_verdicts.py に画面を Chrome で動かすテスト 6 件. テスト 45 OK. 8765 番は /tmp の写しで撮影ののち本物の判定ファイルで立ち上げ直した (本物は md5 変わらず) Co-Authored-By: Claude Opus 5.5 --- pi05/verdict_viewer/index.html | 53 ++++++++++++---- pi05/verdict_viewer/test_verdicts.py | 120 +++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 14 deletions(-) diff --git a/pi05/verdict_viewer/index.html b/pi05/verdict_viewer/index.html index b8e046b..1d95122 100644 --- a/pi05/verdict_viewer/index.html +++ b/pi05/verdict_viewer/index.html @@ -71,6 +71,12 @@ /* 判定するのボタン: 人の確定と押した判定を塗る. 成功は青・失敗は赤・ほかは --ink (依頼 189) */ button.key.on[data-oc="success"] { background: var(--ok); border-color: var(--ok); } button.key.on[data-oc="failure"] { background: var(--stop); border-color: var(--stop); } + /* 確定のボタン: 手本の #cta a の強さ. y/n/u/x を選ぶまでは薄く押せない (依頼 192) */ + #confirm { display: block; width: 100%; margin-top: 10px; text-align: center; background: var(--ink); color: #fff; border-color: var(--ink); + font-weight: 600; font-size: 14px; padding: 11px 12px; border-radius: 6px; } + #confirm:hover:not(:disabled) { background: #000; } + #confirm:disabled { opacity: .3; cursor: default; } + #confirm kbd { margin-left: 6px; } kbd { font: 600 11px/1 "Inter", system-ui, sans-serif; border: 1px solid var(--line); border-radius: 6px; padding: 2px 5px; background: var(--bg); color: var(--ink); } #note { width: 100%; font: inherit; padding: 5px 6px; border: 1px solid var(--line); border-radius: 6px; color: var(--ink); } #status { min-height: 1em; margin: 6px 0 0; font-size: 12px; color: var(--sub); } @@ -140,11 +146,12 @@
place

選ばなければ 成功→yes / ほか→unknown

+

いま確定した回 (新しい 5 回)

ひとこと

- +
-

← → 前後の回 ・ , . コマ送り / 動画は space で再生

+

← → 前後の回 ・ Enter 確定 ・ , . コマ送り / 動画は space で再生

AI の判定 (未確定)

今の判定 (いちばん新しい行)

成功率 (確定した回だけ)

@@ -158,6 +165,7 @@ const COLORS = ["--s1", "--s2", "--s3", "--s4", "--s5", "--s6"].map(v => getComp const pendingOps = {}; // run_stem → 書けたか分からない操作 (回ごとに残す. codex 6 回目 R1) let selSeq = 0; // 応答が届かなかった操作. 同じ中身で押し直したら同じ op_id で送る (codex 2 回目 R2) let runs = [], view = [], cur = null, detail = null, pp = {pick: null, place: null}, frameIdx = 0, busy = false, loading = false; +let chosen = null; // y/n/u/x で選んだ判定. 確定するまで書かない・回を移ったら捨てる (依頼 192) const $ = s => document.querySelector(s); const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&": "&", "<": "<", ">": ">", '"': """}[c])); @@ -217,17 +225,17 @@ async function loadRuns() { async function select(stem) { const seq = ++selSeq; // いちばん新しい選択の応答だけ使う (codex 5 回目 R2・6 回目 R3) if (!stem) { // 空の一覧: 前の回の表示と状態を残さない (codex 7 回目 R1) - cur = null; detail = null; chartGeom = null; loading = false; pp = {pick: null, place: null}; markPP(); markOC(null); $("#note").value = ""; + cur = null; detail = null; chartGeom = null; loading = false; pp = {pick: null, place: null}; chosen = null; markPP(); markOC(null); $("#note").value = ""; $("#title").textContent = "この絞り込みに残っている回はありません"; $("#meta").textContent = ""; $("#media").innerHTML = $("#chart").innerHTML = $("#ai").innerHTML = $("#cur").innerHTML = $("#hist").innerHTML = ""; return true; } // 読み込みが終わるまでは判定を受け付けない (前の回を見ながら次の回に書かない. codex R1) - cur = stem; detail = null; loading = true; pp = {pick: null, place: null}; frameIdx = 0; $("#note").value = ""; + cur = stem; detail = null; loading = true; pp = {pick: null, place: null}; chosen = null; frameIdx = 0; $("#note").value = ""; const po = pendingOps[stem]; if (po) { - // 書けたか分からない操作の回に戻ったら, そのときの入力を戻す (同じキーで同じ op_id になる. codex 5 回目 R1) - pp = {pick: po.payload.pick, place: po.payload.place}; $("#note").value = po.payload.note; + // 書けたか分からない操作の回に戻ったら, そのときの入力を戻す (同じ確定で同じ op_id になる. codex 5 回目 R1) + pp = {pick: po.payload.pick, place: po.payload.place}; $("#note").value = po.payload.note; chosen = po.payload.outcome; } $("#title").textContent = stem + " (読み込み中)"; $("#media").innerHTML = ""; $("#chart").innerHTML = ""; $("#ai").innerHTML = $("#cur").innerHTML = $("#hist").innerHTML = ""; @@ -237,7 +245,7 @@ async function select(stem) { try { const res = await fetch("/api/run/" + stem); d = await res.json(); if (!res.ok) throw new Error(d.error); } catch (e) { if (seq === selSeq) { $("#title").textContent = stem; status("この回を読めませんでした: " + e.message + " ・ 一覧でもう一度選んでください", "err"); } return false; } if (seq !== selSeq || d.run_stem !== cur) return false; - detail = d; loading = false; + detail = d; loading = false; markConfirm(); if (!po) { // 記録した pick / place を塗る (人の確定があればそれ, なければ確定の行. 依頼 190) const r = recordedRow(); if (r) { pp = {pick: r.pick || null, place: r.place || null}; markPP(); } @@ -350,19 +358,29 @@ function recordedRow() { return h || (l && l.labeler !== "ai" ? l : null); } function humanOc() { const h = detail ? detail.history.filter(r => HUMAN.includes(r.labeler)).pop() : null; return h ? h.outcome : null; } -function markOC(oc = humanOc()) { document.querySelectorAll("[data-oc]").forEach(b => b.classList.toggle("on", b.dataset.oc === oc)); } +// 選んだ判定があればそれ, なければ人の確定を塗る (依頼 192) +function markOC(oc = chosen || humanOc()) { document.querySelectorAll("[data-oc]").forEach(b => b.classList.toggle("on", b.dataset.oc === oc)); markConfirm(); } +function markConfirm() { $("#confirm").disabled = !chosen || !cur || loading || !detail; } function markPP() { document.querySelectorAll("[data-pp]").forEach(b => b.classList.toggle("on", pp[b.dataset.pp] === b.dataset.v)); } document.querySelectorAll("[data-pp]").forEach(b => b.onclick = () => { const k = b.dataset.pp; pp[k] = pp[k] === b.dataset.v ? null : b.dataset.v; markPP(); b.blur(); }); -document.querySelectorAll("[data-oc]").forEach(b => b.onclick = () => { b.blur(); save(b.dataset.oc); }); +document.querySelectorAll("[data-oc]").forEach(b => b.onclick = () => { b.blur(); choose(b.dataset.oc); }); +$("#confirm").onclick = () => { $("#confirm").blur(); confirmChosen(); }; + +// y/n/u/x は選ぶだけ (書かない. 選び直せる). 書くのは確定のボタンか Enter (依頼 192) +function choose(oc) { + if (!cur || busy || loading || !detail || detail.run_stem !== cur) return; + chosen = oc; markOC(); +} +function confirmChosen() { if (chosen) save(chosen); } async function save(outcome) { if (!cur || busy || loading || !detail || detail.run_stem !== cur) return; busy = true; const stem = cur; - markOC(outcome); // 押したらすぐ塗り, 0.4 秒見せてから次の回へ (その間も busy なので 2 回目は書かない. 依頼 189) + markOC(outcome); // 確定したら 0.4 秒見せてから次の回へ (その間も busy なので 2 回目は書かない. 依頼 189・192) const shown = new Promise(r => setTimeout(r, 400)); const payload = {run_stem: stem, outcome, pick: pp.pick, place: pp.place, note: $("#note").value}; const key = JSON.stringify(payload); @@ -396,7 +414,7 @@ async function save(outcome) { if (saved) { status(`${stem} は書けています. 一覧の読み直しに失敗しました: ${e} ・ 押し直しても 2 回は書きません`, "err"); return; } // 書けたのに応答が届かなかったこともある. 同じ回・同じ判定で押し直すと同じ op_id で送るので, // サーバーは 2 回書かない (codex R7. 別の回を判定してから戻っても pendingOps に残る) - status("書けたか確かめられませんでした: " + e + " ・ 同じキーで押し直すと 2 回は書きません", "err"); + status("書けたか確かめられませんでした: " + e + " ・ そのまま確定し直すと 2 回は書きません", "err"); } finally { busy = false; } } @@ -408,12 +426,19 @@ function move(d) { } document.addEventListener("keydown", e => { + // Enter は確定. ひとこと欄に入力中でも効く (変換中の Enter は確定にしない. 依頼 192) + if (e.key === "Enter" && e.target.tagName !== "SELECT") { + if (e.isComposing || e.keyCode === 229 || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; + e.preventDefault(); + if (!e.repeat) confirmChosen(); // 押しっぱなしで次の回まで書かない (codex R5) + return; + } if (e.target.tagName === "INPUT" && e.target.type !== "range") { if (e.key === "Escape") e.target.blur(); return; } if (e.metaKey || e.ctrlKey || e.altKey) return; - if (e.repeat && "ynux".includes(e.key)) return; // 押しっぱなしで次の回まで書かない (codex R5) + if (e.repeat && "ynux".includes(e.key)) return; const k = e.key; - if (k === "y") save("success"); else if (k === "n") save("failure"); - else if (k === "u") save("unknown"); else if (k === "x") save("not_judgeable"); + if (k === "y") choose("success"); else if (k === "n") choose("failure"); + else if (k === "u") choose("unknown"); else if (k === "x") choose("not_judgeable"); else if (k === "ArrowRight") { e.preventDefault(); move(1); } else if (k === "ArrowLeft") { e.preventDefault(); move(-1); } else if (k === ".") stepFrame(1); else if (k === ",") stepFrame(-1); else if (k === " " && $("#vid")) { e.preventDefault(); const v = $("#vid"); v.paused ? v.play() : v.pause(); } diff --git a/pi05/verdict_viewer/test_verdicts.py b/pi05/verdict_viewer/test_verdicts.py index 9985e43..106c984 100644 --- a/pi05/verdict_viewer/test_verdicts.py +++ b/pi05/verdict_viewer/test_verdicts.py @@ -374,6 +374,126 @@ class TestServer(unittest.TestCase): self.assertEqual(cm.exception.code, 404) +class TestConfirm192(unittest.TestCase): + """依頼 192: y/n/u/x は選ぶだけ・確定 (ボタンか Enter) で 1 行だけ書く. 画面を Chrome で動かす (playwright がなければ飛ばす).""" + @classmethod + def setUpClass(cls): + try: + from playwright.sync_api import sync_playwright + cls.pw = sync_playwright().start() + cls.browser = cls.pw.chromium.launch(channel="chrome") + except Exception as e: # noqa: BLE001 + raise unittest.SkipTest(f"playwright / Chrome がない: {e}") + + @classmethod + def tearDownClass(cls): + cls.browser.close() + cls.pw.stop() + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.runs = make_runs(Path(self.tmp.name) / "runs") + self.csv = Path(self.tmp.name) / "v.csv" + V.append_rows([row("pi05_20261001_000003", "success", "ai", ai_reason="入っている", evidence="frames")], self.csv) + self.srv = server.ThreadingHTTPServer(("127.0.0.1", 0), server.make_handler(server.App(self.runs, self.csv))) + threading.Thread(target=self.srv.serve_forever, daemon=True).start() + self.pg = self.browser.new_page() + self.pg.goto(f"http://127.0.0.1:{self.srv.server_address[1]}/") + self.wait_loaded() + + def tearDown(self): + self.pg.close() + self.srv.shutdown() + self.srv.server_close() + self.tmp.cleanup() + + def wait_loaded(self): + self.pg.wait_for_function("() => { const t = document.querySelector('#title').textContent;" + " return t.startsWith('pi05_') && !t.includes('読み込み中'); }") + + def n_rows(self): + return len(V.read_rows(self.csv)) + + def on_keys(self): + return self.pg.eval_on_selector_all("button.key.on", "e => e.map(x => x.dataset.oc)") + + def disabled(self): + return self.pg.eval_on_selector("#confirm", "e => e.disabled") + + def test_choose_only_does_not_write(self): + n = self.n_rows() + self.assertTrue(self.disabled()) + self.pg.keyboard.press("n") + self.assertEqual(self.on_keys(), ["failure"]) + self.assertFalse(self.disabled()) + self.pg.click('button.key[data-oc="success"]') # 選び直し + self.assertEqual(self.on_keys(), ["success"]) + self.pg.wait_for_timeout(600) + self.assertEqual(self.n_rows(), n) + + def test_confirm_before_choose_does_nothing(self): + n = self.n_rows() + title = self.pg.text_content("#title") + self.pg.keyboard.press("Enter") + self.pg.click("#confirm", force=True) + self.pg.wait_for_timeout(600) + self.assertEqual(self.n_rows(), n) + self.assertEqual(self.pg.text_content("#title"), title) + + def test_enter_writes_one_row(self): + n = self.n_rows() + stem = self.pg.text_content("#title") + self.pg.keyboard.press("n") + self.pg.keyboard.press("Enter") + self.pg.keyboard.press("Enter") # 0.4 秒の間の 2 回目は書かない + self.pg.wait_for_function("() => document.querySelector('#toast').textContent.includes('確定しました')") + self.assertIn(stem, self.pg.text_content("#toast")) + self.pg.wait_for_timeout(800) + rows = V.read_rows(self.csv) + self.assertEqual(len(rows), n + 1) + self.assertEqual((rows[-1]["run_stem"], rows[-1]["outcome"]), (stem, "failure")) + + def test_button_writes_one_row(self): + n = self.n_rows() + stem = self.pg.text_content("#title") + self.pg.keyboard.press("y") + self.pg.click("#confirm") + self.pg.click("#confirm", force=True) + self.pg.wait_for_timeout(1000) + rows = V.read_rows(self.csv) + self.assertEqual(len(rows), n + 1) + self.assertEqual((rows[-1]["run_stem"], rows[-1]["outcome"]), (stem, "success")) + + def test_enter_in_note_confirms_but_keys_do_not_choose(self): + n = self.n_rows() + self.pg.keyboard.press("u") + self.pg.click("#note") + self.pg.keyboard.type("yn") + self.assertEqual(self.on_keys(), ["unknown"]) + self.pg.keyboard.press("Enter") + self.pg.wait_for_timeout(1000) + rows = V.read_rows(self.csv) + self.assertEqual(len(rows), n + 1) + self.assertEqual((rows[-1]["outcome"], rows[-1]["note"]), ("unknown", "yn")) + + def test_moving_discards_choice(self): + n = self.n_rows() + stem = self.pg.text_content("#title") + self.pg.keyboard.press("n") + self.pg.keyboard.press("ArrowRight") + self.wait_loaded() + self.assertNotEqual(self.pg.text_content("#title"), stem) + self.assertEqual(self.on_keys(), []) + self.assertTrue(self.disabled()) + self.pg.keyboard.press("ArrowLeft") + self.wait_loaded() + self.assertEqual(self.pg.text_content("#title"), stem) + self.assertEqual(self.on_keys(), []) + self.pg.keyboard.press("Enter") + self.pg.wait_for_timeout(600) + self.assertEqual(self.n_rows(), n) + + class TestRule185(unittest.TestCase): """エイイチの決め (2026-11-14 依頼 185): run_input も確定・送ったチャンク 0 → not_applicable・素材不足 → not_judgeable.""" def setUp(self):