feat: DO-gated manual batch mode (auto default, office-only switches)
ci / smoke (push) Canceled after 0s

This commit is contained in:
andrew committed 2026-09-24 15:45:12 +07:00
1 parent 735e0c7594
commit 0efcfa45b8
20 files changed
+3128 -209

No files matched your search

+283
View File
@@ -0,0 +1,283 @@
"""Smoke tests for DO-gated manual batch helpers (src/do_batch.py). Stdlib only."""
import json
from datetime import datetime, timedelta
import pytest
from src.do_batch import (
BATCH_MODES,
OCR_ENGINES,
classify_unit_token,
empty_do_settings,
group_dos_by_plate,
is_valid_batch_mode,
is_valid_ocr_engine,
net_counts,
normalize_batch_mode,
retention_cutoff_date,
should_discard_batch,
should_purge_counting_date,
start_gate_errors,
)
from src.do_ocr import parse_fields_from_text
def test_normalize_batch_mode_defaults_auto():
assert normalize_batch_mode(None) == "auto"
assert normalize_batch_mode("bogus") == "auto"
assert normalize_batch_mode("do_manual") == "do_manual"
assert normalize_batch_mode("MANUAL") == "manual"
assert normalize_batch_mode("auto", "manual") == "auto"
for m in BATCH_MODES:
assert is_valid_batch_mode(m)
assert not is_valid_batch_mode("DO_ONLY")
def test_net_counts_and_discard():
assert net_counts(10, 2, 5, 1) == (8, 4)
assert should_discard_batch(0, 0) is True
assert should_discard_batch(1, 0) is False
assert should_discard_batch(0, 1) is False
assert should_discard_batch(-1, 0) is False
def test_classify_unit_tokens():
assert classify_unit_token("KRG") == "sack"
assert classify_unit_token("karung") == "sack"
assert classify_unit_token("DUS") == "box"
assert classify_unit_token("kardus") == "box"
assert classify_unit_token("BOX") == "box"
assert classify_unit_token("CTN") == "box"
assert classify_unit_token("kg") == "unknown"
def test_group_dos_same_plate():
dos = [
{"no_do": "DO-1", "plate": "b 1234 xyz"},
{"no_do": "DO-2", "plate": "B 1234 XYZ"},
]
plate, out = group_dos_by_plate(dos)
assert plate == "B 1234 XYZ"
assert [d["no_do"] for d in out] == ["DO-1", "DO-2"]
def test_group_dos_mixed_plates_raises():
dos = [{"no_do": "A", "plate": "B 1"}, {"no_do": "B", "plate": "B 2"}]
with pytest.raises(ValueError, match="mixed_plates"):
group_dos_by_plate(dos)
def test_group_dos_empty_plates_ok():
dos = [{"no_do": "A", "plate": ""}, {"no_do": "B", "plate": ""}]
plate, out = group_dos_by_plate(dos)
assert plate == ""
assert len(out) == 2
def test_start_gate_errors_matrix():
good = {"no_do": "DO-1", "plate": "B 1234 XYZ"}
# auto wrong mode
assert "wrong_mode" in start_gate_errors("auto", [good])
assert "wrong_mode" in start_gate_errors("manual", [good])
# empty when do_manual + ok
assert start_gate_errors("do_manual", [good]) == []
# missing DO
assert "missing_do" in start_gate_errors("do_manual", [], require_do=True)
# missing no_do
assert "missing_no_do" in start_gate_errors(
"do_manual", [{"no_do": "", "plate": "B 1"}])
# plate optional: empty plate ok
assert start_gate_errors(
"do_manual", [{"no_do": "D", "plate": ""}], require_plate=False) == []
# plate required
assert "missing_plate" in start_gate_errors(
"do_manual", [{"no_do": "D", "plate": ""}], require_plate=True)
# mixed
assert "mixed_plates" in start_gate_errors(
"do_manual",
[{"no_do": "A", "plate": "B 1"}, {"no_do": "B", "plate": "B 2"}],
)
# batch active
assert "batch_active" in start_gate_errors(
"do_manual", [good], batch_active=True)
def test_retention_cutoff_and_purge():
today = datetime(2026, 9, 24)
cutoff = retention_cutoff_date(7, today)
assert cutoff == "2026-09-17"
assert should_purge_counting_date("2026-09-16", cutoff) is True
assert should_purge_counting_date("2026-09-17", cutoff) is False # equal kept
assert should_purge_counting_date("2026-09-18", cutoff) is False
def test_ocr_engine_validation():
for e in OCR_ENGINES:
assert is_valid_ocr_engine(e)
assert not is_valid_ocr_engine("easyocr")
def test_empty_do_settings_defaults():
s = empty_do_settings({"require_plate": False, "require_do": True, "ocr_engine": "tesseract"})
assert s["ocr_engine"] == "tesseract"
assert s["require_do"] is True
bad = empty_do_settings({"ocr_engine": "nope"})
assert bad["ocr_engine"] == "tesseract"
def test_parse_fields_from_text():
text = "No. DO: DO-001\nPlat: B 1234 XYZ\n80 KRG\n10 DUS\n"
fields = parse_fields_from_text(text)
assert fields["no_do"] == "DO-001"
assert fields["plate"].startswith("B 1234")
assert fields["expected_sack"] == 80
assert fields["expected_box"] == 10
def test_extract_do_fields_none_engine(tmp_path):
from src.do_ocr import extract_do_fields
# no real image needed for none
out = extract_do_fields(str(tmp_path / "x.jpg"), "none")
assert out["ocr_ok"] is False
assert out["ocr_error"] == "disabled"
assert out["engine"] == "none"
# ---------------------------------------------------------------------------
# Flask API auth tests — skipped when flask/openpyxl not installed (CI)
# ---------------------------------------------------------------------------
@pytest.fixture()
def dash_client(tmp_path, monkeypatch):
pytest.importorskip("flask")
pytest.importorskip("openpyxl")
import counter_dashboard as cd
# isolate state files into tmp
mode_path = tmp_path / "batch_mode.json"
state_path = tmp_path / "current_batch.json"
settings_path = tmp_path / "do_settings.json"
monkeypatch.setattr(cd, "BATCH_MODE_PATH", str(mode_path))
monkeypatch.setattr(cd, "CURRENT_BATCH_PATH", str(state_path))
monkeypatch.setattr(cd, "DO_SETTINGS_PATH", str(settings_path))
client = cd.app.test_client()
return client, mode_path, state_path, settings_path
def _office(client, **kw):
return client.post("/api/batch/mode", json=kw,
headers={"Host": f"localhost:{cd_office_port()}"})
def cd_office_port():
import counter_dashboard as cd
return cd.OFFICE_PORT
def test_mode_post_operator_403(dash_client):
client, mode_path, _, _ = dash_client
res = client.post("/api/batch/mode", json={"mode": "do_manual"},
headers={"Host": "localhost:5000"})
assert res.status_code == 403
def test_mode_post_office_200_and_invalid_400(dash_client):
client, mode_path, _, _ = dash_client
office = {"Host": f"localhost:{cd_office_port()}"}
res = client.post("/api/batch/mode", json={"mode": "do_manual"}, headers=office)
assert res.status_code == 200
assert res.get_json()["mode"] == "do_manual"
assert mode_path.exists()
assert json.loads(mode_path.read_text())["mode"] == "do_manual"
res = client.post("/api/batch/mode", json={"mode": "nope"}, headers=office)
assert res.status_code == 400
def test_mode_post_409_when_batch_active(dash_client):
client, _, state_path, _ = dash_client
office = {"Host": f"localhost:{cd_office_port()}"}
state_path.write_text(json.dumps({
"counting_date": "2026-09-24", "batch_number": 1,
"count": 1, "start_time": "x",
}))
res = client.post("/api/batch/mode", json={"mode": "auto"}, headers=office)
assert res.status_code == 409
def test_model_mode_post_operator_403(dash_client):
client, _, _, _ = dash_client
res = client.post("/api/batch/mode", json={"model_mode": "B"},
headers={"Host": "localhost:5000"})
assert res.status_code == 403
def test_ocr_engine_post_both_ports(dash_client):
client, _, _, settings_path = dash_client
res = client.post("/api/do/settings", json={"ocr_engine": "paddle"},
headers={"Host": "localhost:5000"})
assert res.status_code == 200
assert res.get_json()["ocr_engine"] == "paddle"
res = client.post("/api/do/settings", json={"ocr_engine": "tesseract"},
headers={"Host": f"localhost:{cd_office_port()}"})
assert res.status_code == 200
res = client.post("/api/do/settings", json={"ocr_engine": "bogus"},
headers={"Host": "localhost:5000"})
assert res.status_code == 400
def test_require_plate_post_operator_403(dash_client):
client, _, _, _ = dash_client
res = client.post("/api/do/settings", json={"require_plate": True},
headers={"Host": "localhost:5000"})
assert res.status_code == 403
office = {"Host": f"localhost:{cd_office_port()}"}
res = client.post("/api/do/settings", json={"require_plate": True}, headers=office)
assert res.status_code == 200
assert res.get_json()["require_plate"] is True
def test_start_stop_auto_409(dash_client):
client, mode_path, _, _ = dash_client
office = {"Host": f"localhost:{cd_office_port()}"}
client.post("/api/batch/mode", json={"mode": "auto"}, headers=office)
res = client.post("/api/batch/start", headers={"Host": "localhost:5000"})
assert res.status_code == 409
res = client.post("/api/batch/stop", headers={"Host": "localhost:5000"})
assert res.status_code == 409
def test_stop_discard_both_nets_zero(dash_client):
client, mode_path, state_path, _ = dash_client
office = {"Host": f"localhost:{cd_office_port()}"}
client.post("/api/batch/mode", json={"mode": "manual"}, headers=office)
state_path.write_text(json.dumps({
"counting_date": "2026-09-24", "batch_number": 99,
"count": 0, "box_count": 0, "box_unloading": 0, "unloading": 0,
"start_time": "2026-09-24T00:00:00",
"model_mode": "C",
}))
res = client.post("/api/batch/stop", headers={"Host": "localhost:5000"})
body = res.get_json()
assert res.status_code == 200
assert body["discarded"] is True
assert body["net_sack"] == 0 and body["net_box"] == 0
assert not state_path.exists()
def test_stop_keeps_box_only_batch(dash_client):
client, mode_path, state_path, _ = dash_client
office = {"Host": f"localhost:{cd_office_port()}"}
client.post("/api/batch/mode", json={"mode": "manual"}, headers=office)
state_path.write_text(json.dumps({
"counting_date": "2026-09-24", "batch_number": 98,
"count": 0, "box_count": 5, "box_unloading": 1, "unloading": 0,
"start_time": "2026-09-24T00:00:00",
"model_mode": "C",
}))
res = client.post("/api/batch/stop", headers={"Host": "localhost:5000"})
body = res.get_json()
assert res.status_code == 200
assert body["discarded"] is False
assert body["net_box"] == 4