fix test bundling bug

This commit is contained in:
2026-07-17 15:17:08 -04:00
parent b5dd611f82
commit a49772aece
5 changed files with 159 additions and 66 deletions
+24 -16
View File
@@ -16,6 +16,7 @@ os.environ.setdefault("DUT", db.DEVICE_DUT)
os.environ.setdefault("REF", db.DEVICE_REF)
from scheduler import Scheduler, Test as SchedulerTest
from scanner import resolve_runtime_path
from test_config import build_bundle_test_configs, build_config_rows
from test_window import get_shift_capacity_for_date, get_shift_sequence, is_off_day
from watcher import configure_result_watcher, stop_result_watcher
@@ -79,6 +80,28 @@ def _runtime_overrides_from_settings(settings: dict[str, Any]) -> dict[str, dict
return overrides
def _is_explicit_path(path_value: str) -> bool:
return (
path_value.startswith(("/", "\\\\", "//"))
or "\\" in path_value
or ":" in path_value
)
def _resolve_requested_csv_path(requested_path: str) -> str | Path:
received_path = str(requested_path).strip()
resolved_path = str(resolve_runtime_path(received_path)).strip()
if _is_explicit_path(received_path) or _is_explicit_path(resolved_path):
return resolved_path
data_dir_path = APP_ROOT / "data" / received_path
if data_dir_path.exists():
return data_dir_path
return APP_ROOT / received_path
class CompileScheduleRequest(BaseModel):
start_date: str | None = Field(default=None, description="YYYY-MM-DD")
rule: str = ""
@@ -314,22 +337,7 @@ def get_settings() -> dict[str, Any]:
@app.post("/api/tests/load")
def load_tests(request: LoadTestsRequest) -> dict[str, Any]:
received_path = request.csv_path
# Handle absolute paths (Windows or Unix) by extracting just the filename
if "\\" in received_path or ":" in received_path or received_path.startswith("/"):
# Absolute path (Windows with backslash or drive letter, or Unix with /)
# Extract just the filename from the path
filename = received_path.replace("\\", "/").split("/")[-1]
data_dir_path = APP_ROOT / "data" / filename
if data_dir_path.exists():
csv_path = data_dir_path
else:
csv_path = APP_ROOT / filename
else:
# Relative paths - prepend APP_ROOT
csv_path = APP_ROOT / received_path
csv_path = _resolve_requested_csv_path(request.csv_path)
settings = db.read_settings(DB_PATH)
smb_credentials = _smb_credentials_from_settings(settings)
+31 -10
View File
@@ -27,6 +27,7 @@ class TestRecord:
test_type: str
rotation: str | None
rx_tx: str | None
power_mode: str | None
has_coe_pair: bool
coe_pairing: list[str]
priority: int
@@ -80,6 +81,7 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
test_type TEXT NOT NULL CHECK (test_type IN ('P2P', 'COE', 'P3P')),
rotation TEXT,
rx_tx TEXT CHECK (rx_tx IN ('RX', 'TX') OR rx_tx IS NULL),
power_mode TEXT,
has_coe_pair INTEGER NOT NULL DEFAULT 0,
coe_pairing_json TEXT,
priority INTEGER NOT NULL CHECK (priority BETWEEN 1 AND 5),
@@ -132,6 +134,7 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
_ensure_column(conn, "tests", "has_coe_pair", "INTEGER NOT NULL DEFAULT 0")
_ensure_column(conn, "tests", "coe_pairing_json", "TEXT")
_ensure_column(conn, "tests", "config_json", "TEXT")
_ensure_column(conn, "tests", "power_mode", "TEXT")
_ensure_column(conn, "tests", "victim_band", "TEXT")
_ensure_column(conn, "tests", "excluded", "INTEGER NOT NULL DEFAULT 0")
_ensure_column(conn, "tests", "throttled", "INTEGER NOT NULL DEFAULT 0")
@@ -169,6 +172,7 @@ def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> in
r.test_type,
r.rotation,
r.rx_tx,
r.power_mode,
int(r.has_coe_pair),
json.dumps(r.coe_pairing or []),
json.dumps(r.config),
@@ -187,15 +191,16 @@ def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> in
conn.executemany(
"""
INSERT INTO tests(
test_id, device, test_type, rotation, rx_tx, has_coe_pair,
test_id, device, test_type, rotation, rx_tx, power_mode, has_coe_pair,
coe_pairing_json, config_json, priority, victim_band, throttled, estimated_minutes, status, excluded, raw_payload, station_testpoint_map
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(test_id, device) DO UPDATE SET
test_type = excluded.test_type,
device = excluded.device,
rotation = excluded.rotation,
rx_tx = excluded.rx_tx,
power_mode = excluded.power_mode,
has_coe_pair = excluded.has_coe_pair,
coe_pairing_json = excluded.coe_pairing_json,
config_json = excluded.config_json,
@@ -277,12 +282,7 @@ def _entry_value(entry: dict[str, str | None], key: str) -> str:
def _record_power_mode(record: TestRecord) -> str:
power_mode = _entry_value(_band_entry(record.config, "6G"), "power_mode")
if power_mode:
return power_mode
raw_payload = record.raw_payload or {}
return _normalize_text(raw_payload.get("6GHz Power Mode"))
return _normalize_text(record.power_mode)
def _match_any_band_value(record: TestRecord, key: str, token_suffix: str) -> bool:
@@ -369,7 +369,7 @@ def list_schedulable_tests(db_path: str | Path = DB_PATH, rule: str = "") -> lis
"""
SELECT
test_id, device, test_type, rotation, rx_tx, has_coe_pair,
config_json, victim_band,
power_mode, config_json, victim_band,
coe_pairing_json, priority, throttled, estimated_minutes,
excluded, status, raw_payload
FROM tests
@@ -387,6 +387,7 @@ def list_schedulable_tests(db_path: str | Path = DB_PATH, rule: str = "") -> lis
test_type=row["test_type"],
rotation=row["rotation"],
rx_tx=row["rx_tx"],
power_mode=row["power_mode"],
has_coe_pair=bool(row["has_coe_pair"]),
coe_pairing=json.loads(row["coe_pairing_json"] or "[]"),
priority=int(row["priority"]),
@@ -411,7 +412,7 @@ def list_tests_for_device(device: str, db_path: str | Path = DB_PATH) -> list[Te
"""
SELECT
test_id, device, test_type, rotation, rx_tx, has_coe_pair,
config_json, victim_band,
power_mode, config_json, victim_band,
coe_pairing_json, priority, throttled, estimated_minutes,
excluded, status, raw_payload, station_testpoint_map
FROM tests
@@ -429,6 +430,7 @@ def list_tests_for_device(device: str, db_path: str | Path = DB_PATH) -> list[Te
test_type=row["test_type"],
rotation=row["rotation"],
rx_tx=row["rx_tx"],
power_mode=row["power_mode"],
has_coe_pair=bool(row["has_coe_pair"]),
coe_pairing=json.loads(row["coe_pairing_json"] or "[]"),
priority=int(row["priority"]),
@@ -670,6 +672,19 @@ def get_schedule_rows(
return result
def reset_completed_to_pending(db_path: str | Path = DB_PATH) -> int:
"""Reset all 'completed' tests back to 'pending'. Used before a full directory rescan."""
with get_connection(db_path) as conn:
cursor = conn.execute(
"""
UPDATE tests
SET status = 'pending', updated_at = CURRENT_TIMESTAMP
WHERE status = 'completed'
"""
)
return cursor.rowcount
def mark_tests_completed(test_ids_with_device: list[tuple[str, str]], db_path: str | Path = DB_PATH) -> int:
if not test_ids_with_device:
return 0
@@ -811,3 +826,9 @@ def upsert_holidays(dates: list[str], db_path: str | Path = DB_PATH) -> None:
[(d,) for d in dates],
)
def reset_tests(db_path: str | Path = DB_PATH) -> int:
"""Clear all tests records keep empty tests table."""
with get_connection(db_path) as conn:
cursor = conn.execute("DELETE FROM tests")
return cursor.rowcount
+50 -15
View File
@@ -11,6 +11,7 @@ except ModuleNotFoundError:
smbclient = None
from db import DEVICE_DUT, DEVICE_REF, TestRecord
from scanner import resolve_runtime_path
P2P_COE_REQUIRED_COLUMNS = [
"Priority",
@@ -96,6 +97,8 @@ def _normalize_input_path(path_value: str | Path) -> str:
if not path:
return path
path = str(resolve_runtime_path(path)).strip()
# Accept //server/share style and normalize to UNC for smbclient.
if path.startswith("//"):
return "\\\\" + path.lstrip("/").replace("/", "\\")
@@ -104,6 +107,12 @@ def _normalize_input_path(path_value: str | Path) -> str:
if re.match(r"^/\d{1,3}(?:\.\d{1,3}){3}/[^/]+", path):
return "\\\\" + path.lstrip("/").replace("/", "\\")
if os.path.exists(path):
return path
if path.startswith("/"):
return path
return path
@@ -372,9 +381,10 @@ def _parse_single_csv(
test_type = _infer_test_type(test_id)
rx_tx = _infer_rx_tx(test_id)
rotation = _empty_to_none(_row_get(row, "Rotation"))
power_mode = _empty_to_none(_row_get(row, "6GHz Power Mode"))
has_coe_pair = _normalize_yes_no(_row_get(row, "COE Pair")) if csv_format == "p2p_coe" else False
config = _build_config(row, csv_format)
signature = _victim_band_signature(row) if csv_format == "p2p_coe" else None
signature = _first_populated_p2p_signature(row) if csv_format == "p2p_coe" and test_type == "P2P" else None
victim_band_source = "Victim Band" if csv_format == "p2p_coe" else "Band"
victim_band = _normalize_victim_band(_row_get(row, victim_band_source))
@@ -392,6 +402,7 @@ def _parse_single_csv(
test_type=test_type,
rotation=rotation,
rx_tx=rx_tx,
power_mode=power_mode,
has_coe_pair=has_coe_pair,
coe_pairing=[],
priority=priority,
@@ -405,14 +416,18 @@ def _parse_single_csv(
)
records_with_signature.append((record, signature))
# Build COE pairing based on victim band signature and RX/TX+band suffix.
# Build COE pairing based on full signature and RX/TX+band suffix.
# Example key suffixes: RXAX, TXAX, RXBE, TXBE.
coe_by_signature_and_suffix: dict[tuple, list[str]] = {}
coe_by_signature_and_suffix: dict[tuple[tuple[str, ...], str], list[str]] = {}
for record, signature in records_with_signature:
if record.test_type == "COE" and signature is not None:
if record.test_type == "COE":
suffix = _extract_pairing_suffix(record.test_id)
if suffix:
key = (signature, suffix)
if not suffix:
continue
# Index all populated COE band signatures so any one can match the selected P2P signature.
for coe_signature in _all_band_signatures(record.raw_payload or {}):
key = (coe_signature, suffix)
coe_by_signature_and_suffix.setdefault(key, []).append(record.test_id)
for record, signature in records_with_signature:
@@ -420,7 +435,7 @@ def _parse_single_csv(
if record.test_type == "P2P" and signature is not None:
suffix = _extract_pairing_suffix(record.test_id)
key = (signature, suffix) if suffix else None
pairs = sorted(coe_by_signature_and_suffix.get(key, [])) if key else []
pairs = sorted(set(coe_by_signature_and_suffix.get(key, []))) if key else []
for device in (DEVICE_DUT, DEVICE_REF):
device_runtime_defaults = runtime_defaults_by_device.get(device, RUNTIME_DEFAULTS)
@@ -431,6 +446,7 @@ def _parse_single_csv(
test_type=record.test_type,
rotation=record.rotation,
rx_tx=record.rx_tx,
power_mode=record.power_mode,
has_coe_pair=bool(pairs),
coe_pairing=pairs,
priority=record.priority,
@@ -493,13 +509,21 @@ def _empty_to_none(value: str | None) -> str | None:
return cleaned if cleaned else None
def _victim_band_signature(row: dict[str, str]) -> tuple[str, ...] | None:
band = _normalize_victim_band(_row_get(row, "Victim Band"))
if band is None:
def _first_populated_p2p_signature(row: dict[str, str]) -> tuple[str, ...] | None:
for band in ("5G", "6G", "2G"):
signature = _band_signature(row, band)
if signature is not None:
return signature
return None
def _band_signature(row: dict[str, str], band: str) -> tuple[str, ...] | None:
normalized_band = _normalize_victim_band(band)
if normalized_band is None:
return None
# Source CSVs may use either "5G_Test Point" or "5G Test Point" style headers.
prefixes = (f"{band}_", f"{band} ")
prefixes = (f"{normalized_band}_", f"{normalized_band} ")
def _pick_value(suffix: str) -> str:
for prefix in prefixes:
@@ -512,12 +536,24 @@ def _victim_band_signature(row: dict[str, str]) -> tuple[str, ...] | None:
channel = _pick_value("Channel")
rssi = _pick_value("RSSI")
bandwidth = _pick_value("Bandwidth")
direction = _pick_value("Direction")
sta = _pick_value("STA")
# Direction is intentionally ignored for COE pairing matching.
if not all([test_point, channel, rssi, bandwidth]):
if not all([test_point, channel, rssi, bandwidth, direction, sta]):
return None
return (band, test_point, channel, rssi, bandwidth)
return (test_point, channel, bandwidth, rssi, direction, sta)
def _all_band_signatures(row: dict[str, str]) -> list[tuple[str, ...]]:
seen: set[tuple[str, ...]] = set()
signatures: list[tuple[str, ...]] = []
for band in ("5G", "6G", "2G"):
signature = _band_signature(row, band)
if signature is not None and signature not in seen:
seen.add(signature)
signatures.append(signature)
return signatures
def _normalize_victim_band(value: str | None) -> str | None:
@@ -561,7 +597,6 @@ def _build_legacy_config(row: dict[str, str]) -> dict[str, dict[str, str | None]
"sta": _empty_to_none(_row_get(row, "5G_STA")),
},
"6G": {
"power_mode": _empty_to_none(_row_get(row, "6GHz Power Mode")),
"test_point": _empty_to_none(_row_get(row, "6G_Test Point")),
"channel": _empty_to_none(_row_get(row, "6G_Channel")),
"bandwidth": _empty_to_none(_row_get(row, "6G_Bandwidth")),
+42 -18
View File
@@ -7,7 +7,7 @@ try:
except ModuleNotFoundError:
smbclient = None
from db import DEVICE_DUT, DEVICE_REF, mark_tests_completed, mark_overdue_as_rerun
from db import DEVICE_DUT, DEVICE_REF, mark_tests_completed, mark_overdue_as_rerun, reset_completed_to_pending
_SMB_SESSIONS = set()
_SCAN_STATE_LOCK = threading.Lock()
@@ -148,13 +148,12 @@ def resolve_runtime_path(path_value):
if raw_path.startswith("\\\\") or raw_path.startswith("//"):
return raw_path
# Map host paths (Windows or Linux) to the container mount point when running in a container.
if os.name != "nt":
mount_root = os.getenv("HOST_MOUNT_ROOT", "/host").strip() or "/host"
host_root = os.getenv("HOST_BROWSE_ROOT", "").strip()
mount_root = (os.getenv("HOST_MOUNT_ROOT", "/host") or "/host").strip() or "/host"
host_root = (os.getenv("HOST_BROWSE_ROOT", "") or "").strip()
raw_norm = raw_path.replace("\\", "/")
if host_root:
# In container/Linux runtime, translate host-browse paths into mounted container paths.
if os.name != "nt" and host_root:
host_norm = host_root.replace("\\", "/").rstrip("/")
if raw_norm.lower() == host_norm.lower() or raw_norm.lower().startswith(host_norm.lower() + "/"):
relative = raw_norm[len(host_norm):].lstrip("/")
@@ -162,6 +161,16 @@ def resolve_runtime_path(path_value):
return os.path.join(mount_root, *relative.split("/"))
return mount_root
# In native Windows runtime, accept /host/... paths coming from container-oriented settings
# and map them back to HOST_BROWSE_ROOT (for example C:/Users/... ).
if os.name == "nt" and host_root:
mount_norm = mount_root.replace("\\", "/").rstrip("/")
if mount_norm and (raw_norm.lower() == mount_norm.lower() or raw_norm.lower().startswith(mount_norm.lower() + "/")):
relative = raw_norm[len(mount_norm):].lstrip("/")
if relative:
return os.path.join(host_root, *relative.split("/"))
return host_root
return raw_path
@@ -172,25 +181,36 @@ def _normalize_input_path(path_value):
path = resolve_runtime_path(path_value)
path = str(path).strip()
# Normalize forward slashes to backslashes first
path = path.replace("/", "\\")
# Accept //server/share style and normalize to UNC for smbclient.
if path.startswith("//"):
path = path.lstrip("/").replace("/", "\\")
return "\\\\" + path
# Accept \\server\share style UNC paths and ensure proper escaping
# Accept \\server\share style UNC paths and ensure proper escaping.
if path.startswith("\\\\"):
# Clean up any doubled backslashes from replacement
while "\\\\\\" in path:
path = path.replace("\\\\\\", "\\\\")
return path
# Accept //server/share style and normalize to UNC for smbclient.
if path.startswith("//"):
path = path.lstrip("/").replace("/", "\\")
return "\\\\" + path
# Accept /<ipv4>/<share>/... and normalize to UNC for Linux-hosted inputs.
if re.match(r"^\\?\d{1,3}(?:\\\.\d{1,3}){3}\\[^\\]+", path):
if path.startswith("\\"):
path = path.lstrip("\\")
if re.match(r"^/\d{1,3}(?:\.\d{1,3}){3}/[^/]+", path):
return "\\\\" + path.lstrip("/").replace("/", "\\")
# Preserve existing slash-based local paths such as /host/... that are valid
# in the current runtime, so scanner matches watcher behavior.
if os.path.exists(path):
return path
# Preserve slash-based local mount paths even if they are temporarily missing.
if path.startswith("/"):
return path
# Normalize local paths after UNC checks.
path = path.replace("/", "\\")
# Accept \<ipv4>\<share>\... and normalize to UNC.
if re.match(r"^\d{1,3}(?:\.\d{1,3}){3}\\[^\\]+", path):
return "\\\\" + path
return path
@@ -316,6 +336,10 @@ def scan_results(results_dir_dut, results_dir_ref, smb_credentials=None):
for test_id, device in completed_batch:
print(f" - {test_id} on {device}")
reset_count = reset_completed_to_pending()
if reset_count:
print(f"[scanner] reset {reset_count} previously-completed test(s) to pending before resync")
updated_count = mark_tests_completed(completed_batch)
print(f"[scanner] {updated_count} test(s) actually updated in database")
+5
View File
@@ -61,6 +61,11 @@ class Scheduler:
if not bundles:
return f"Error: No test bundles could be created from the provided tests."
# Print all test bundles for debugging
print("Compiled Test Bundles:")
for bundle in bundles:
print(f"Bundle Index: {bundle.index}, Device: {bundle.device}, Priority: {bundle.priority}, Config: {bundle.config}, Total Minutes: {bundle.total_minutes}, Tests: {bundle.tests}")
active_dut_bundles = [b for b in bundles if b.device == DUT]
active_ref_bundles = [b for b in bundles if b.device == REF]