feat(wip): preserve filters between Overview and Detail with thundering-herd fix
URL is now single source of truth for filter state (workorder, lotid, package, type, status) across WIP Overview and Detail pages. Drill-down carries all filters + status; back button dynamically reflects Detail changes. Backend Detail API now supports pj_type filter parameter. Harden concurrency: add pagehide abort for MPA navigation, double-check locking on Redis JSON parse and snapshot build to prevent thread pool saturation during rapid page switching. Fix watchdog setsid and PID discovery. Fix test_realtime_equipment_cache RUNCARDLOTID field mismatch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,10 +4,10 @@
|
||||
Tests aggregation, status classification, and cache query functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import json
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import json
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class TestClassifyStatus:
|
||||
@@ -98,6 +98,7 @@ class TestAggregateByResourceid:
|
||||
'OBJECTCATEGORY': 'ASSEMBLY',
|
||||
'EQUIPMENTASSETSSTATUS': 'PRD',
|
||||
'EQUIPMENTASSETSSTATUSREASON': None,
|
||||
'RUNCARDLOTID': 'LOT001',
|
||||
'JOBORDER': 'JO001',
|
||||
'JOBSTATUS': 'RUN',
|
||||
'SYMPTOMCODE': None,
|
||||
@@ -127,6 +128,7 @@ class TestAggregateByResourceid:
|
||||
'OBJECTCATEGORY': 'ASSEMBLY',
|
||||
'EQUIPMENTASSETSSTATUS': 'PRD',
|
||||
'EQUIPMENTASSETSSTATUSREASON': None,
|
||||
'RUNCARDLOTID': 'LOT001',
|
||||
'JOBORDER': 'JO001',
|
||||
'JOBSTATUS': 'RUN',
|
||||
'SYMPTOMCODE': None,
|
||||
@@ -141,6 +143,7 @@ class TestAggregateByResourceid:
|
||||
'OBJECTCATEGORY': 'ASSEMBLY',
|
||||
'EQUIPMENTASSETSSTATUS': 'PRD',
|
||||
'EQUIPMENTASSETSSTATUSREASON': None,
|
||||
'RUNCARDLOTID': 'LOT002',
|
||||
'JOBORDER': 'JO002',
|
||||
'JOBSTATUS': 'RUN',
|
||||
'SYMPTOMCODE': None,
|
||||
@@ -155,6 +158,7 @@ class TestAggregateByResourceid:
|
||||
'OBJECTCATEGORY': 'ASSEMBLY',
|
||||
'EQUIPMENTASSETSSTATUS': 'PRD',
|
||||
'EQUIPMENTASSETSSTATUSREASON': None,
|
||||
'RUNCARDLOTID': 'LOT003',
|
||||
'JOBORDER': 'JO003',
|
||||
'JOBSTATUS': 'RUN',
|
||||
'SYMPTOMCODE': None,
|
||||
@@ -184,6 +188,7 @@ class TestAggregateByResourceid:
|
||||
'OBJECTCATEGORY': 'ASSEMBLY',
|
||||
'EQUIPMENTASSETSSTATUS': 'PRD',
|
||||
'EQUIPMENTASSETSSTATUSREASON': None,
|
||||
'RUNCARDLOTID': 'LOT001',
|
||||
'JOBORDER': 'JO001',
|
||||
'JOBSTATUS': 'RUN',
|
||||
'SYMPTOMCODE': None,
|
||||
@@ -198,6 +203,7 @@ class TestAggregateByResourceid:
|
||||
'OBJECTCATEGORY': 'WAFERSORT',
|
||||
'EQUIPMENTASSETSSTATUS': 'SBY',
|
||||
'EQUIPMENTASSETSSTATUSREASON': 'Waiting',
|
||||
'RUNCARDLOTID': None,
|
||||
'JOBORDER': None,
|
||||
'JOBSTATUS': None,
|
||||
'SYMPTOMCODE': None,
|
||||
@@ -216,7 +222,7 @@ class TestAggregateByResourceid:
|
||||
|
||||
assert r1['LOT_COUNT'] == 1
|
||||
assert r1['STATUS_CATEGORY'] == 'PRODUCTIVE'
|
||||
assert r2['LOT_COUNT'] == 1
|
||||
assert r2['LOT_COUNT'] == 0
|
||||
assert r2['STATUS_CATEGORY'] == 'STANDBY'
|
||||
|
||||
def test_handles_empty_records(self):
|
||||
@@ -298,17 +304,17 @@ class TestGetEquipmentStatusById:
|
||||
"""Test get_equipment_status_by_id function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_modules(self):
|
||||
"""Reset module state before each test."""
|
||||
import mes_dashboard.core.redis_client as rc
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
yield
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
def reset_modules(self):
|
||||
"""Reset module state before each test."""
|
||||
import mes_dashboard.core.redis_client as rc
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
yield
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
|
||||
def test_returns_none_when_redis_unavailable(self):
|
||||
"""Test returns None when Redis client unavailable."""
|
||||
@@ -356,17 +362,17 @@ class TestGetEquipmentStatusByIds:
|
||||
"""Test get_equipment_status_by_ids function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_modules(self):
|
||||
"""Reset module state before each test."""
|
||||
import mes_dashboard.core.redis_client as rc
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
yield
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
def reset_modules(self):
|
||||
"""Reset module state before each test."""
|
||||
import mes_dashboard.core.redis_client as rc
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
yield
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
|
||||
def test_returns_empty_for_empty_input(self):
|
||||
"""Test returns empty list for empty input."""
|
||||
@@ -412,17 +418,17 @@ class TestGetAllEquipmentStatus:
|
||||
"""Test get_all_equipment_status function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_modules(self):
|
||||
"""Reset module state before each test."""
|
||||
import mes_dashboard.core.redis_client as rc
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
yield
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
def reset_modules(self):
|
||||
"""Reset module state before each test."""
|
||||
import mes_dashboard.core.redis_client as rc
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
yield
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
|
||||
def test_returns_empty_when_redis_unavailable(self):
|
||||
"""Test returns empty list when Redis unavailable."""
|
||||
@@ -465,7 +471,7 @@ class TestGetAllEquipmentStatus:
|
||||
assert result[1]['RESOURCEID'] == 'R002'
|
||||
|
||||
|
||||
class TestGetEquipmentStatusCacheStatus:
|
||||
class TestGetEquipmentStatusCacheStatus:
|
||||
"""Test get_equipment_status_cache_status function."""
|
||||
|
||||
@pytest.fixture
|
||||
@@ -505,44 +511,44 @@ class TestGetEquipmentStatusCacheStatus:
|
||||
from mes_dashboard.services.realtime_equipment_cache import get_equipment_status_cache_status
|
||||
result = get_equipment_status_cache_status()
|
||||
|
||||
assert result['enabled'] is True
|
||||
assert result['loaded'] is True
|
||||
assert result['count'] == 1000
|
||||
|
||||
|
||||
class TestEquipmentProcessLevelCache:
|
||||
"""Test bounded process-level cache behavior for equipment status."""
|
||||
|
||||
def test_lru_eviction_prefers_recent_keys(self):
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
|
||||
cache = eq._ProcessLevelCache(ttl_seconds=60, max_size=2)
|
||||
cache.set("a", [{"RESOURCEID": "R001"}])
|
||||
cache.set("b", [{"RESOURCEID": "R002"}])
|
||||
assert cache.get("a") is not None # refresh recency
|
||||
cache.set("c", [{"RESOURCEID": "R003"}]) # should evict "b"
|
||||
|
||||
assert cache.get("b") is None
|
||||
assert cache.get("a") is not None
|
||||
assert cache.get("c") is not None
|
||||
|
||||
def test_global_equipment_cache_uses_bounded_config(self):
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
|
||||
assert eq.EQUIPMENT_PROCESS_CACHE_MAX_SIZE >= 1
|
||||
assert eq._equipment_status_cache.max_size == eq.EQUIPMENT_PROCESS_CACHE_MAX_SIZE
|
||||
|
||||
|
||||
class TestSharedQueryFragments:
|
||||
"""Test shared SQL fragment governance for equipment cache."""
|
||||
|
||||
def test_equipment_load_uses_shared_sql_fragment(self):
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
from mes_dashboard.services.sql_fragments import EQUIPMENT_STATUS_SELECT_SQL
|
||||
|
||||
mock_df = pd.DataFrame([{"RESOURCEID": "R001", "EQUIPMENTID": "EQ-01"}])
|
||||
with patch.object(eq, "read_sql_df", return_value=mock_df) as mock_read:
|
||||
eq._load_equipment_status_from_oracle()
|
||||
|
||||
sql = mock_read.call_args[0][0]
|
||||
assert sql.strip() == EQUIPMENT_STATUS_SELECT_SQL.strip()
|
||||
assert result['enabled'] is True
|
||||
assert result['loaded'] is True
|
||||
assert result['count'] == 1000
|
||||
|
||||
|
||||
class TestEquipmentProcessLevelCache:
|
||||
"""Test bounded process-level cache behavior for equipment status."""
|
||||
|
||||
def test_lru_eviction_prefers_recent_keys(self):
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
|
||||
cache = eq._ProcessLevelCache(ttl_seconds=60, max_size=2)
|
||||
cache.set("a", [{"RESOURCEID": "R001"}])
|
||||
cache.set("b", [{"RESOURCEID": "R002"}])
|
||||
assert cache.get("a") is not None # refresh recency
|
||||
cache.set("c", [{"RESOURCEID": "R003"}]) # should evict "b"
|
||||
|
||||
assert cache.get("b") is None
|
||||
assert cache.get("a") is not None
|
||||
assert cache.get("c") is not None
|
||||
|
||||
def test_global_equipment_cache_uses_bounded_config(self):
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
|
||||
assert eq.EQUIPMENT_PROCESS_CACHE_MAX_SIZE >= 1
|
||||
assert eq._equipment_status_cache.max_size == eq.EQUIPMENT_PROCESS_CACHE_MAX_SIZE
|
||||
|
||||
|
||||
class TestSharedQueryFragments:
|
||||
"""Test shared SQL fragment governance for equipment cache."""
|
||||
|
||||
def test_equipment_load_uses_shared_sql_fragment(self):
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
from mes_dashboard.services.sql_fragments import EQUIPMENT_STATUS_SELECT_SQL
|
||||
|
||||
mock_df = pd.DataFrame([{"RESOURCEID": "R001", "EQUIPMENTID": "EQ-01"}])
|
||||
with patch.object(eq, "read_sql_df", return_value=mock_df) as mock_read:
|
||||
eq._load_equipment_status_from_oracle()
|
||||
|
||||
sql = mock_read.call_args[0][0]
|
||||
assert sql.strip() == EQUIPMENT_STATUS_SELECT_SQL.strip()
|
||||
|
||||
Reference in New Issue
Block a user