Transform /mid-section-defect from TMTT-only backward analysis into a full-line bidirectional defect traceability center supporting all detection stations. Key changes: - Parameterized station detection: any workcenter group as detection station - Bidirectional tracing: backward (upstream attribution) + forward (downstream reject rates) - Dual query mode: date range OR LOT/工單/WAFER container-based seed resolution - Multi-select filters for upstream station, equipment model (RESOURCEFAMILYNAME), and loss reasons - Progressive 3-stage trace pipeline (seed-resolve → lineage → events) with streaming UI - Equipment model lookup via resource cache instead of SPECNAME - Session caching, auto-refresh, searchable MultiSelect with fuzzy matching - Remove legacy tmtt-defect module (fully superseded) - Archive openspec change artifacts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
208 lines
6.8 KiB
Python
208 lines
6.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Route tests for mid-section defect APIs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
import mes_dashboard.core.database as db
|
|
from mes_dashboard.app import create_app
|
|
from mes_dashboard.core.rate_limit import reset_rate_limits_for_tests
|
|
|
|
|
|
def _client():
|
|
db._ENGINE = None
|
|
app = create_app('testing')
|
|
app.config['TESTING'] = True
|
|
return app.test_client()
|
|
|
|
|
|
def setup_function():
|
|
reset_rate_limits_for_tests()
|
|
|
|
|
|
def teardown_function():
|
|
reset_rate_limits_for_tests()
|
|
|
|
|
|
@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis')
|
|
def test_analysis_success(mock_query_analysis):
|
|
mock_query_analysis.return_value = {
|
|
'kpi': {'total_input': 100},
|
|
'charts': {'by_station': []},
|
|
'daily_trend': [],
|
|
'available_loss_reasons': ['A'],
|
|
'genealogy_status': 'ready',
|
|
'detail': [{}, {}],
|
|
}
|
|
|
|
client = _client()
|
|
response = client.get(
|
|
'/api/mid-section-defect/analysis?start_date=2025-01-01&end_date=2025-01-31&loss_reasons=A,B'
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.get_json()
|
|
assert payload['success'] is True
|
|
assert payload['data']['detail_total_count'] == 2
|
|
assert payload['data']['kpi']['total_input'] == 100
|
|
mock_query_analysis.assert_called_once_with(
|
|
'2025-01-01', '2025-01-31', ['A', 'B'], '測試', 'backward',
|
|
)
|
|
|
|
|
|
@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis')
|
|
def test_analysis_with_station_and_direction(mock_query_analysis):
|
|
mock_query_analysis.return_value = {
|
|
'kpi': {'detection_lot_count': 50},
|
|
'charts': {'by_downstream_station': []},
|
|
'daily_trend': [],
|
|
'available_loss_reasons': [],
|
|
'genealogy_status': 'ready',
|
|
'detail': [],
|
|
}
|
|
|
|
client = _client()
|
|
response = client.get(
|
|
'/api/mid-section-defect/analysis?start_date=2025-01-01&end_date=2025-01-31&station=成型&direction=forward'
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_query_analysis.assert_called_once_with(
|
|
'2025-01-01', '2025-01-31', None, '成型', 'forward',
|
|
)
|
|
|
|
|
|
def test_analysis_missing_dates_returns_400():
|
|
client = _client()
|
|
response = client.get('/api/mid-section-defect/analysis?start_date=2025-01-01')
|
|
|
|
assert response.status_code == 400
|
|
payload = response.get_json()
|
|
assert payload['success'] is False
|
|
|
|
|
|
@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis')
|
|
def test_analysis_service_failure_returns_500(mock_query_analysis):
|
|
mock_query_analysis.return_value = None
|
|
|
|
client = _client()
|
|
response = client.get('/api/mid-section-defect/analysis?start_date=2025-01-01&end_date=2025-01-31')
|
|
|
|
assert response.status_code == 500
|
|
payload = response.get_json()
|
|
assert payload['success'] is False
|
|
|
|
|
|
@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis')
|
|
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 7))
|
|
def test_analysis_rate_limited_returns_429(_mock_rate_limit, mock_query_analysis):
|
|
client = _client()
|
|
response = client.get('/api/mid-section-defect/analysis?start_date=2025-01-01&end_date=2025-01-31')
|
|
|
|
assert response.status_code == 429
|
|
assert response.headers.get('Retry-After') == '7'
|
|
payload = response.get_json()
|
|
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
|
|
mock_query_analysis.assert_not_called()
|
|
|
|
|
|
@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis_detail')
|
|
def test_detail_success(mock_query_detail):
|
|
mock_query_detail.return_value = {
|
|
'detail': [{'CONTAINERNAME': 'LOT-1'}],
|
|
'pagination': {'page': 2, 'page_size': 200, 'total_count': 350, 'total_pages': 2},
|
|
}
|
|
|
|
client = _client()
|
|
response = client.get(
|
|
'/api/mid-section-defect/analysis/detail?start_date=2025-01-01&end_date=2025-01-31&page=2&page_size=200'
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.get_json()
|
|
assert payload['success'] is True
|
|
assert payload['data']['pagination']['page'] == 2
|
|
mock_query_detail.assert_called_once_with(
|
|
'2025-01-01',
|
|
'2025-01-31',
|
|
None,
|
|
'測試',
|
|
'backward',
|
|
page=2,
|
|
page_size=200,
|
|
)
|
|
|
|
|
|
def test_detail_missing_dates_returns_400():
|
|
client = _client()
|
|
response = client.get('/api/mid-section-defect/analysis/detail?end_date=2025-01-31')
|
|
|
|
assert response.status_code == 400
|
|
payload = response.get_json()
|
|
assert payload['success'] is False
|
|
|
|
|
|
@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis_detail')
|
|
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 5))
|
|
def test_detail_rate_limited_returns_429(_mock_rate_limit, mock_query_detail):
|
|
client = _client()
|
|
response = client.get('/api/mid-section-defect/analysis/detail?start_date=2025-01-01&end_date=2025-01-31')
|
|
|
|
assert response.status_code == 429
|
|
assert response.headers.get('Retry-After') == '5'
|
|
payload = response.get_json()
|
|
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
|
|
mock_query_detail.assert_not_called()
|
|
|
|
|
|
@patch('mes_dashboard.routes.mid_section_defect_routes.export_csv')
|
|
def test_export_success(mock_export_csv):
|
|
mock_export_csv.return_value = iter([
|
|
'\ufeff',
|
|
'LOT ID,TYPE\r\n',
|
|
'A001,T1\r\n',
|
|
])
|
|
|
|
client = _client()
|
|
response = client.get(
|
|
'/api/mid-section-defect/export?start_date=2025-01-01&end_date=2025-01-31&loss_reasons=A,B'
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert 'text/csv' in response.content_type
|
|
assert 'attachment;' in response.headers.get('Content-Disposition', '')
|
|
mock_export_csv.assert_called_once_with(
|
|
'2025-01-01', '2025-01-31', ['A', 'B'], '測試', 'backward',
|
|
)
|
|
|
|
|
|
@patch('mes_dashboard.routes.mid_section_defect_routes.export_csv')
|
|
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 9))
|
|
def test_export_rate_limited_returns_429(_mock_rate_limit, mock_export_csv):
|
|
client = _client()
|
|
response = client.get('/api/mid-section-defect/export?start_date=2025-01-01&end_date=2025-01-31')
|
|
|
|
assert response.status_code == 429
|
|
assert response.headers.get('Retry-After') == '9'
|
|
payload = response.get_json()
|
|
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
|
|
mock_export_csv.assert_not_called()
|
|
|
|
|
|
@patch('mes_dashboard.routes.mid_section_defect_routes.query_station_options')
|
|
def test_station_options_success(mock_query_station_options):
|
|
mock_query_station_options.return_value = [
|
|
{'name': '切割', 'order': 0},
|
|
{'name': '測試', 'order': 11},
|
|
]
|
|
|
|
client = _client()
|
|
response = client.get('/api/mid-section-defect/station-options')
|
|
|
|
assert response.status_code == 200
|
|
payload = response.get_json()
|
|
assert payload['success'] is True
|
|
assert len(payload['data']) == 2
|
|
assert payload['data'][0]['name'] == '切割'
|