Files
DashBoard/tests/test_hold_history_service.py
egg 8550f6dc3e fix(hold-history): align KPI cards with trend data, improve filters and UX across pages
Use Daily Trend as single source of truth for On Hold and New Hold KPI cards
instead of separate snapshot SQL queries, eliminating value mismatches. Fix
timezone bug in default date range (toISOString UTC offset), add 1st-of-month
fallback to previous month, replace Hold Type radio buttons with select dropdown,
reorder/relabel summary cards with 累計 prefix, add job-query MultiSelect for
equipment filter, and fix heatmap chart X-axis overlap with visualMap legend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 08:13:07 +08:00

352 lines
15 KiB
Python

# -*- coding: utf-8 -*-
"""Unit tests for hold_history_service module."""
from __future__ import annotations
import json
import unittest
from datetime import date, datetime, timedelta
from unittest.mock import MagicMock, patch
import pandas as pd
from mes_dashboard.services import hold_history_service
class TestHoldHistoryTrendCache(unittest.TestCase):
"""Test trend cache hit/miss/cross-month behavior."""
def setUp(self):
hold_history_service._load_hold_history_sql.cache_clear()
def _trend_rows_for_days(self, days: list[str]) -> pd.DataFrame:
rows = []
for day in days:
rows.append(
{
'TXN_DATE': day,
'HOLD_TYPE': 'quality',
'HOLD_QTY': 10,
'NEW_HOLD_QTY': 2,
'RELEASE_QTY': 3,
'FUTURE_HOLD_QTY': 1,
}
)
rows.append(
{
'TXN_DATE': day,
'HOLD_TYPE': 'non-quality',
'HOLD_QTY': 4,
'NEW_HOLD_QTY': 1,
'RELEASE_QTY': 1,
'FUTURE_HOLD_QTY': 0,
}
)
rows.append(
{
'TXN_DATE': day,
'HOLD_TYPE': 'all',
'HOLD_QTY': 14,
'NEW_HOLD_QTY': 3,
'RELEASE_QTY': 4,
'FUTURE_HOLD_QTY': 1,
}
)
return pd.DataFrame(rows)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
@patch('mes_dashboard.services.hold_history_service.get_redis_client')
def test_trend_cache_hit_for_recent_month(self, mock_get_redis_client, mock_read_sql_df):
today = date.today()
start = today.replace(day=1)
end = start + timedelta(days=1)
cached_days = [
{
'date': start.strftime('%Y-%m-%d'),
'quality': {'holdQty': 11, 'newHoldQty': 2, 'releaseQty': 4, 'futureHoldQty': 1},
'non_quality': {'holdQty': 5, 'newHoldQty': 1, 'releaseQty': 1, 'futureHoldQty': 0},
'all': {'holdQty': 16, 'newHoldQty': 3, 'releaseQty': 5, 'futureHoldQty': 1},
},
{
'date': end.strftime('%Y-%m-%d'),
'quality': {'holdQty': 12, 'newHoldQty': 3, 'releaseQty': 5, 'futureHoldQty': 1},
'non_quality': {'holdQty': 4, 'newHoldQty': 1, 'releaseQty': 2, 'futureHoldQty': 0},
'all': {'holdQty': 16, 'newHoldQty': 4, 'releaseQty': 7, 'futureHoldQty': 1},
},
]
mock_redis = MagicMock()
mock_redis.get.return_value = json.dumps(cached_days)
mock_get_redis_client.return_value = mock_redis
result = hold_history_service.get_hold_history_trend(start.isoformat(), end.isoformat())
self.assertIsNotNone(result)
self.assertEqual(len(result['days']), 2)
self.assertEqual(result['days'][0]['quality']['holdQty'], 11)
self.assertEqual(result['days'][1]['all']['releaseQty'], 7)
mock_read_sql_df.assert_not_called()
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
@patch('mes_dashboard.services.hold_history_service.get_redis_client')
def test_trend_cache_miss_populates_cache(self, mock_get_redis_client, mock_read_sql_df):
today = date.today()
start = today.replace(day=1)
end = start + timedelta(days=1)
mock_redis = MagicMock()
mock_redis.get.return_value = None
mock_get_redis_client.return_value = mock_redis
mock_read_sql_df.return_value = self._trend_rows_for_days([start.isoformat(), end.isoformat()])
result = hold_history_service.get_hold_history_trend(start.isoformat(), end.isoformat())
self.assertIsNotNone(result)
self.assertEqual(len(result['days']), 2)
self.assertEqual(result['days'][0]['all']['holdQty'], 14)
self.assertEqual(mock_read_sql_df.call_count, 1)
mock_redis.setex.assert_called_once()
cache_key = mock_redis.setex.call_args.args[0]
self.assertIn('hold_history:daily', cache_key)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
@patch('mes_dashboard.services.hold_history_service.get_redis_client')
def test_trend_cross_month_assembly_from_cache(self, mock_get_redis_client, mock_read_sql_df):
today = date.today()
current_month_start = today.replace(day=1)
previous_month_end = current_month_start - timedelta(days=1)
start = previous_month_end - timedelta(days=1)
end = current_month_start + timedelta(days=1)
previous_cache = [
{
'date': start.strftime('%Y-%m-%d'),
'quality': {'holdQty': 9, 'newHoldQty': 2, 'releaseQty': 1, 'futureHoldQty': 0},
'non_quality': {'holdQty': 3, 'newHoldQty': 1, 'releaseQty': 0, 'futureHoldQty': 0},
'all': {'holdQty': 12, 'newHoldQty': 3, 'releaseQty': 1, 'futureHoldQty': 0},
},
{
'date': (start + timedelta(days=1)).strftime('%Y-%m-%d'),
'quality': {'holdQty': 8, 'newHoldQty': 1, 'releaseQty': 2, 'futureHoldQty': 0},
'non_quality': {'holdQty': 2, 'newHoldQty': 1, 'releaseQty': 1, 'futureHoldQty': 0},
'all': {'holdQty': 10, 'newHoldQty': 2, 'releaseQty': 3, 'futureHoldQty': 0},
},
]
current_cache = [
{
'date': current_month_start.strftime('%Y-%m-%d'),
'quality': {'holdQty': 7, 'newHoldQty': 2, 'releaseQty': 3, 'futureHoldQty': 1},
'non_quality': {'holdQty': 2, 'newHoldQty': 1, 'releaseQty': 1, 'futureHoldQty': 0},
'all': {'holdQty': 9, 'newHoldQty': 3, 'releaseQty': 4, 'futureHoldQty': 1},
},
{
'date': (current_month_start + timedelta(days=1)).strftime('%Y-%m-%d'),
'quality': {'holdQty': 6, 'newHoldQty': 1, 'releaseQty': 2, 'futureHoldQty': 0},
'non_quality': {'holdQty': 1, 'newHoldQty': 1, 'releaseQty': 0, 'futureHoldQty': 0},
'all': {'holdQty': 7, 'newHoldQty': 2, 'releaseQty': 2, 'futureHoldQty': 0},
},
]
mock_redis = MagicMock()
mock_redis.get.side_effect = [json.dumps(previous_cache), json.dumps(current_cache)]
mock_get_redis_client.return_value = mock_redis
result = hold_history_service.get_hold_history_trend(start.isoformat(), end.isoformat())
self.assertIsNotNone(result)
self.assertEqual(len(result['days']), (end - start).days + 1)
self.assertEqual(result['days'][0]['date'], start.isoformat())
self.assertEqual(result['days'][-1]['date'], end.isoformat())
self.assertEqual(result['days'][0]['all']['holdQty'], 12)
self.assertEqual(result['days'][-1]['quality']['releaseQty'], 2)
mock_read_sql_df.assert_not_called()
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
@patch('mes_dashboard.services.hold_history_service.get_redis_client')
def test_trend_older_month_queries_oracle_without_cache(self, mock_get_redis_client, mock_read_sql_df):
today = date.today()
current_month_start = today.replace(day=1)
old_month_start = (current_month_start - timedelta(days=100)).replace(day=1)
start = old_month_start
end = old_month_start + timedelta(days=1)
mock_redis = MagicMock()
mock_get_redis_client.return_value = mock_redis
mock_read_sql_df.return_value = self._trend_rows_for_days([start.isoformat(), end.isoformat()])
result = hold_history_service.get_hold_history_trend(start.isoformat(), end.isoformat())
self.assertIsNotNone(result)
self.assertEqual(len(result['days']), 2)
self.assertEqual(mock_read_sql_df.call_count, 1)
mock_redis.get.assert_not_called()
class TestHoldHistoryServiceFunctions(unittest.TestCase):
"""Test non-trend service function formatting and behavior."""
def setUp(self):
hold_history_service._load_hold_history_sql.cache_clear()
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_reason_pareto_formats_response(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame(
[
{'REASON': '品質確認', 'ITEM_COUNT': 10, 'QTY': 2000, 'PCT': 40.0, 'CUM_PCT': 40.0},
{'REASON': '工程驗證', 'ITEM_COUNT': 8, 'QTY': 1800, 'PCT': 32.0, 'CUM_PCT': 72.0},
]
)
result = hold_history_service.get_hold_history_reason_pareto('2026-02-01', '2026-02-07', 'quality')
self.assertIsNotNone(result)
self.assertEqual(len(result['items']), 2)
self.assertEqual(result['items'][0]['reason'], '品質確認')
self.assertEqual(result['items'][0]['count'], 10)
self.assertEqual(result['items'][1]['cumPct'], 72.0)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_reason_pareto_passes_record_type_flags(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame([])
hold_history_service.get_hold_history_reason_pareto(
'2026-02-01', '2026-02-07', 'quality', record_type='on_hold'
)
params = mock_read_sql_df.call_args.args[1]
self.assertEqual(params['include_new'], 0)
self.assertEqual(params['include_on_hold'], 1)
self.assertEqual(params['include_released'], 0)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_reason_pareto_multi_record_type_flags(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame([])
hold_history_service.get_hold_history_reason_pareto(
'2026-02-01', '2026-02-07', 'quality', record_type='on_hold,released'
)
params = mock_read_sql_df.call_args.args[1]
self.assertEqual(params['include_new'], 0)
self.assertEqual(params['include_on_hold'], 1)
self.assertEqual(params['include_released'], 1)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_reason_pareto_normalizes_invalid_hold_type(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame([])
hold_history_service.get_hold_history_reason_pareto('2026-02-01', '2026-02-07', 'invalid')
params = mock_read_sql_df.call_args.args[1]
self.assertEqual(params['hold_type'], 'quality')
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_duration_formats_response(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame(
[
{'RANGE_LABEL': '<4h', 'ITEM_COUNT': 5, 'QTY': 500, 'PCT': 25.0},
{'RANGE_LABEL': '4-24h', 'ITEM_COUNT': 7, 'QTY': 700, 'PCT': 35.0},
{'RANGE_LABEL': '1-3d', 'ITEM_COUNT': 4, 'QTY': 400, 'PCT': 20.0},
{'RANGE_LABEL': '>3d', 'ITEM_COUNT': 4, 'QTY': 400, 'PCT': 20.0},
]
)
result = hold_history_service.get_hold_history_duration('2026-02-01', '2026-02-07', 'quality')
self.assertIsNotNone(result)
self.assertEqual(len(result['items']), 4)
self.assertEqual(result['items'][0]['range'], '<4h')
self.assertEqual(result['items'][0]['qty'], 500)
self.assertEqual(result['items'][1]['count'], 7)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_duration_passes_record_type_flags(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame([])
hold_history_service.get_hold_history_duration(
'2026-02-01', '2026-02-07', 'quality', record_type='released'
)
params = mock_read_sql_df.call_args.args[1]
self.assertEqual(params['include_new'], 0)
self.assertEqual(params['include_on_hold'], 0)
self.assertEqual(params['include_released'], 1)
@patch('mes_dashboard.services.hold_history_service._get_wc_group')
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_list_formats_response_and_pagination(self, mock_read_sql_df, mock_wc_group):
mock_wc_group.side_effect = lambda wc: {'WB': '焊接_WB', 'DB': '焊接_DB'}.get(wc)
mock_read_sql_df.return_value = pd.DataFrame(
[
{
'LOT_ID': 'LOT001',
'WORKORDER': 'GA26010001',
'WORKCENTER': 'WB',
'HOLD_REASON': '品質確認',
'QTY': 250,
'HOLD_DATE': datetime(2026, 2, 1, 8, 30, 0),
'HOLD_EMP': '王小明',
'HOLD_COMMENT': '確認中',
'RELEASE_DATE': None,
'RELEASE_EMP': None,
'RELEASE_COMMENT': None,
'HOLD_HOURS': 12.345,
'NCR_ID': 'NCR-001',
'TOTAL_COUNT': 3,
},
{
'LOT_ID': 'LOT002',
'WORKORDER': 'GA26010002',
'WORKCENTER': 'DB',
'HOLD_REASON': '工程驗證',
'QTY': 100,
'HOLD_DATE': datetime(2026, 2, 1, 9, 10, 0),
'HOLD_EMP': '陳小華',
'HOLD_COMMENT': '待確認',
'RELEASE_DATE': datetime(2026, 2, 1, 12, 0, 0),
'RELEASE_EMP': '李主管',
'RELEASE_COMMENT': '已解除',
'HOLD_HOURS': 2.5,
'NCR_ID': None,
'TOTAL_COUNT': 3,
},
]
)
result = hold_history_service.get_hold_history_list(
start_date='2026-02-01',
end_date='2026-02-07',
hold_type='quality',
reason=None,
page=1,
per_page=2,
)
self.assertIsNotNone(result)
self.assertEqual(len(result['items']), 2)
self.assertEqual(result['items'][0]['workcenter'], '焊接_WB')
self.assertEqual(result['items'][1]['workcenter'], '焊接_DB')
self.assertEqual(result['items'][0]['qty'], 250)
self.assertEqual(result['items'][1]['qty'], 100)
self.assertEqual(result['items'][0]['releaseDate'], None)
self.assertEqual(result['items'][0]['holdHours'], 12.35)
self.assertEqual(result['pagination']['total'], 3)
self.assertEqual(result['pagination']['totalPages'], 2)
def test_trend_sql_contains_shift_boundary_logic(self):
sql = hold_history_service._load_hold_history_sql('trend')
self.assertIn('0730', sql)
self.assertIn('ROW_NUMBER', sql)
self.assertIn('FUTUREHOLDCOMMENTS', sql)
if __name__ == '__main__': # pragma: no cover
unittest.main()