# -*- coding: utf-8 -*- """Unit tests for TMTT Defect Analysis Service.""" import unittest from unittest.mock import patch, MagicMock import pandas as pd from mes_dashboard.services.tmtt_defect_service import ( _build_kpi, _build_chart_data, _build_all_charts, _build_detail_table, _validate_date_range, query_tmtt_defect_analysis, PRINT_DEFECT, LEAD_DEFECT, ) def _make_df(rows): """Helper to create test DataFrame from list of dicts.""" cols = [ 'CONTAINERID', 'CONTAINERNAME', 'PJ_TYPE', 'PRODUCTLINENAME', 'WORKFLOW', 'FINISHEDRUNCARD', 'TMTT_EQUIPMENTID', 'TMTT_EQUIPMENTNAME', 'TRACKINQTY', 'TRACKINTIMESTAMP', 'MOLD_EQUIPMENTID', 'MOLD_EQUIPMENTNAME', 'LOSSREASONNAME', 'REJECTQTY', ] if not rows: return pd.DataFrame(columns=cols) df = pd.DataFrame(rows) for c in cols: if c not in df.columns: df[c] = None return df class TestValidateDateRange(unittest.TestCase): """Test date range validation.""" def test_valid_range(self): self.assertIsNone(_validate_date_range('2025-01-01', '2025-01-31')) def test_invalid_format(self): result = _validate_date_range('2025/01/01', '2025-01-31') self.assertIn('格式', result) def test_start_after_end(self): result = _validate_date_range('2025-02-01', '2025-01-01') self.assertIn('不能晚於', result) def test_exceeds_max_days(self): result = _validate_date_range('2025-01-01', '2025-12-31') self.assertIn('180', result) def test_exactly_max_days(self): self.assertIsNone(_validate_date_range('2025-01-01', '2025-06-30')) class TestBuildKpi(unittest.TestCase): """Test KPI calculation with separate defect rates.""" def test_empty_dataframe(self): df = _make_df([]) kpi = _build_kpi(df) self.assertEqual(kpi['total_input'], 0) self.assertEqual(kpi['lot_count'], 0) self.assertEqual(kpi['print_defect_qty'], 0) self.assertEqual(kpi['lead_defect_qty'], 0) self.assertEqual(kpi['print_defect_rate'], 0.0) self.assertEqual(kpi['lead_defect_rate'], 0.0) def test_single_lot_no_defects(self): df = _make_df([{ 'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'LOSSREASONNAME': None, 'REJECTQTY': 0, }]) kpi = _build_kpi(df) self.assertEqual(kpi['total_input'], 100) self.assertEqual(kpi['lot_count'], 1) self.assertEqual(kpi['print_defect_qty'], 0) self.assertEqual(kpi['lead_defect_qty'], 0) def test_separate_defect_rates(self): """A LOT with both print and lead defects - rates calculated separately.""" df = _make_df([ {'CONTAINERID': 'A001', 'TRACKINQTY': 10000, 'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 50}, {'CONTAINERID': 'A001', 'TRACKINQTY': 10000, 'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 30}, ]) kpi = _build_kpi(df) # INPUT should be deduplicated (10000, not 20000) self.assertEqual(kpi['total_input'], 10000) self.assertEqual(kpi['lot_count'], 1) self.assertEqual(kpi['print_defect_qty'], 50) self.assertEqual(kpi['lead_defect_qty'], 30) self.assertAlmostEqual(kpi['print_defect_rate'], 0.5, places=4) self.assertAlmostEqual(kpi['lead_defect_rate'], 0.3, places=4) def test_multiple_lots(self): df = _make_df([ {'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 2}, {'CONTAINERID': 'A002', 'TRACKINQTY': 200, 'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 1}, {'CONTAINERID': 'A003', 'TRACKINQTY': 300, 'LOSSREASONNAME': None, 'REJECTQTY': 0}, ]) kpi = _build_kpi(df) self.assertEqual(kpi['total_input'], 600) self.assertEqual(kpi['lot_count'], 3) self.assertEqual(kpi['print_defect_qty'], 2) self.assertEqual(kpi['lead_defect_qty'], 1) class TestBuildChartData(unittest.TestCase): """Test Pareto chart data aggregation.""" def test_empty_dataframe(self): df = _make_df([]) result = _build_chart_data(df, 'PJ_TYPE') self.assertEqual(result, []) def test_single_dimension_value(self): df = _make_df([ {'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA', 'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 5}, {'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA', 'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 3}, ]) result = _build_chart_data(df, 'PJ_TYPE') self.assertEqual(len(result), 1) self.assertEqual(result[0]['name'], 'TypeA') self.assertEqual(result[0]['print_defect_qty'], 5) self.assertEqual(result[0]['lead_defect_qty'], 3) self.assertEqual(result[0]['total_defect_qty'], 8) self.assertAlmostEqual(result[0]['cumulative_pct'], 100.0) def test_null_dimension_grouped_as_unknown(self): df = _make_df([ {'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'MOLD_EQUIPMENTNAME': None, 'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 2}, ]) result = _build_chart_data(df, 'MOLD_EQUIPMENTNAME') self.assertEqual(len(result), 1) self.assertEqual(result[0]['name'], '(未知)') def test_sorted_by_total_defect_desc(self): df = _make_df([ {'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA', 'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 1}, {'CONTAINERID': 'A002', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeB', 'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 10}, ]) result = _build_chart_data(df, 'PJ_TYPE') self.assertEqual(result[0]['name'], 'TypeB') self.assertEqual(result[1]['name'], 'TypeA') def test_cumulative_percentage(self): df = _make_df([ {'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA', 'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 6}, {'CONTAINERID': 'A002', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeB', 'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 4}, ]) result = _build_chart_data(df, 'PJ_TYPE') # TypeA: 6/10 = 60%, TypeB: cumulative 10/10 = 100% self.assertAlmostEqual(result[0]['cumulative_pct'], 60.0) self.assertAlmostEqual(result[1]['cumulative_pct'], 100.0) class TestBuildAllCharts(unittest.TestCase): """Test all 5 chart dimensions are built.""" def test_returns_all_dimensions(self): df = _make_df([{ 'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'WORKFLOW': 'WF1', 'PRODUCTLINENAME': 'PKG1', 'PJ_TYPE': 'T1', 'TMTT_EQUIPMENTNAME': 'TMTT-1', 'MOLD_EQUIPMENTNAME': 'MOLD-1', 'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 1, }]) charts = _build_all_charts(df) self.assertIn('by_workflow', charts) self.assertIn('by_package', charts) self.assertIn('by_type', charts) self.assertIn('by_tmtt_machine', charts) self.assertIn('by_mold_machine', charts) class TestBuildDetailTable(unittest.TestCase): """Test detail table building.""" def test_empty_dataframe(self): df = _make_df([]) result = _build_detail_table(df) self.assertEqual(result, []) def test_single_lot_aggregated(self): """LOT with both defect types should produce one row.""" df = _make_df([ {'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001', 'TRACKINQTY': 100, 'PJ_TYPE': 'T1', 'PRODUCTLINENAME': 'P1', 'WORKFLOW': 'WF1', 'FINISHEDRUNCARD': 'RC001', 'TMTT_EQUIPMENTNAME': 'TMTT-1', 'MOLD_EQUIPMENTNAME': 'MOLD-1', 'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 5}, {'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001', 'TRACKINQTY': 100, 'PJ_TYPE': 'T1', 'PRODUCTLINENAME': 'P1', 'WORKFLOW': 'WF1', 'FINISHEDRUNCARD': 'RC001', 'TMTT_EQUIPMENTNAME': 'TMTT-1', 'MOLD_EQUIPMENTNAME': 'MOLD-1', 'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 3}, ]) result = _build_detail_table(df) self.assertEqual(len(result), 1) row = result[0] self.assertEqual(row['CONTAINERNAME'], 'LOT-001') self.assertEqual(row['INPUT_QTY'], 100) self.assertEqual(row['PRINT_DEFECT_QTY'], 5) self.assertEqual(row['LEAD_DEFECT_QTY'], 3) self.assertAlmostEqual(row['PRINT_DEFECT_RATE'], 5.0, places=4) self.assertAlmostEqual(row['LEAD_DEFECT_RATE'], 3.0, places=4) def test_lot_with_no_defects(self): df = _make_df([{ 'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001', 'TRACKINQTY': 100, 'PJ_TYPE': 'T1', 'LOSSREASONNAME': None, 'REJECTQTY': 0, }]) result = _build_detail_table(df) self.assertEqual(len(result), 1) self.assertEqual(result[0]['PRINT_DEFECT_QTY'], 0) self.assertEqual(result[0]['LEAD_DEFECT_QTY'], 0) class TestQueryTmttDefectAnalysis(unittest.TestCase): """Test the main entry point function.""" def setUp(self): from mes_dashboard.core import database as db db._ENGINE = None @patch('mes_dashboard.services.tmtt_defect_service.cache_get', return_value=None) @patch('mes_dashboard.services.tmtt_defect_service.cache_set') @patch('mes_dashboard.services.tmtt_defect_service._fetch_base_data') def test_valid_query(self, mock_fetch, mock_cache_set, mock_cache_get): mock_fetch.return_value = _make_df([{ 'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001', 'TRACKINQTY': 100, 'PJ_TYPE': 'T1', 'PRODUCTLINENAME': 'P1', 'WORKFLOW': 'WF1', 'FINISHEDRUNCARD': 'RC001', 'TMTT_EQUIPMENTNAME': 'TMTT-1', 'MOLD_EQUIPMENTNAME': 'MOLD-1', 'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 2, }]) result = query_tmtt_defect_analysis('2025-01-01', '2025-01-31') self.assertIn('kpi', result) self.assertIn('charts', result) self.assertIn('detail', result) self.assertNotIn('error', result) mock_cache_set.assert_called_once() def test_invalid_dates(self): result = query_tmtt_defect_analysis('invalid', '2025-01-31') self.assertIn('error', result) def test_exceeds_max_days(self): result = query_tmtt_defect_analysis('2025-01-01', '2025-12-31') self.assertIn('error', result) self.assertIn('180', result['error']) @patch('mes_dashboard.services.tmtt_defect_service.cache_get') def test_cache_hit(self, mock_cache_get): cached_data = {'kpi': {}, 'charts': {}, 'detail': []} mock_cache_get.return_value = cached_data result = query_tmtt_defect_analysis('2025-01-01', '2025-01-31') self.assertEqual(result, cached_data) @patch('mes_dashboard.services.tmtt_defect_service.cache_get', return_value=None) @patch('mes_dashboard.services.tmtt_defect_service._fetch_base_data', return_value=None) def test_query_failure(self, mock_fetch, mock_cache_get): result = query_tmtt_defect_analysis('2025-01-01', '2025-01-31') self.assertIsNone(result) if __name__ == '__main__': unittest.main()