Files
DashBoard/tests/test_hold_overview_routes.py
egg 8225863a85 feat(hold-overview): add Hold Lot Overview page with TreeMap, Matrix, and cascade filtering
Provide managers with a dedicated page to analyze hold lots across all stations.
Extends existing service functions (get_hold_detail_summary, get_hold_detail_lots,
get_wip_matrix) with optional parameters for backward compatibility, adds one new
function (get_hold_overview_treemap), and registers the page in the portal navigation.

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

229 lines
8.9 KiB
Python

# -*- coding: utf-8 -*-
"""Unit tests for Hold Overview API routes."""
import json
import unittest
from unittest.mock import patch
from mes_dashboard.app import create_app
import mes_dashboard.core.database as db
class TestHoldOverviewRoutesBase(unittest.TestCase):
"""Base class for Hold Overview route tests."""
def setUp(self):
db._ENGINE = None
self.app = create_app('testing')
self.app.config['TESTING'] = True
self.client = self.app.test_client()
class TestHoldOverviewPageRoute(TestHoldOverviewRoutesBase):
"""Test GET /hold-overview page route."""
@patch('mes_dashboard.routes.hold_overview_routes.os.path.exists', return_value=False)
def test_hold_overview_page_includes_vite_entry(self, _mock_exists):
# Page is registered as 'dev' status, requires admin session
with self.client.session_transaction() as sess:
sess['admin'] = {'displayName': 'Test Admin', 'employeeNo': 'A001'}
response = self.client.get('/hold-overview')
self.assertEqual(response.status_code, 200)
self.assertIn(b'/static/dist/hold-overview.js', response.data)
@patch('mes_dashboard.routes.hold_overview_routes.os.path.exists', return_value=False)
def test_hold_overview_page_returns_403_without_admin(self, _mock_exists):
response = self.client.get('/hold-overview')
self.assertEqual(response.status_code, 403)
class TestHoldOverviewSummaryRoute(TestHoldOverviewRoutesBase):
"""Test GET /api/hold-overview/summary endpoint."""
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_summary')
def test_summary_defaults_to_quality(self, mock_service):
mock_service.return_value = {
'totalLots': 12,
'totalQty': 3400,
'avgAge': 2.5,
'maxAge': 9.0,
'workcenterCount': 3,
'dataUpdateDate': '2026-01-01 08:00:00',
}
response = self.client.get('/api/hold-overview/summary')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(payload['success'])
mock_service.assert_called_once_with(
reason=None,
hold_type='quality',
include_dummy=False,
)
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_summary')
def test_summary_hold_type_all_maps_to_none(self, mock_service):
mock_service.return_value = {
'totalLots': 0,
'totalQty': 0,
'avgAge': 0,
'maxAge': 0,
'workcenterCount': 0,
'dataUpdateDate': None,
}
response = self.client.get('/api/hold-overview/summary?hold_type=all&reason=品質確認')
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
reason='品質確認',
hold_type=None,
include_dummy=False,
)
def test_summary_invalid_hold_type(self):
response = self.client.get('/api/hold-overview/summary?hold_type=invalid')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_summary')
def test_summary_failure_returns_500(self, mock_service):
mock_service.return_value = None
response = self.client.get('/api/hold-overview/summary')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 500)
self.assertFalse(payload['success'])
class TestHoldOverviewMatrixRoute(TestHoldOverviewRoutesBase):
"""Test GET /api/hold-overview/matrix endpoint."""
@patch('mes_dashboard.routes.hold_overview_routes.get_wip_matrix')
def test_matrix_passes_hold_filters(self, mock_service):
mock_service.return_value = {
'workcenters': [],
'packages': [],
'matrix': {},
'workcenter_totals': {},
'package_totals': {},
'grand_total': 0,
}
response = self.client.get('/api/hold-overview/matrix?hold_type=non-quality&reason=特殊需求管控')
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
include_dummy=False,
status='HOLD',
hold_type='non-quality',
reason='特殊需求管控',
)
def test_matrix_invalid_hold_type(self):
response = self.client.get('/api/hold-overview/matrix?hold_type=invalid')
self.assertEqual(response.status_code, 400)
@patch('mes_dashboard.routes.hold_overview_routes.get_wip_matrix')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 7))
def test_matrix_rate_limited_returns_429(self, _mock_limit, mock_service):
response = self.client.get('/api/hold-overview/matrix')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 429)
self.assertEqual(payload['error']['code'], 'TOO_MANY_REQUESTS')
self.assertEqual(response.headers.get('Retry-After'), '7')
mock_service.assert_not_called()
class TestHoldOverviewTreemapRoute(TestHoldOverviewRoutesBase):
"""Test GET /api/hold-overview/treemap endpoint."""
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_overview_treemap')
def test_treemap_passes_filters(self, mock_service):
mock_service.return_value = {'items': []}
response = self.client.get(
'/api/hold-overview/treemap?hold_type=quality&reason=品質確認&workcenter=WB&package=QFN'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
hold_type='quality',
reason='品質確認',
workcenter='WB',
package='QFN',
include_dummy=False,
)
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_overview_treemap')
def test_treemap_failure_returns_500(self, mock_service):
mock_service.return_value = None
response = self.client.get('/api/hold-overview/treemap')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 500)
self.assertFalse(payload['success'])
class TestHoldOverviewLotsRoute(TestHoldOverviewRoutesBase):
"""Test GET /api/hold-overview/lots endpoint."""
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots')
def test_lots_passes_all_filters_and_caps_per_page(self, mock_service):
mock_service.return_value = {
'lots': [],
'pagination': {'page': 2, 'perPage': 200, 'total': 0, 'totalPages': 1},
'filters': {},
}
response = self.client.get(
'/api/hold-overview/lots?hold_type=all&reason=品質確認'
'&workcenter=WB&package=QFN&treemap_reason=品質確認'
'&age_range=1-3&page=2&per_page=500'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
reason='品質確認',
hold_type=None,
treemap_reason='品質確認',
workcenter='WB',
package='QFN',
age_range='1-3',
include_dummy=False,
page=2,
page_size=200,
)
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots')
def test_lots_handles_page_less_than_one(self, mock_service):
mock_service.return_value = {
'lots': [],
'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
'filters': {},
}
response = self.client.get('/api/hold-overview/lots?page=0')
self.assertEqual(response.status_code, 200)
call_args = mock_service.call_args
self.assertEqual(call_args.kwargs['page'], 1)
def test_lots_invalid_age_range(self):
response = self.client.get('/api/hold-overview/lots?age_range=invalid')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
def test_lots_invalid_hold_type(self):
response = self.client.get('/api/hold-overview/lots?hold_type=invalid')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 4))
def test_lots_rate_limited_returns_429(self, _mock_limit, mock_service):
response = self.client.get('/api/hold-overview/lots')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 429)
self.assertEqual(payload['error']['code'], 'TOO_MANY_REQUESTS')
self.assertEqual(response.headers.get('Retry-After'), '4')
mock_service.assert_not_called()