feat(admin-performance): fix RSS measurement, add multi-worker aggregation, and new dashboard panels

- Fix RSS metric: replace peak `ru_maxrss` with current RSS via `process_rss_mb()`
- Add `query_snapshots_aggregated()` with SQLite time-bucket GROUP BY to eliminate
  multi-worker zigzag oscillation in trend charts
- Add Worker Memory Guard panel (GaugeBar + 6 StatCards) to dashboard
- Add Pareto Materialization panel (stats grid + fallback reasons table)
- Fix Redis memory trend to display in MB instead of raw bytes
- Restore 14 accidentally deleted migration JSON config files
- Fix 44 pre-existing test failures: mock targets, redirect expectations,
  filter params, navigation baselines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-03-05 08:52:19 +08:00
parent f6a54f357f
commit 7f409dc17f
23 changed files with 1571 additions and 404 deletions

View File

@@ -8,23 +8,23 @@ Tests the core service functions without database dependencies:
"""
import pytest
from mes_dashboard.services.query_tool_service import (
validate_date_range,
validate_lot_input,
validate_equipment_input,
_resolve_by_lot_id,
_resolve_by_wafer_lot,
_resolve_by_serial_number,
_resolve_by_work_order,
get_lot_split_merge_history,
from mes_dashboard.services.query_tool_service import (
validate_date_range,
validate_lot_input,
validate_equipment_input,
_resolve_by_lot_id,
_resolve_by_wafer_lot,
_resolve_by_serial_number,
_resolve_by_work_order,
get_lot_split_merge_history,
BATCH_SIZE,
MAX_LOT_IDS,
MAX_SERIAL_NUMBERS,
MAX_WORK_ORDERS,
MAX_GD_WORK_ORDERS,
MAX_EQUIPMENTS,
MAX_DATE_RANGE_DAYS,
)
MAX_LOT_IDS,
MAX_SERIAL_NUMBERS,
MAX_WORK_ORDERS,
MAX_GD_WORK_ORDERS,
MAX_EQUIPMENTS,
MAX_DATE_RANGE_DAYS,
)
class TestValidateDateRange:
@@ -52,18 +52,18 @@ class TestValidateDateRange:
assert result is not None
assert str(MAX_DATE_RANGE_DAYS) in result
def test_exactly_max_range(self):
"""Should allow exactly max range days."""
# 365 days from 2025-01-01 is 2026-01-01
result = validate_date_range('2025-01-01', '2026-01-01')
assert result is None
def test_one_day_over_max_range(self):
"""Should reject one day over max range."""
# 366 days
result = validate_date_range('2025-01-01', '2026-01-02')
assert result is not None
assert str(MAX_DATE_RANGE_DAYS) in result
def test_exactly_max_range(self):
"""Should allow exactly max range days."""
# 365 days from 2025-01-01 is 2026-01-01
result = validate_date_range('2025-01-01', '2026-01-01')
assert result is None
def test_one_day_over_max_range(self):
"""Should reject one day over max range."""
# 366 days
result = validate_date_range('2025-01-01', '2026-01-02')
assert result is not None
assert str(MAX_DATE_RANGE_DAYS) in result
def test_invalid_date_format(self):
"""Should reject invalid date format."""
@@ -90,7 +90,7 @@ class TestValidateDateRange:
assert '格式' in result or 'format' in result.lower()
class TestValidateLotInput:
class TestValidateLotInput:
"""Tests for validate_lot_input function."""
def test_valid_lot_ids(self):
@@ -117,24 +117,24 @@ class TestValidateLotInput:
assert result is not None
assert '至少一個' in result
def test_large_input_list_allowed_when_no_count_cap(self, monkeypatch):
"""Should allow large lists when count cap is disabled."""
monkeypatch.setenv("CONTAINER_RESOLVE_INPUT_MAX_VALUES", "0")
values = [f'GA{i:09d}' for i in range(MAX_LOT_IDS + 50)]
result = validate_lot_input('lot_id', values)
assert result is None
def test_rejects_too_broad_wildcard_pattern(self, monkeypatch):
"""Should reject broad wildcard like '%' to prevent full scan."""
monkeypatch.setenv("CONTAINER_RESOLVE_PATTERN_MIN_PREFIX_LEN", "2")
result = validate_lot_input('lot_id', ['%'])
assert result is not None
assert '萬用字元條件過於寬鬆' in result
def test_accepts_wildcard_with_prefix(self, monkeypatch):
monkeypatch.setenv("CONTAINER_RESOLVE_PATTERN_MIN_PREFIX_LEN", "2")
result = validate_lot_input('lot_id', ['GA25%'])
assert result is None
def test_large_input_list_allowed_when_no_count_cap(self, monkeypatch):
"""Should allow large lists when count cap is disabled."""
monkeypatch.setenv("CONTAINER_RESOLVE_INPUT_MAX_VALUES", "0")
values = [f'GA{i:09d}' for i in range(MAX_LOT_IDS + 50)]
result = validate_lot_input('lot_id', values)
assert result is None
def test_rejects_too_broad_wildcard_pattern(self, monkeypatch):
"""Should reject broad wildcard like '%' to prevent full scan."""
monkeypatch.setenv("CONTAINER_RESOLVE_PATTERN_MIN_PREFIX_LEN", "2")
result = validate_lot_input('lot_id', ['%'])
assert result is not None
assert '萬用字元條件過於寬鬆' in result
def test_accepts_wildcard_with_prefix(self, monkeypatch):
monkeypatch.setenv("CONTAINER_RESOLVE_PATTERN_MIN_PREFIX_LEN", "2")
result = validate_lot_input('lot_id', ['GA25%'])
assert result is None
class TestValidateEquipmentInput:
@@ -167,15 +167,15 @@ class TestValidateEquipmentInput:
assert result is None
class TestResolveQueriesUseBindParams:
class TestResolveQueriesUseBindParams:
"""Queries with user input should always use bind params."""
def test_resolve_by_lot_id_uses_query_builder_params(self):
def test_resolve_by_lot_id_uses_query_builder_params(self):
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.SQLLoader.load_with_params') as mock_load:
with patch('mes_dashboard.services.query_tool_service.read_sql_df') as mock_read:
with patch('mes_dashboard.services.query_tool_service.read_sql_df_slow') as mock_read:
mock_load.return_value = "SELECT * FROM DUAL"
mock_read.return_value = pd.DataFrame([
{
@@ -193,143 +193,143 @@ class TestResolveQueriesUseBindParams:
sql_params = mock_load.call_args.kwargs
assert 'CONTAINER_FILTER' in sql_params
assert ':p0' in sql_params['CONTAINER_FILTER']
_, query_params = mock_read.call_args.args
assert query_params == {'p0': 'LOT-1'}
def test_resolve_by_lot_id_supports_wildcard_pattern(self):
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.SQLLoader.load_with_params') as mock_load:
with patch('mes_dashboard.services.query_tool_service.read_sql_df') as mock_read:
mock_load.return_value = "SELECT * FROM DUAL"
mock_read.return_value = pd.DataFrame([
{
'CONTAINERID': 'CID-1',
'CONTAINERNAME': 'GA25123401',
'SPECNAME': 'SPEC-1',
'QTY': 100,
},
{
'CONTAINERID': 'CID-2',
'CONTAINERNAME': 'GA24123401',
'SPECNAME': 'SPEC-2',
'QTY': 200,
},
])
result = _resolve_by_lot_id(['GA25%01'])
assert result['total'] == 1
assert result['data'][0]['lot_id'] == 'GA25123401'
assert result['data'][0]['input_value'] == 'GA25%01'
sql_params = mock_load.call_args.kwargs
assert "LIKE" in sql_params['CONTAINER_FILTER']
_, query_params = mock_read.call_args.args
assert query_params == {'p0': 'GA25%01'}
def test_resolve_by_wafer_lot_supports_wildcard_pattern(self):
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.SQLLoader.load_with_params') as mock_load:
with patch('mes_dashboard.services.query_tool_service.read_sql_df') as mock_read:
mock_load.return_value = "SELECT * FROM DUAL"
mock_read.return_value = pd.DataFrame([
{
'CONTAINERID': 'CID-1',
'CONTAINERNAME': 'GA25123401-A00-001',
'SPECNAME': 'SPEC-1',
'QTY': 100,
'FIRSTNAME': 'GMSN-1173#A',
},
{
'CONTAINERID': 'CID-2',
'CONTAINERNAME': 'GA25123402-A00-001',
'SPECNAME': 'SPEC-2',
'QTY': 100,
'FIRSTNAME': 'GMSN-9999#B',
},
])
result = _resolve_by_wafer_lot(['GMSN-1173%'])
assert result['total'] == 1
assert result['data'][0]['input_value'] == 'GMSN-1173%'
sql_params = mock_load.call_args.kwargs
assert "LIKE" in sql_params['WAFER_FILTER']
assert "OBJECTTYPE = 'LOT'" in sql_params['WAFER_FILTER']
def test_resolve_by_serial_number_uses_query_builder_params(self):
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.SQLLoader.load_with_params') as mock_load:
with patch('mes_dashboard.services.query_tool_service.read_sql_df') as mock_read:
mock_load.side_effect = [
"SELECT * FROM COMBINE",
"SELECT * FROM CONTAINER_NAME",
"SELECT * FROM FIRSTNAME",
]
mock_read.side_effect = [
pd.DataFrame([
{
'CONTAINERID': 'CID-FIN',
'FINISHEDNAME': 'SN-1',
'CONTAINERNAME': 'LOT-FIN',
'SPECNAME': 'SPEC-1',
}
]),
pd.DataFrame([
{
'CONTAINERID': 'CID-NAME',
'CONTAINERNAME': 'SN-1',
'SPECNAME': 'SPEC-2',
'MFGORDERNAME': None,
'QTY': 1,
}
]),
pd.DataFrame([
{
'CONTAINERID': 'CID-FIRST',
'CONTAINERNAME': 'GD25000001-A01',
'FIRSTNAME': 'SN-1',
'SPECNAME': 'SPEC-3',
'QTY': 1,
}
]),
]
result = _resolve_by_serial_number(['SN-1'])
assert result['total'] == 3
assert {row['match_source'] for row in result['data']} == {
'finished_name',
'container_name',
'first_name',
}
assert [call.args[0] for call in mock_load.call_args_list] == [
'query_tool/lot_resolve_serial',
'query_tool/lot_resolve_id',
'query_tool/lot_resolve_wafer_lot',
]
assert ':p0' in mock_load.call_args_list[0].kwargs['SERIAL_FILTER']
assert ':p0' in mock_load.call_args_list[1].kwargs['CONTAINER_FILTER']
assert ':p0' in mock_load.call_args_list[2].kwargs['WAFER_FILTER']
assert "OBJECTTYPE = 'LOT'" in mock_load.call_args_list[1].kwargs['CONTAINER_FILTER']
assert "OBJECTTYPE = 'LOT'" in mock_load.call_args_list[2].kwargs['WAFER_FILTER']
assert mock_read.call_args_list[0].args[1] == {'p0': 'SN-1'}
assert mock_read.call_args_list[1].args[1] == {'p0': 'SN-1'}
assert mock_read.call_args_list[2].args[1] == {'p0': 'SN-1'}
_, query_params = mock_read.call_args.args
assert query_params == {'p0': 'LOT-1'}
def test_resolve_by_work_order_uses_query_builder_params(self):
def test_resolve_by_lot_id_supports_wildcard_pattern(self):
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.SQLLoader.load_with_params') as mock_load:
with patch('mes_dashboard.services.query_tool_service.read_sql_df') as mock_read:
with patch('mes_dashboard.services.query_tool_service.read_sql_df_slow') as mock_read:
mock_load.return_value = "SELECT * FROM DUAL"
mock_read.return_value = pd.DataFrame([
{
'CONTAINERID': 'CID-1',
'CONTAINERNAME': 'GA25123401',
'SPECNAME': 'SPEC-1',
'QTY': 100,
},
{
'CONTAINERID': 'CID-2',
'CONTAINERNAME': 'GA24123401',
'SPECNAME': 'SPEC-2',
'QTY': 200,
},
])
result = _resolve_by_lot_id(['GA25%01'])
assert result['total'] == 1
assert result['data'][0]['lot_id'] == 'GA25123401'
assert result['data'][0]['input_value'] == 'GA25%01'
sql_params = mock_load.call_args.kwargs
assert "LIKE" in sql_params['CONTAINER_FILTER']
_, query_params = mock_read.call_args.args
assert query_params == {'p0': 'GA25%01'}
def test_resolve_by_wafer_lot_supports_wildcard_pattern(self):
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.SQLLoader.load_with_params') as mock_load:
with patch('mes_dashboard.services.query_tool_service.read_sql_df_slow') as mock_read:
mock_load.return_value = "SELECT * FROM DUAL"
mock_read.return_value = pd.DataFrame([
{
'CONTAINERID': 'CID-1',
'CONTAINERNAME': 'GA25123401-A00-001',
'SPECNAME': 'SPEC-1',
'QTY': 100,
'FIRSTNAME': 'GMSN-1173#A',
},
{
'CONTAINERID': 'CID-2',
'CONTAINERNAME': 'GA25123402-A00-001',
'SPECNAME': 'SPEC-2',
'QTY': 100,
'FIRSTNAME': 'GMSN-9999#B',
},
])
result = _resolve_by_wafer_lot(['GMSN-1173%'])
assert result['total'] == 1
assert result['data'][0]['input_value'] == 'GMSN-1173%'
sql_params = mock_load.call_args.kwargs
assert "LIKE" in sql_params['WAFER_FILTER']
assert "OBJECTTYPE = 'LOT'" in sql_params['WAFER_FILTER']
def test_resolve_by_serial_number_uses_query_builder_params(self):
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.SQLLoader.load_with_params') as mock_load:
with patch('mes_dashboard.services.query_tool_service.read_sql_df_slow') as mock_read:
mock_load.side_effect = [
"SELECT * FROM COMBINE",
"SELECT * FROM CONTAINER_NAME",
"SELECT * FROM FIRSTNAME",
]
mock_read.side_effect = [
pd.DataFrame([
{
'CONTAINERID': 'CID-FIN',
'FINISHEDNAME': 'SN-1',
'CONTAINERNAME': 'LOT-FIN',
'SPECNAME': 'SPEC-1',
}
]),
pd.DataFrame([
{
'CONTAINERID': 'CID-NAME',
'CONTAINERNAME': 'SN-1',
'SPECNAME': 'SPEC-2',
'MFGORDERNAME': None,
'QTY': 1,
}
]),
pd.DataFrame([
{
'CONTAINERID': 'CID-FIRST',
'CONTAINERNAME': 'GD25000001-A01',
'FIRSTNAME': 'SN-1',
'SPECNAME': 'SPEC-3',
'QTY': 1,
}
]),
]
result = _resolve_by_serial_number(['SN-1'])
assert result['total'] == 3
assert {row['match_source'] for row in result['data']} == {
'finished_name',
'container_name',
'first_name',
}
assert [call.args[0] for call in mock_load.call_args_list] == [
'query_tool/lot_resolve_serial',
'query_tool/lot_resolve_id',
'query_tool/lot_resolve_wafer_lot',
]
assert ':p0' in mock_load.call_args_list[0].kwargs['SERIAL_FILTER']
assert ':p0' in mock_load.call_args_list[1].kwargs['CONTAINER_FILTER']
assert ':p0' in mock_load.call_args_list[2].kwargs['WAFER_FILTER']
assert "OBJECTTYPE = 'LOT'" in mock_load.call_args_list[1].kwargs['CONTAINER_FILTER']
assert "OBJECTTYPE = 'LOT'" in mock_load.call_args_list[2].kwargs['WAFER_FILTER']
assert mock_read.call_args_list[0].args[1] == {'p0': 'SN-1'}
assert mock_read.call_args_list[1].args[1] == {'p0': 'SN-1'}
assert mock_read.call_args_list[2].args[1] == {'p0': 'SN-1'}
def test_resolve_by_work_order_uses_query_builder_params(self):
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.SQLLoader.load_with_params') as mock_load:
with patch('mes_dashboard.services.query_tool_service.read_sql_df_slow') as mock_read:
mock_load.return_value = "SELECT * FROM DUAL"
mock_read.return_value = pd.DataFrame([
{
@@ -343,66 +343,64 @@ class TestResolveQueriesUseBindParams:
result = _resolve_by_work_order(['WO-1'])
assert result['total'] == 1
sql_params = mock_load.call_args.kwargs
assert ':p0' in sql_params['WORK_ORDER_FILTER']
_, query_params = mock_read.call_args.args
assert query_params == {'p0': 'WO-1'}
def test_resolve_by_work_order_supports_wildcard_pattern(self):
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.SQLLoader.load_with_params') as mock_load:
with patch('mes_dashboard.services.query_tool_service.read_sql_df') as mock_read:
mock_load.return_value = "SELECT * FROM DUAL"
mock_read.return_value = pd.DataFrame([
{
'CONTAINERID': 'CID-1',
'MFGORDERNAME': 'GA25120018',
'CONTAINERNAME': 'GA25120018-A00-001',
'SPECNAME': 'SPEC-1',
},
{
'CONTAINERID': 'CID-2',
'MFGORDERNAME': 'GA24120018',
'CONTAINERNAME': 'GA24120018-A00-001',
'SPECNAME': 'SPEC-2',
},
])
result = _resolve_by_work_order(['ga25%'])
assert result['total'] == 1
assert result['data'][0]['input_value'] == 'ga25%'
assert result['data'][0]['lot_id'] == 'GA25120018-A00-001'
sql_params = mock_load.call_args.kwargs
assert "LIKE" in sql_params['WORK_ORDER_FILTER']
assert "UPPER(NVL(MFGORDERNAME, ''))" in sql_params['WORK_ORDER_FILTER']
_, query_params = mock_read.call_args.args
assert query_params == {'p0': 'GA25%'}
sql_params = mock_load.call_args.kwargs
assert ':p0' in sql_params['WORK_ORDER_FILTER']
_, query_params = mock_read.call_args.args
assert query_params == {'p0': 'WO-1'}
def test_resolve_by_work_order_supports_wildcard_pattern(self):
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.SQLLoader.load_with_params') as mock_load:
with patch('mes_dashboard.services.query_tool_service.read_sql_df_slow') as mock_read:
mock_load.return_value = "SELECT * FROM DUAL"
mock_read.return_value = pd.DataFrame([
{
'CONTAINERID': 'CID-1',
'MFGORDERNAME': 'GA25120018',
'CONTAINERNAME': 'GA25120018-A00-001',
'SPECNAME': 'SPEC-1',
},
{
'CONTAINERID': 'CID-2',
'MFGORDERNAME': 'GA24120018',
'CONTAINERNAME': 'GA24120018-A00-001',
'SPECNAME': 'SPEC-2',
},
])
result = _resolve_by_work_order(['ga25%'])
assert result['total'] == 1
assert result['data'][0]['input_value'] == 'ga25%'
assert result['data'][0]['lot_id'] == 'GA25120018-A00-001'
sql_params = mock_load.call_args.kwargs
assert "LIKE" in sql_params['WORK_ORDER_FILTER']
assert "UPPER(NVL(MFGORDERNAME, ''))" in sql_params['WORK_ORDER_FILTER']
_, query_params = mock_read.call_args.args
assert query_params == {'p0': 'GA25%'}
class TestSplitMergeHistoryMode:
"""Fast mode should use read_sql_df, full mode should use read_sql_df_slow."""
"""Both modes use read_sql_df_slow for timeout protection."""
def test_fast_mode_uses_time_window_and_row_limit(self):
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.SQLLoader.load_with_params') as mock_load:
with patch('mes_dashboard.services.query_tool_service.read_sql_df') as mock_fast:
with patch('mes_dashboard.services.query_tool_service.read_sql_df_slow') as mock_slow:
mock_load.return_value = "SELECT * FROM DUAL"
mock_fast.return_value = pd.DataFrame([])
with patch('mes_dashboard.services.query_tool_service.read_sql_df_slow') as mock_slow:
mock_load.return_value = "SELECT * FROM DUAL"
mock_slow.return_value = pd.DataFrame([])
result = get_lot_split_merge_history('WO-1', full_history=False)
result = get_lot_split_merge_history('WO-1', full_history=False)
assert result['mode'] == 'fast'
kwargs = mock_load.call_args.kwargs
assert "ADD_MONTHS(SYSDATE, -6)" in kwargs['TIME_WINDOW']
assert "FETCH FIRST 500 ROWS ONLY" == kwargs['ROW_LIMIT']
mock_fast.assert_called_once()
mock_slow.assert_not_called()
assert result['mode'] == 'fast'
kwargs = mock_load.call_args.kwargs
assert "ADD_MONTHS(SYSDATE, -6)" in kwargs['TIME_WINDOW']
assert "FETCH FIRST 500 ROWS ONLY" == kwargs['ROW_LIMIT']
mock_slow.assert_called_once()
def test_full_mode_uses_slow_query_without_limits(self):
from unittest.mock import patch
@@ -431,9 +429,9 @@ class TestServiceConstants:
"""Batch size should be <= 1000 (Oracle limit)."""
assert BATCH_SIZE <= 1000
def test_max_date_range_is_reasonable(self):
"""Max date range should be 365 days."""
assert MAX_DATE_RANGE_DAYS == 365
def test_max_date_range_is_reasonable(self):
"""Max date range should be 365 days."""
assert MAX_DATE_RANGE_DAYS == 365
def test_max_lot_ids_is_reasonable(self):
"""Max LOT IDs should be sensible."""
@@ -443,13 +441,13 @@ class TestServiceConstants:
"""Max serial numbers should be sensible."""
assert 10 <= MAX_SERIAL_NUMBERS <= 100
def test_max_work_orders_is_reasonable(self):
"""Max work orders should match API contract."""
assert MAX_WORK_ORDERS == 50
def test_max_gd_work_orders_is_reasonable(self):
"""Max GD work orders should match API contract."""
assert MAX_GD_WORK_ORDERS == 100
def test_max_work_orders_is_reasonable(self):
"""Max work orders should match API contract."""
assert MAX_WORK_ORDERS == 50
def test_max_gd_work_orders_is_reasonable(self):
"""Max GD work orders should match API contract."""
assert MAX_GD_WORK_ORDERS == 100
def test_max_equipments_is_reasonable(self):
"""Max equipments should be sensible."""