- canonical_plate (uppercase alnum, strips space/dot/hyphen) at all plate write sites; pretty_plate for render (history, XLSX, operator tile) so B 1234 XYZ and B1234XYZ are one plate everywhere - group_dos_by_plate compares canonical plates -> no false mixed_plates - warn-only (never blocking) plate format hint in operator + monitoring modals - batch.default_mode: manual (auto merged truck loads when a sack sat in the counting ROI); operator banner explains plate -> start -> stop - docs + AGENTS known-limitation note; 85 tests pass
This commit is contained in:
1 parent
7ad995d8d1
commit
6c5b1c8b30
11 files changed
+128
-32
No files matched your search
@@ -35,7 +35,10 @@ Indonesian (`karung`=sack, `truk`=truck); YOLO class names are English
|
||||
`LOCAL_RTSP`) is deprecated — don't add keys there. Dashboard mode switches
|
||||
write `config.yaml` `models.active_mode` (atomic, manual restart to apply);
|
||||
`batch_mode.json` keeps only batch flow mode (`auto`|`do_manual`|`manual`,
|
||||
default seed `batch.default_mode: auto`).
|
||||
default seed `batch.default_mode: manual`). `manual` start requires plate
|
||||
(`canonical_plate`: spaces/dots/hyphens stripped, upper; DB stores canonical,
|
||||
render `pretty_plate`); `auto` merges batches when a sack sits in the counting
|
||||
ROI — known limitation, fix not landed.
|
||||
**Port split:** `mode` / `model_mode` / `require_plate` / `require_do` POSTs →
|
||||
office `:5721` only (403 on `:5000`); **`ocr_engine` office-only too**
|
||||
(default `rapid`). Discard batch only if both **gross** `count==0` and
|
||||
|
||||
+15
-2
@@ -6,15 +6,28 @@ version tags are cut in this repo — POC stage, `main` is the release line).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Plate canonicalization** (`canonical_plate` in `src/do_batch.py`): plates are
|
||||
stored uppercase alnum-only (`B 1234 XYZ` / `b-1234.xyz` → `B1234XYZ`), so
|
||||
spacing/dot/hyphen variants are one plate everywhere — DB, history, XLSX, DO
|
||||
grouping (no more false `mixed_plates`). Render pretty (`B 1234 XYZ`) via
|
||||
`pretty_plate`. Applies to manual start, DO plate edit, DO OCR draft.
|
||||
- `batch.default_mode` = **`manual`** (was `auto`) — auto mode merged many
|
||||
truck loads into one batch when a sack sat in the counting ROI (2026-10-03:
|
||||
2486 sacks / 7h14m in a single row). Operator page gets a manual-mode banner
|
||||
(plate → start when truck ready → stop when truck leaves).
|
||||
- Manual start plate field warns (never blocks) when the value doesn't look like
|
||||
a plate.
|
||||
|
||||
### Added
|
||||
- **Manual mode plate entry**: legacy `manual` batch start is now locked until a
|
||||
plate number is entered — plate input in operator + monitoring start modals
|
||||
(confirm button disabled while empty), `POST /api/batch/start` rejects
|
||||
manual start with 400 `missing_plate` (strip + uppercase, no format regex);
|
||||
manual start with 400 `missing_plate` (canonical + non-empty, no format regex);
|
||||
plate flows through `current_batch.json` → `batches.plate` on stop, so
|
||||
history/XLSX show it for manual batches. Operator plate tile now also shows
|
||||
for manual batches (was `do_manual` only).
|
||||
- **DO-gated manual batch mode** (`auto` default · `do_manual` · legacy `manual`):
|
||||
- **DO-gated manual batch mode** (`manual` default · `do_manual` · `auto`):
|
||||
smartphone photo capture → OCR draft → plate-grouped start gates → net
|
||||
expected/counted on live panel → stop soft-warn + force → discard only when
|
||||
**both** gross `count` and `box_loading` are 0.
|
||||
|
||||
@@ -93,9 +93,9 @@ batch mode, DO upload/staged/settings, CSV/Excel export). Data source:
|
||||
|
||||
| Mode | Who opens/closes batch | DO gate |
|
||||
|---|---|---|
|
||||
| `auto` (default) | AI truck FSM | — |
|
||||
| `auto` | AI truck FSM — merges batches when a sack sits in the counting ROI (known) | — |
|
||||
| `do_manual` | Operator + DO photo scan | yes |
|
||||
| `manual` | Operator buttons (legacy) | no |
|
||||
| `manual` (default) | Operator buttons + plate number per truck | no |
|
||||
|
||||
Office (`:5721`) switches mode / model mode / require_plate / ocr_engine (403
|
||||
on operator POST). **DO manual flow:** open `http://<jetson>:5000/operator` on a phone →
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ batch:
|
||||
truck_gone_tolerance_seconds: 30.0 # truck-gone: in WAITING_FOR_ACTIVITY, N s with no sack crossing + no visible sack + no valid truck signal -> finalize the batch (auto-mode end condition)
|
||||
merge_threshold_seconds: 300 # deprecated by timeout_seconds and detecting sacks within the ROI
|
||||
daily_cutoff_time: "06:00"
|
||||
default_mode: "auto" # auto | do_manual | manual (seed when batch_mode.json missing)
|
||||
default_mode: "manual" # auto | do_manual | manual (seed when batch_mode.json missing). manual = operator start/stop + plate per truck (auto merges batches when a sack sits in the counting ROI)
|
||||
|
||||
# do = Delivery Order (surat jalan). Gated manual-batch scan flow.
|
||||
# Used when batch mode == do_manual (mode itself lives in batch_mode.json).
|
||||
|
||||
+11
-9
@@ -71,12 +71,14 @@ DASHBOARD_HOST = CFG.dashboard.host
|
||||
FLASK_DEBUG = CFG.dashboard.debug
|
||||
|
||||
from src.do_batch import (
|
||||
canonical_plate,
|
||||
empty_do_settings,
|
||||
group_dos_by_plate,
|
||||
is_valid_batch_mode,
|
||||
is_valid_ocr_engine,
|
||||
net_counts,
|
||||
normalize_batch_mode,
|
||||
pretty_plate,
|
||||
OCR_ENGINES,
|
||||
retention_cutoff_date,
|
||||
should_discard_batch,
|
||||
@@ -475,7 +477,7 @@ def api_batch_start():
|
||||
elif mode == "manual":
|
||||
# Legacy manual mode: batch start locked until plate entered.
|
||||
req_data = request.get_json(silent=True) or request.form or {}
|
||||
plate = str(req_data.get("plate", "")).strip().upper()
|
||||
plate = canonical_plate(req_data.get("plate", ""))
|
||||
if not plate:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
@@ -982,7 +984,7 @@ def api_do_upload():
|
||||
""",
|
||||
(counting_date, rel,
|
||||
ocr.get("no_do", "") or "",
|
||||
ocr.get("plate", "") or "",
|
||||
canonical_plate(ocr.get("plate")),
|
||||
int(ocr.get("expected_sack") or 0),
|
||||
int(ocr.get("expected_box") or 0),
|
||||
ocr_raw),
|
||||
@@ -992,7 +994,7 @@ def api_do_upload():
|
||||
"id": do_id,
|
||||
"photo_url": f"/api/do/photo/{do_id}",
|
||||
"no_do": ocr.get("no_do", "") or "",
|
||||
"plate": ocr.get("plate", "") or "",
|
||||
"plate": pretty_plate(ocr.get("plate")),
|
||||
"expected_sack": int(ocr.get("expected_sack") or 0),
|
||||
"expected_box": int(ocr.get("expected_box") or 0),
|
||||
"ocr_text": ocr.get("ocr_text", "") or "",
|
||||
@@ -1067,7 +1069,7 @@ def api_do_staged():
|
||||
"id": r["id"],
|
||||
"photo_url": f"/api/do/photo/{r['id']}",
|
||||
"no_do": r["no_do"] or ocr.get("no_do", ""),
|
||||
"plate": r["plate"] or ocr.get("plate", ""),
|
||||
"plate": pretty_plate(r["plate"] or ocr.get("plate")),
|
||||
"expected_sack": r["expected_sack"],
|
||||
"expected_box": r["expected_box"],
|
||||
"status": r["status"],
|
||||
@@ -1111,7 +1113,7 @@ def api_do_item(do_id):
|
||||
|
||||
req = request.get_json(silent=True) or {}
|
||||
no_do = str(req.get("no_do", "")).strip()
|
||||
plate = str(req.get("plate", "")).strip().upper()
|
||||
plate = canonical_plate(req.get("plate", ""))
|
||||
try:
|
||||
expected_sack = int(req.get("expected_sack", 0) or 0)
|
||||
expected_box = int(req.get("expected_box", 0) or 0)
|
||||
@@ -1267,7 +1269,7 @@ def api_current_batch():
|
||||
"unloading": data.get("unloading", 0),
|
||||
"net_sack": net_sack,
|
||||
"net_box": net_box,
|
||||
"plate": data.get("plate", "") or "",
|
||||
"plate": pretty_plate(data.get("plate")),
|
||||
"do_numbers": data.get("do_numbers") or [],
|
||||
"expected_sack": int(data.get("expected_sack", 0) or 0),
|
||||
"expected_box": int(data.get("expected_box", 0) or 0),
|
||||
@@ -1344,7 +1346,7 @@ def api_previous_batch():
|
||||
"count": row["count"],
|
||||
"box_loading": row["box_loading"],
|
||||
"model_mode": row["model_mode"],
|
||||
"plate": row["plate"] if "plate" in row.keys() else "",
|
||||
"plate": pretty_plate(row["plate"]),
|
||||
"do_numbers": do_numbers,
|
||||
"expected_sack": row["expected_sack"] if "expected_sack" in row.keys() else 0,
|
||||
"expected_box": row["expected_box"] if "expected_box" in row.keys() else 0,
|
||||
@@ -1507,7 +1509,7 @@ def api_day_detail(date):
|
||||
"count": row["count"],
|
||||
"box_loading": row["box_loading"],
|
||||
"model_mode": row["model_mode"],
|
||||
"plate": row["plate"] if "plate" in row.keys() else "",
|
||||
"plate": pretty_plate(row["plate"]),
|
||||
"do_numbers": do_numbers,
|
||||
"expected_sack": row["expected_sack"] if "expected_sack" in row.keys() else 0,
|
||||
"expected_box": row["expected_box"] if "expected_box" in row.keys() else 0,
|
||||
@@ -1990,7 +1992,7 @@ def export_day_xlsx(date):
|
||||
ws.cell(row=r_idx, column=1, value=row["batch_number"])
|
||||
ws.cell(row=r_idx, column=2, value=row["count"])
|
||||
ws.cell(row=r_idx, column=3, value=row["box_loading"])
|
||||
ws.cell(row=r_idx, column=4, value=row["plate"] if "plate" in row.keys() else "")
|
||||
ws.cell(row=r_idx, column=4, value=pretty_plate(row["plate"]))
|
||||
ws.cell(row=r_idx, column=5, value=", ".join(do_numbers))
|
||||
ws.cell(row=r_idx, column=6, value=row["expected_sack"] if "expected_sack" in row.keys() else 0)
|
||||
ws.cell(row=r_idx, column=7, value=row["expected_box"] if "expected_box" in row.keys() else 0)
|
||||
|
||||
@@ -36,9 +36,9 @@ deprecated (warned).
|
||||
|
||||
| Mode | Who opens/closes | DO gate |
|
||||
|---|---|---|
|
||||
| `auto` (default) | AI truck FSM | n/a |
|
||||
| `auto` | AI truck FSM — merges batches when a sack sits in the counting ROI (known) | n/a |
|
||||
| `do_manual` | Operator start/stop + staged DO photos | yes |
|
||||
| `manual` | Operator start/stop (legacy) — plate required at start | no |
|
||||
| `manual` (**default**) | Operator start/stop, plate required at start | no |
|
||||
|
||||
`POST /api/batch/mode` with `mode` and/or `model_mode` is **office port only**
|
||||
(403 on operator `:5000`); mode switch while a batch is active → **409**.
|
||||
|
||||
+22
-3
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
@@ -11,6 +12,25 @@ SACK_LIKE_UNITS = frozenset({"krg", "karung", "sack", "bg"})
|
||||
BOX_LIKE_UNITS = frozenset({"box", "ctn", "karton", "dus", "kardus"})
|
||||
|
||||
|
||||
def canonical_plate(raw: Any) -> str:
|
||||
"""Plate identity: uppercase alnum only. Spaces/dots/hyphens removed.
|
||||
|
||||
Storage form — `B 1234 XYZ` and `b1234xyz` are the same plate.
|
||||
"""
|
||||
return re.sub(r"[\s.\-_]+", "", str(raw or "")).upper()
|
||||
|
||||
|
||||
def pretty_plate(raw: Any) -> str:
|
||||
"""Display form of a canonical plate: `B1234XYZ` -> `B 1234 XYZ`."""
|
||||
p = canonical_plate(raw)
|
||||
if not p:
|
||||
return ""
|
||||
m = re.match(r"^([A-Z]*)(\d*)([A-Z]*)$", p)
|
||||
if m and (m.group(1) or m.group(3)):
|
||||
return " ".join(g for g in m.groups() if g)
|
||||
return p
|
||||
|
||||
|
||||
def normalize_batch_mode(raw: Any, default: str = "auto") -> str:
|
||||
"""Map unknown/missing mode to default (auto). Validates known values."""
|
||||
mode = str(raw or "").strip().lower()
|
||||
@@ -68,7 +88,7 @@ def group_dos_by_plate(dos: Sequence[Dict[str, Any]]) -> Tuple[str, List[Dict[st
|
||||
Raises ValueError if non-empty plates disagree (mixed plates).
|
||||
"""
|
||||
items = list(dos or [])
|
||||
plates = {str(d.get("plate") or "").strip().upper() for d in items}
|
||||
plates = {canonical_plate(d.get("plate")) for d in items}
|
||||
plates.discard("")
|
||||
if len(plates) > 1:
|
||||
raise ValueError(f"mixed_plates:{','.join(sorted(plates))}")
|
||||
@@ -76,8 +96,7 @@ def group_dos_by_plate(dos: Sequence[Dict[str, Any]]) -> Tuple[str, List[Dict[st
|
||||
out = []
|
||||
for d in items:
|
||||
d = dict(d)
|
||||
p = str(d.get("plate") or "").strip().upper()
|
||||
d["plate"] = p
|
||||
d["plate"] = canonical_plate(d.get("plate"))
|
||||
out.append(d)
|
||||
out.sort(key=lambda d: (d.get("plate") or "", str(d.get("no_do") or "")))
|
||||
return plate, out
|
||||
|
||||
@@ -412,6 +412,9 @@
|
||||
oninput="onMonStartPlateInput()"
|
||||
style="width:100%;padding:8px 10px;border-radius:var(--radius-md);border:1px solid var(--border-color);background:var(--bg-card);color:var(--text-primary);font-size:14px;text-transform:uppercase;box-sizing:border-box;">
|
||||
</div>
|
||||
<div id="monStartPlateHint" style="display:none;color:var(--accent-orange);font-size:12px;margin-top:4px; text-align:left;">
|
||||
Format plat tidak umum — cek lagi (mis. B 1234 XYZ).
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<button class="btn btn-secondary" style="flex: 1;" onclick="document.getElementById('modalStart').style.display='none'">Batal</button>
|
||||
<button id="monBtnStartConfirm" class="btn btn-primary" style="flex: 1; background-color: var(--accent-green);" onclick="executeStartBatch()">Ya, Mulai</button>
|
||||
@@ -485,11 +488,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
function plateLooksOff(v) {
|
||||
const c = v.toUpperCase().replace(/[\s.\-_]+/g, '');
|
||||
return c.length > 0 && (!/^[A-Z0-9]{4,12}$/.test(c) || !/[A-Z]/.test(c));
|
||||
}
|
||||
|
||||
function onMonStartPlateInput() {
|
||||
const plate = document.getElementById('monStartPlateInput').value.trim();
|
||||
const raw = document.getElementById('monStartPlateInput').value;
|
||||
document.getElementById('monStartPlateHint').style.display =
|
||||
plateLooksOff(raw) ? 'block' : 'none';
|
||||
const btn = document.getElementById('monBtnStartConfirm');
|
||||
btn.style.opacity = plate.length === 0 ? '0.6' : '';
|
||||
btn.style.pointerEvents = plate.length === 0 ? 'none' : '';
|
||||
btn.style.opacity = raw.trim().length === 0 ? '0.6' : '';
|
||||
btn.style.pointerEvents = raw.trim().length === 0 ? 'none' : '';
|
||||
}
|
||||
|
||||
function confirmStartBatch() {
|
||||
|
||||
+23
-5
@@ -432,6 +432,9 @@
|
||||
style="width:100%;padding:8px 10px;border-radius:var(--radius-md);border:1px solid var(--border-color);background:var(--bg-card);color:var(--text-primary);font-size:14px;text-transform:uppercase;box-sizing:border-box;"
|
||||
oninput="onStartPlateInput()">
|
||||
</div>
|
||||
<div id="startPlateHint" style="display:none;color:var(--accent-orange);font-size:12px;margin-top:4px;">
|
||||
Format plat tidak umum — cek lagi (mis. B 1234 XYZ).
|
||||
</div>
|
||||
<div id="startGateList" class="modal-desc" style="display:none;color:var(--accent-red);"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modalStart')">Batal</button>
|
||||
@@ -474,14 +477,22 @@
|
||||
const res = await fetch('/api/batch/mode');
|
||||
const data = await res.json();
|
||||
updateModeUI(data.mode || 'auto');
|
||||
const banner = document.getElementById('modeBanner');
|
||||
if (data.mode === 'auto') {
|
||||
document.getElementById('modeBannerText').textContent =
|
||||
'Mode OTOMATIS aktif. Mode diatur dari monitoring/kantor.';
|
||||
if (!sessionStorage.getItem('mode_banner_dismissed')) {
|
||||
document.getElementById('modeBanner').style.display = 'flex';
|
||||
banner.style.display = 'flex';
|
||||
}
|
||||
} else if (data.mode === 'manual') {
|
||||
document.getElementById('modeBannerText').textContent =
|
||||
'Mode MANUAL: isi plat → Mulai saat truk siap di posisi muat → Selesai saat truk keluar. ' +
|
||||
'Satu batch = satu truk. Mode diatur dari monitoring/kantor.';
|
||||
if (!sessionStorage.getItem('mode_banner_dismissed')) {
|
||||
banner.style.display = 'flex';
|
||||
}
|
||||
} else {
|
||||
document.getElementById('modeBanner').style.display = 'none';
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Mode load error:', e);
|
||||
@@ -508,7 +519,7 @@
|
||||
loadStagedDos();
|
||||
loadDoSettings();
|
||||
} else if (mode === 'manual') {
|
||||
if (sub) sub.textContent = 'Tekan tombol saat proses muat truk dimulai dan selesai';
|
||||
if (sub) sub.textContent = 'Isi plat → Mulai saat truk siap → Selesai saat truk keluar';
|
||||
} else {
|
||||
if (sub) sub.textContent = 'Mode otomatis — batch dikontrol AI truk';
|
||||
}
|
||||
@@ -694,10 +705,17 @@
|
||||
return (data && data.items) || [];
|
||||
}
|
||||
|
||||
function plateLooksOff(v) {
|
||||
const c = v.toUpperCase().replace(/[\s.\-_]+/g, '');
|
||||
return c.length > 0 && (!/^[A-Z0-9]{4,12}$/.test(c) || !/[A-Z]/.test(c));
|
||||
}
|
||||
|
||||
function onStartPlateInput() {
|
||||
const plate = document.getElementById('startPlateInput').value.trim();
|
||||
const raw = document.getElementById('startPlateInput').value;
|
||||
document.getElementById('startPlateHint').style.display =
|
||||
plateLooksOff(raw) ? 'block' : 'none';
|
||||
const btn = document.getElementById('btnStartConfirm');
|
||||
btn.classList.toggle('btn-disabled', plate.length === 0);
|
||||
btn.classList.toggle('btn-disabled', raw.trim().length === 0);
|
||||
}
|
||||
|
||||
function confirmStartBatch() {
|
||||
|
||||
@@ -41,7 +41,7 @@ def test_repo_config_loads_and_validates(repo_config_path):
|
||||
|
||||
def test_do_block_and_batch_default_mode(repo_config_path):
|
||||
cfg = load_config(repo_config_path)
|
||||
assert cfg.batch.default_mode == "auto"
|
||||
assert cfg.batch.default_mode == "manual"
|
||||
# timeout_seconds = sack-idle (pause); truck_gone_tolerance_seconds = finalize
|
||||
assert cfg.batch.timeout_seconds == 20.0
|
||||
assert cfg.batch.truck_gone_tolerance_seconds == 30.0
|
||||
|
||||
+34
-3
@@ -8,7 +8,9 @@ import pytest
|
||||
from src.do_batch import (
|
||||
BATCH_MODES,
|
||||
OCR_ENGINES,
|
||||
canonical_plate,
|
||||
classify_unit_token,
|
||||
pretty_plate,
|
||||
empty_do_settings,
|
||||
group_dos_by_plate,
|
||||
is_valid_batch_mode,
|
||||
@@ -23,6 +25,35 @@ from src.do_batch import (
|
||||
from src.do_ocr import parse_fields_from_text
|
||||
|
||||
|
||||
def test_canonical_plate_strips_separators():
|
||||
assert canonical_plate(" b 1234 xyz ") == "B1234XYZ"
|
||||
assert canonical_plate("B-1234-XYZ") == "B1234XYZ"
|
||||
assert canonical_plate("B.1234.XYZ") == "B1234XYZ"
|
||||
assert canonical_plate("b1234xyz") == canonical_plate("B 1234 XYZ")
|
||||
assert canonical_plate(None) == ""
|
||||
assert canonical_plate(" ") == ""
|
||||
|
||||
|
||||
def test_pretty_plate_display_form():
|
||||
assert pretty_plate("B1234XYZ") == "B 1234 XYZ"
|
||||
assert pretty_plate("b 1234. xyz") == "B 1234 XYZ"
|
||||
assert pretty_plate("B1") == "B 1"
|
||||
assert pretty_plate("1234AB") == "1234 AB"
|
||||
assert pretty_plate("") == ""
|
||||
assert pretty_plate("!!") == "!!" # not a plate pattern, raw fallback
|
||||
|
||||
|
||||
def test_group_dos_by_plate_ignores_spacing_only_difference():
|
||||
dos = [
|
||||
{"no_do": "D1", "plate": "B 1234 XYZ"},
|
||||
{"no_do": "D2", "plate": "b1234xyz"},
|
||||
{"no_do": "D3", "plate": "B-1234-XYZ"},
|
||||
]
|
||||
plate, out = group_dos_by_plate(dos)
|
||||
assert plate == "B1234XYZ"
|
||||
assert all(d["plate"] == "B1234XYZ" for d in out)
|
||||
|
||||
|
||||
def test_normalize_batch_mode_defaults_auto():
|
||||
assert normalize_batch_mode(None) == "auto"
|
||||
assert normalize_batch_mode("bogus") == "auto"
|
||||
@@ -59,7 +90,7 @@ def test_group_dos_same_plate():
|
||||
{"no_do": "DO-2", "plate": "B 1234 XYZ"},
|
||||
]
|
||||
plate, out = group_dos_by_plate(dos)
|
||||
assert plate == "B 1234 XYZ"
|
||||
assert plate == "B1234XYZ"
|
||||
assert [d["no_do"] for d in out] == ["DO-1", "DO-2"]
|
||||
|
||||
|
||||
@@ -293,10 +324,10 @@ def test_manual_start_with_plate_normalizes_and_persists(dash_client):
|
||||
assert res.status_code == 200
|
||||
body = res.get_json()
|
||||
assert body["success"] is True
|
||||
assert body["plate"] == "B 1234 XYZ"
|
||||
assert body["plate"] == "B1234XYZ"
|
||||
assert state_path.exists()
|
||||
state = json.loads(state_path.read_text())
|
||||
assert state["plate"] == "B 1234 XYZ"
|
||||
assert state["plate"] == "B1234XYZ"
|
||||
assert state["batch_mode"] == "manual"
|
||||
assert state["manual_control"] is True
|
||||
|
||||
|
||||
Reference in new issue
Block a user