From 42f75fecefeec083d18eb1bea03525a5b586bce4 Mon Sep 17 00:00:00 2001 From: Mia Wu Date: Mon, 13 Jul 2026 02:43:52 -0400 Subject: [PATCH] clean up calendar display, added separated device runtime overrides --- backend/app.py | 28 ++++-- backend/parser.py | 41 ++++++-- backend/test_runtime_overrides.py | 62 ++++++++++++ frontend/src/App.jsx | 25 ++++- frontend/src/components/Calendar.jsx | 111 +++++++++++++++------ frontend/src/components/DayColumn.jsx | 15 ++- frontend/src/components/SettingsModal.jsx | 112 +++++++++++++++------- frontend/src/components/ShiftSlot.jsx | 33 ++++--- 8 files changed, 317 insertions(+), 110 deletions(-) create mode 100644 backend/test_runtime_overrides.py diff --git a/backend/app.py b/backend/app.py index 92b63a1..4796422 100644 --- a/backend/app.py +++ b/backend/app.py @@ -44,7 +44,7 @@ def _smb_credentials_from_settings(settings: dict[str, Any]) -> dict[str, str]: } -def _runtime_overrides_from_settings(settings: dict[str, Any]) -> dict[str, int]: +def _runtime_overrides_from_settings(settings: dict[str, Any]) -> dict[str, dict[str, int]]: def _parse_positive_int(value: Any) -> int | None: if value is None: return None @@ -58,15 +58,23 @@ def _runtime_overrides_from_settings(settings: dict[str, Any]) -> dict[str, int] return None return parsed if parsed > 0 else None - overrides: dict[str, int] = {} - for test_type, key in ( - ("P2P", "p2pRuntimeMinutes"), - ("COE", "coeRuntimeMinutes"), - ("P3P", "p3pRuntimeMinutes"), - ): - minutes = _parse_positive_int(settings.get(key)) - if minutes is not None: - overrides[test_type] = minutes + legacy_overrides = { + "P2P": _parse_positive_int(settings.get("p2pRuntimeMinutes")), + "COE": _parse_positive_int(settings.get("coeRuntimeMinutes")), + "P3P": _parse_positive_int(settings.get("p3pRuntimeMinutes")), + } + + overrides: dict[str, dict[str, int]] = {} + for device_key, field_prefix in ((db.DEVICE_DUT, "dut"), (db.DEVICE_REF, "ref")): + device_overrides: dict[str, int] = {} + for test_type, suffix in (("P2P", "P2p"), ("COE", "Coe"), ("P3P", "P3p")): + minutes = _parse_positive_int(settings.get(f"{field_prefix}{suffix}RuntimeMinutes")) + if minutes is None: + minutes = legacy_overrides[test_type] + if minutes is not None: + device_overrides[test_type] = minutes + if device_overrides: + overrides[device_key] = device_overrides return overrides diff --git a/backend/parser.py b/backend/parser.py index 910fa7e..0c3475b 100644 --- a/backend/parser.py +++ b/backend/parser.py @@ -236,13 +236,17 @@ def parse_target_csv( runtime_overrides: dict[str, Any] | None = None, ) -> ParseResult: paths = _resolve_csv_paths(csv_path, smb_credentials=smb_credentials) - runtime_defaults = _resolve_runtime_defaults(runtime_overrides) + runtime_defaults_by_device = _resolve_runtime_defaults(runtime_overrides) all_tests: list[TestRecord] = [] all_warnings: list[str] = [] seen_test_keys: set[tuple[str, str]] = set() for path in paths: - parsed = _parse_single_csv(path, smb_credentials=smb_credentials, runtime_defaults=runtime_defaults) + parsed = _parse_single_csv( + path, + smb_credentials=smb_credentials, + runtime_defaults_by_device=runtime_defaults_by_device, + ) all_warnings.extend(parsed.warnings) for record in parsed.tests: key = (record.test_id, record.device) @@ -257,13 +261,13 @@ def parse_target_csv( return ParseResult(tests=all_tests, warnings=all_warnings) -def _resolve_runtime_defaults(runtime_overrides: dict[str, Any] | None) -> dict[str, int]: +def _coerce_runtime_defaults(raw_overrides: dict[str, Any] | None) -> dict[str, int]: defaults = dict(RUNTIME_DEFAULTS) - if not runtime_overrides: + if not raw_overrides: return defaults for test_type in ("P2P", "COE", "P3P"): - raw_value = runtime_overrides.get(test_type) + raw_value = raw_overrides.get(test_type) if raw_value is None: continue if isinstance(raw_value, str): @@ -280,6 +284,25 @@ def _resolve_runtime_defaults(runtime_overrides: dict[str, Any] | None) -> dict[ return defaults +def _resolve_runtime_defaults(runtime_overrides: dict[str, Any] | None) -> dict[str, dict[str, int]]: + legacy_defaults = _coerce_runtime_defaults(runtime_overrides) + if not runtime_overrides: + return { + DEVICE_DUT: dict(legacy_defaults), + DEVICE_REF: dict(legacy_defaults), + } + + resolved: dict[str, dict[str, int]] = {} + for device in (DEVICE_DUT, DEVICE_REF): + device_defaults = dict(legacy_defaults) + raw_device_overrides = runtime_overrides.get(device) + if isinstance(raw_device_overrides, dict): + device_defaults.update(_coerce_runtime_defaults(raw_device_overrides)) + resolved[device] = device_defaults + + return resolved + + def _resolve_csv_paths( csv_path: str | Path | list[str | Path] | tuple[str | Path, ...], @@ -318,7 +341,7 @@ def _open_csv_handle(path: str, smb_credentials: dict[str, Any] | None): def _parse_single_csv( path: str, smb_credentials: dict[str, Any] | None, - runtime_defaults: dict[str, int], + runtime_defaults_by_device: dict[str, dict[str, int]], ) -> ParseResult: with _open_csv_handle(path, smb_credentials=smb_credentials) as handle: reader = csv.DictReader(handle) @@ -352,7 +375,6 @@ def _parse_single_csv( 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 - estimated_minutes = runtime_defaults.get(test_type, RUNTIME_DEFAULTS[test_type]) victim_band_source = "Victim Band" if csv_format == "p2p_coe" else "Band" victim_band = _normalize_victim_band(_row_get(row, victim_band_source)) @@ -376,7 +398,7 @@ def _parse_single_csv( victim_band=victim_band, config=config, throttled=throttled, - estimated_minutes=estimated_minutes, + estimated_minutes=RUNTIME_DEFAULTS[test_type], status="pending", excluded=False, raw_payload=row, @@ -401,6 +423,7 @@ def _parse_single_csv( pairs = sorted(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) tests.append( TestRecord( test_id=record.test_id, @@ -414,7 +437,7 @@ def _parse_single_csv( victim_band=record.victim_band, config=record.config, throttled=record.throttled, - estimated_minutes=record.estimated_minutes, + estimated_minutes=device_runtime_defaults.get(record.test_type, RUNTIME_DEFAULTS[record.test_type]), status=record.status, excluded=record.excluded, raw_payload=record.raw_payload, diff --git a/backend/test_runtime_overrides.py b/backend/test_runtime_overrides.py new file mode 100644 index 0000000..5757ba7 --- /dev/null +++ b/backend/test_runtime_overrides.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import csv +from pathlib import Path +import sys +from tempfile import TemporaryDirectory +import unittest + +sys.path.append(str(Path(__file__).resolve().parent)) + +from db import DEVICE_DUT, DEVICE_REF +from parser import P2P_COE_REQUIRED_COLUMNS, parse_target_csv + + +def _write_p2p_csv(directory: str, test_id: str = "P2PRXAX001") -> Path: + path = Path(directory) / "runtime-overrides.csv" + row = {column: "" for column in P2P_COE_REQUIRED_COLUMNS} + row["Priority"] = "1" + row["Index"] = "1" + row["TC ID"] = test_id + + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=P2P_COE_REQUIRED_COLUMNS) + writer.writeheader() + writer.writerow(row) + + return path + + +class RuntimeOverridesParseTests(unittest.TestCase): + def test_parse_target_csv_applies_device_specific_runtime_overrides(self) -> None: + with TemporaryDirectory() as tmpdir: + csv_path = _write_p2p_csv(tmpdir) + + parsed = parse_target_csv( + csv_path, + runtime_overrides={ + DEVICE_DUT: {"P2P": 91}, + DEVICE_REF: {"P2P": 123}, + }, + ) + + by_device = {record.device: record for record in parsed.tests} + self.assertEqual(by_device[DEVICE_DUT].estimated_minutes, 91) + self.assertEqual(by_device[DEVICE_REF].estimated_minutes, 123) + + def test_parse_target_csv_keeps_legacy_runtime_override_for_both_devices(self) -> None: + with TemporaryDirectory() as tmpdir: + csv_path = _write_p2p_csv(tmpdir) + + parsed = parse_target_csv( + csv_path, + runtime_overrides={"P2P": 88}, + ) + + by_device = {record.device: record for record in parsed.tests} + self.assertEqual(by_device[DEVICE_DUT].estimated_minutes, 88) + self.assertEqual(by_device[DEVICE_REF].estimated_minutes, 88) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ef2c939..b28c188 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -127,17 +127,34 @@ const DEFAULT_SETTINGS = { smbUsername: '', smbPassword: '', smbDomain: '', - p2pRuntimeMinutes: '', - coeRuntimeMinutes: '', - p3pRuntimeMinutes: '', + dutP2pRuntimeMinutes: '', + dutCoeRuntimeMinutes: '', + dutP3pRuntimeMinutes: '', + refP2pRuntimeMinutes: '', + refCoeRuntimeMinutes: '', + refP3pRuntimeMinutes: '', testExclusion: '', holidays: '', } function sanitizeSettings(saved = {}) { - const { startDateOverride: _ignored, ...rest } = saved + const { + p2pRuntimeMinutes, + coeRuntimeMinutes, + p3pRuntimeMinutes, + ...rest + } = saved + + delete rest.startDateOverride + return { ...DEFAULT_SETTINGS, + dutP2pRuntimeMinutes: rest.dutP2pRuntimeMinutes ?? p2pRuntimeMinutes ?? '', + dutCoeRuntimeMinutes: rest.dutCoeRuntimeMinutes ?? coeRuntimeMinutes ?? '', + dutP3pRuntimeMinutes: rest.dutP3pRuntimeMinutes ?? p3pRuntimeMinutes ?? '', + refP2pRuntimeMinutes: rest.refP2pRuntimeMinutes ?? p2pRuntimeMinutes ?? '', + refCoeRuntimeMinutes: rest.refCoeRuntimeMinutes ?? coeRuntimeMinutes ?? '', + refP3pRuntimeMinutes: rest.refP3pRuntimeMinutes ?? p3pRuntimeMinutes ?? '', ...rest, } } diff --git a/frontend/src/components/Calendar.jsx b/frontend/src/components/Calendar.jsx index a09df90..197e5d9 100644 --- a/frontend/src/components/Calendar.jsx +++ b/frontend/src/components/Calendar.jsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import DayColumn from './DayColumn' import { getDeviceAccentClass } from './TestCard' @@ -58,6 +59,11 @@ export default function Calendar({ windowLookup = new Map(), onWindowSelect, }) { + const SLOT_CHROME_PX = 10 + const TEST_CARD_ROW_HEIGHT_PX = 22 + const TEST_CARD_ROW_GAP_PX = 2 + const DAY_HEADER_HEIGHT_PX = 48 + const today = new Date() today.setHours(0, 0, 0, 0) @@ -68,6 +74,36 @@ export default function Calendar({ // Reorder columns: Sun Mon Tue Wed Thu Fri Sat // weekStart is Monday, so days[0]=Mon ... days[6]=Sun → put Sunday first const orderedDays = [ ...days.slice(0, 7)] + const shiftMinRows = useMemo(() => { + const minima = { shift2: 1, shift3: 1, nextDayShift1: 1 } + + for (const date of orderedDays) { + const key = toDateKey(date) + const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] } + const nextKey = toDateKey(addDays(date, 1)) + const nextDayShift1 = scheduleData[nextKey]?.shift1 ?? [] + + minima.shift2 = Math.max(minima.shift2, shifts.shift2.length || 1) + minima.shift3 = Math.max(minima.shift3, shifts.shift3.length || 1) + minima.nextDayShift1 = Math.max(minima.nextDayShift1, nextDayShift1.length || 1) + } + + return minima + }, [orderedDays, scheduleData]) + const slotHeights = useMemo(() => { + const calcSlotHeight = (rows) => { + const safeRows = Math.max(1, rows || 1) + const contentHeight = (safeRows * TEST_CARD_ROW_HEIGHT_PX) + ((safeRows - 1) * TEST_CARD_ROW_GAP_PX) + return contentHeight + SLOT_CHROME_PX + } + + return { + shift2: calcSlotHeight(shiftMinRows.shift2), + shift3: calcSlotHeight(shiftMinRows.shift3), + nextDayShift1: calcSlotHeight(shiftMinRows.nextDayShift1), + } + }, [shiftMinRows]) + const totalShiftHeightPx = slotHeights.shift2 + slotHeights.shift3 + slotHeights.nextDayShift1 const deviceLegendItems = Array.from( new Set( Object.values(scheduleData) @@ -135,37 +171,50 @@ export default function Calendar({ {/* 7-day grid */} -
- {orderedDays.map((date) => { - const key = toDateKey(date) - const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] } - const isToday = date.getTime() === today.getTime() - const isWeekend = date.getDay() === 0 || date.getDay() === 6 - // Get next day's shift1 for display - const nextDate = addDays(date, 1) - const nextKey = toDateKey(nextDate) - const nextDayShift1 = scheduleData[nextKey]?.shift1 ?? [] - // Which shifts on this date are part of the active test window? - const activeShifts = new Set() - if (key === activeWindow.shift3Date) activeShifts.add(3) - if (key === activeWindow.shift1Date) activeShifts.add(1) - // Check if next day's shift1 is active - const nextDayShift1Active = nextKey === activeWindow.shift1Date - return ( - - ) - })} +
+
+
+
+ 9AM + 5PM + 12AM +
+
+ +
+ {orderedDays.map((date) => { + const key = toDateKey(date) + const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] } + const isToday = date.getTime() === today.getTime() + const isWeekend = date.getDay() === 0 || date.getDay() === 6 + // Get next day's shift1 for display + const nextDate = addDays(date, 1) + const nextKey = toDateKey(nextDate) + const nextDayShift1 = scheduleData[nextKey]?.shift1 ?? [] + // Which shifts on this date are part of the active test window? + const activeShifts = new Set() + if (key === activeWindow.shift3Date) activeShifts.add(3) + if (key === activeWindow.shift1Date) activeShifts.add(1) + // Check if next day's shift1 is active + const nextDayShift1Active = nextKey === activeWindow.shift1Date + return ( + + ) + })} +
{/* Legend */} diff --git a/frontend/src/components/DayColumn.jsx b/frontend/src/components/DayColumn.jsx index 7120877..120349f 100644 --- a/frontend/src/components/DayColumn.jsx +++ b/frontend/src/components/DayColumn.jsx @@ -10,6 +10,8 @@ export default function DayColumn({ nextDayShift1 = [], nextDayShift1Active = false, showShift2 = false, + shiftMinRows = { shift2: 1, shift3: 1, nextDayShift1: 1 }, + slotHeights = { shift2: 0, shift3: 0, nextDayShift1: 0 }, windowLookup = new Map(), onWindowSelect, }) { @@ -27,7 +29,7 @@ export default function DayColumn({
{/* Day header — subtle today ring, no full-column highlight */}
@@ -40,29 +42,32 @@ export default function DayColumn({
{/* Shifts */} -
+
diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx index 54801d2..cb41c11 100644 --- a/frontend/src/components/SettingsModal.jsx +++ b/frontend/src/components/SettingsModal.jsx @@ -152,40 +152,84 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {

Runtime Overrides

-
- - set('p2pRuntimeMinutes', e.target.value)} - /> - - - set('coeRuntimeMinutes', e.target.value)} - /> - - - set('p3pRuntimeMinutes', e.target.value)} - /> - +
+
+

DUT

+
+ + set('dutP2pRuntimeMinutes', e.target.value)} + /> + + + set('dutCoeRuntimeMinutes', e.target.value)} + /> + + + set('dutP3pRuntimeMinutes', e.target.value)} + /> + +
+
+ +
+

REF

+
+ + set('refP2pRuntimeMinutes', e.target.value)} + /> + + + set('refCoeRuntimeMinutes', e.target.value)} + /> + + + set('refP3pRuntimeMinutes', e.target.value)} + /> + +
+
diff --git a/frontend/src/components/ShiftSlot.jsx b/frontend/src/components/ShiftSlot.jsx index f0b094e..e7a2551 100644 --- a/frontend/src/components/ShiftSlot.jsx +++ b/frontend/src/components/ShiftSlot.jsx @@ -1,16 +1,25 @@ import TestCard from './TestCard' +const TEST_CARD_ROW_HEIGHT_PX = 22 +const TEST_CARD_ROW_GAP_PX = 2 + export default function ShiftSlot({ - label, tests = [], visible = true, active = false, windowDetails = null, + minContentRows = 1, + slotHeightPx = null, onWindowSelect, }) { if (!visible) return null const clickable = Boolean(windowDetails && onWindowSelect) + const targetRows = Math.max(1, minContentRows) + const minContentHeightPx = (targetRows * TEST_CARD_ROW_HEIGHT_PX) + ((targetRows - 1) * TEST_CARD_ROW_GAP_PX) + const outerMinHeightPx = Number.isFinite(slotHeightPx) && slotHeightPx > 0 + ? slotHeightPx + : minContentHeightPx + 10 return (
-
-

- {label} -

- {clickable && ( - - - - )} -
-
- {tests.length === 0 ? ( - - ) : ( +
+ {tests.length === 0 ? null : ( tests.map((test) => ) )}