Migrate /wip-overview, /wip-detail, and /hold-detail (1,941 lines vanilla JS) to Vue 3 SFC architecture. Extract shared CSS/constants/components to wip-shared/. Switch Pareto charts to vue-echarts with autoresize. Replace Jinja2 template injection with frontend URL params + constant classification for Hold Detail. Add 10-min auto-refresh + AbortController to Hold Detail. Remove three Jinja2 templates, update Flask routes to send_from_directory. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
319 lines
13 KiB
Python
319 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Unit tests for Hold Detail API routes.
|
|
|
|
Tests the Hold Detail API endpoints in hold_routes.py.
|
|
"""
|
|
|
|
import unittest
|
|
from unittest.mock import patch
|
|
import json
|
|
|
|
from mes_dashboard.app import create_app
|
|
import mes_dashboard.core.database as db
|
|
|
|
|
|
class TestHoldRoutesBase(unittest.TestCase):
|
|
"""Base class for Hold routes tests."""
|
|
|
|
def setUp(self):
|
|
"""Set up test client."""
|
|
db._ENGINE = None
|
|
self.app = create_app('testing')
|
|
self.app.config['TESTING'] = True
|
|
self.client = self.app.test_client()
|
|
|
|
|
|
class TestHoldDetailPageRoute(TestHoldRoutesBase):
|
|
"""Test GET /hold-detail page route."""
|
|
|
|
def test_hold_detail_page_requires_reason(self):
|
|
"""GET /hold-detail without reason should redirect to wip-overview."""
|
|
response = self.client.get('/hold-detail')
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertIn('/wip-overview', response.location)
|
|
|
|
def test_hold_detail_page_with_reason(self):
|
|
"""GET /hold-detail?reason=xxx should return 200."""
|
|
response = self.client.get('/hold-detail?reason=YieldLimit')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_hold_detail_page_includes_vite_entry(self):
|
|
"""Page should load the Hold Detail Vite module."""
|
|
response = self.client.get('/hold-detail?reason=YieldLimit')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn(b'/static/dist/hold-detail.js', response.data)
|
|
|
|
|
|
class TestHoldDetailSummaryRoute(TestHoldRoutesBase):
|
|
"""Test GET /api/wip/hold-detail/summary endpoint."""
|
|
|
|
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_summary')
|
|
def test_returns_success_with_data(self, mock_get_summary):
|
|
"""Should return success=True with summary data."""
|
|
mock_get_summary.return_value = {
|
|
'totalLots': 128,
|
|
'totalQty': 25600,
|
|
'avgAge': 2.3,
|
|
'maxAge': 15.0,
|
|
'workcenterCount': 8
|
|
}
|
|
|
|
response = self.client.get('/api/wip/hold-detail/summary?reason=YieldLimit')
|
|
data = json.loads(response.data)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertTrue(data['success'])
|
|
self.assertEqual(data['data']['totalLots'], 128)
|
|
self.assertEqual(data['data']['totalQty'], 25600)
|
|
self.assertEqual(data['data']['avgAge'], 2.3)
|
|
self.assertEqual(data['data']['maxAge'], 15.0)
|
|
self.assertEqual(data['data']['workcenterCount'], 8)
|
|
|
|
def test_returns_error_without_reason(self):
|
|
"""Should return 400 when reason is missing."""
|
|
response = self.client.get('/api/wip/hold-detail/summary')
|
|
data = json.loads(response.data)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertFalse(data['success'])
|
|
self.assertIn('reason', data['error'])
|
|
|
|
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_summary')
|
|
def test_returns_error_on_failure(self, mock_get_summary):
|
|
"""Should return success=False and 500 on failure."""
|
|
mock_get_summary.return_value = None
|
|
|
|
response = self.client.get('/api/wip/hold-detail/summary?reason=YieldLimit')
|
|
data = json.loads(response.data)
|
|
|
|
self.assertEqual(response.status_code, 500)
|
|
self.assertFalse(data['success'])
|
|
self.assertIn('error', data)
|
|
|
|
|
|
class TestHoldDetailDistributionRoute(TestHoldRoutesBase):
|
|
"""Test GET /api/wip/hold-detail/distribution endpoint."""
|
|
|
|
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_distribution')
|
|
def test_returns_success_with_distribution(self, mock_get_dist):
|
|
"""Should return success=True with distribution data."""
|
|
mock_get_dist.return_value = {
|
|
'byWorkcenter': [
|
|
{'name': 'DA', 'lots': 45, 'qty': 9000, 'percentage': 35.2},
|
|
{'name': 'WB', 'lots': 38, 'qty': 7600, 'percentage': 29.7}
|
|
],
|
|
'byPackage': [
|
|
{'name': 'DIP-B', 'lots': 50, 'qty': 10000, 'percentage': 39.1},
|
|
{'name': 'QFN', 'lots': 35, 'qty': 7000, 'percentage': 27.3}
|
|
],
|
|
'byAge': [
|
|
{'range': '0-1', 'label': '0-1天', 'lots': 45, 'qty': 9000, 'percentage': 35.2},
|
|
{'range': '1-3', 'label': '1-3天', 'lots': 38, 'qty': 7600, 'percentage': 29.7},
|
|
{'range': '3-7', 'label': '3-7天', 'lots': 30, 'qty': 6000, 'percentage': 23.4},
|
|
{'range': '7+', 'label': '7+天', 'lots': 15, 'qty': 3000, 'percentage': 11.7}
|
|
]
|
|
}
|
|
|
|
response = self.client.get('/api/wip/hold-detail/distribution?reason=YieldLimit')
|
|
data = json.loads(response.data)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertTrue(data['success'])
|
|
self.assertIn('byWorkcenter', data['data'])
|
|
self.assertIn('byPackage', data['data'])
|
|
self.assertIn('byAge', data['data'])
|
|
self.assertEqual(len(data['data']['byWorkcenter']), 2)
|
|
self.assertEqual(len(data['data']['byAge']), 4)
|
|
|
|
def test_returns_error_without_reason(self):
|
|
"""Should return 400 when reason is missing."""
|
|
response = self.client.get('/api/wip/hold-detail/distribution')
|
|
data = json.loads(response.data)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertFalse(data['success'])
|
|
|
|
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_distribution')
|
|
def test_returns_error_on_failure(self, mock_get_dist):
|
|
"""Should return success=False and 500 on failure."""
|
|
mock_get_dist.return_value = None
|
|
|
|
response = self.client.get('/api/wip/hold-detail/distribution?reason=YieldLimit')
|
|
data = json.loads(response.data)
|
|
|
|
self.assertEqual(response.status_code, 500)
|
|
self.assertFalse(data['success'])
|
|
|
|
|
|
class TestHoldDetailLotsRoute(TestHoldRoutesBase):
|
|
"""Test GET /api/wip/hold-detail/lots endpoint."""
|
|
|
|
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
|
def test_returns_success_with_lots(self, mock_get_lots):
|
|
"""Should return success=True with lots data."""
|
|
mock_get_lots.return_value = {
|
|
'lots': [
|
|
{
|
|
'lotId': 'L001',
|
|
'workorder': 'WO123',
|
|
'qty': 200,
|
|
'package': 'DIP-B',
|
|
'workcenter': 'DA',
|
|
'spec': 'S01',
|
|
'age': 2.3,
|
|
'holdBy': 'EMP01',
|
|
'dept': 'QC',
|
|
'holdComment': 'Yield below threshold'
|
|
}
|
|
],
|
|
'pagination': {
|
|
'page': 1,
|
|
'perPage': 50,
|
|
'total': 128,
|
|
'totalPages': 3
|
|
},
|
|
'filters': {
|
|
'workcenter': None,
|
|
'package': None,
|
|
'ageRange': None
|
|
}
|
|
}
|
|
|
|
response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit')
|
|
data = json.loads(response.data)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertTrue(data['success'])
|
|
self.assertIn('lots', data['data'])
|
|
self.assertIn('pagination', data['data'])
|
|
self.assertIn('filters', data['data'])
|
|
self.assertEqual(len(data['data']['lots']), 1)
|
|
self.assertEqual(data['data']['pagination']['total'], 128)
|
|
|
|
def test_returns_error_without_reason(self):
|
|
"""Should return 400 when reason is missing."""
|
|
response = self.client.get('/api/wip/hold-detail/lots')
|
|
data = json.loads(response.data)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertFalse(data['success'])
|
|
|
|
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
|
def test_passes_filter_parameters(self, mock_get_lots):
|
|
"""Should pass filter parameters to service function."""
|
|
mock_get_lots.return_value = {
|
|
'lots': [],
|
|
'pagination': {'page': 2, 'perPage': 50, 'total': 0, 'totalPages': 1},
|
|
'filters': {'workcenter': 'DA', 'package': 'DIP-B', 'ageRange': '1-3'}
|
|
}
|
|
|
|
response = self.client.get(
|
|
'/api/wip/hold-detail/lots?reason=YieldLimit&workcenter=DA&package=DIP-B&age_range=1-3&page=2'
|
|
)
|
|
|
|
mock_get_lots.assert_called_once_with(
|
|
reason='YieldLimit',
|
|
workcenter='DA',
|
|
package='DIP-B',
|
|
age_range='1-3',
|
|
include_dummy=False,
|
|
page=2,
|
|
page_size=50
|
|
)
|
|
|
|
def test_validates_age_range_parameter(self):
|
|
"""Should return 400 for invalid age_range."""
|
|
response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit&age_range=invalid')
|
|
data = json.loads(response.data)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertFalse(data['success'])
|
|
self.assertIn('age_range', data['error'])
|
|
|
|
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
|
def test_limits_per_page_to_200(self, mock_get_lots):
|
|
"""Per page should be capped at 200."""
|
|
mock_get_lots.return_value = {
|
|
'lots': [],
|
|
'pagination': {'page': 1, 'perPage': 200, 'total': 0, 'totalPages': 1},
|
|
'filters': {'workcenter': None, 'package': None, 'ageRange': None}
|
|
}
|
|
|
|
response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit&per_page=500')
|
|
|
|
call_args = mock_get_lots.call_args
|
|
self.assertEqual(call_args.kwargs['page_size'], 200)
|
|
|
|
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
|
def test_handles_page_less_than_one(self, mock_get_lots):
|
|
"""Page number less than 1 should be set to 1."""
|
|
mock_get_lots.return_value = {
|
|
'lots': [],
|
|
'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
|
|
'filters': {'workcenter': None, 'package': None, 'ageRange': None}
|
|
}
|
|
|
|
response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit&page=0')
|
|
|
|
call_args = mock_get_lots.call_args
|
|
self.assertEqual(call_args.kwargs['page'], 1)
|
|
|
|
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
|
def test_returns_error_on_failure(self, mock_get_lots):
|
|
"""Should return success=False and 500 on failure."""
|
|
mock_get_lots.return_value = None
|
|
|
|
response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit')
|
|
data = json.loads(response.data)
|
|
|
|
self.assertEqual(response.status_code, 500)
|
|
self.assertFalse(data['success'])
|
|
|
|
|
|
class TestHoldDetailAgeRangeFilters(TestHoldRoutesBase):
|
|
"""Test age range filter validation."""
|
|
|
|
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
|
def test_valid_age_range_0_1(self, mock_get_lots):
|
|
"""Should accept 0-1 as valid age_range."""
|
|
mock_get_lots.return_value = {
|
|
'lots': [], 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
|
|
'filters': {'workcenter': None, 'package': None, 'ageRange': '0-1'}
|
|
}
|
|
response = self.client.get('/api/wip/hold-detail/lots?reason=Test&age_range=0-1')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
|
def test_valid_age_range_1_3(self, mock_get_lots):
|
|
"""Should accept 1-3 as valid age_range."""
|
|
mock_get_lots.return_value = {
|
|
'lots': [], 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
|
|
'filters': {'workcenter': None, 'package': None, 'ageRange': '1-3'}
|
|
}
|
|
response = self.client.get('/api/wip/hold-detail/lots?reason=Test&age_range=1-3')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
|
def test_valid_age_range_3_7(self, mock_get_lots):
|
|
"""Should accept 3-7 as valid age_range."""
|
|
mock_get_lots.return_value = {
|
|
'lots': [], 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
|
|
'filters': {'workcenter': None, 'package': None, 'ageRange': '3-7'}
|
|
}
|
|
response = self.client.get('/api/wip/hold-detail/lots?reason=Test&age_range=3-7')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
|
|
def test_valid_age_range_7_plus(self, mock_get_lots):
|
|
"""Should accept 7+ as valid age_range."""
|
|
mock_get_lots.return_value = {
|
|
'lots': [], 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
|
|
'filters': {'workcenter': None, 'package': None, 'ageRange': '7+'}
|
|
}
|
|
response = self.client.get('/api/wip/hold-detail/lots?reason=Test&age_range=7%2B')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|