dash-devtools 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. dash_devtools/__init__.py +8 -0
  2. dash_devtools/__main__.py +11 -0
  3. dash_devtools/ai_engine.py +441 -0
  4. dash_devtools/browser.py +541 -0
  5. dash_devtools/cli.py +1452 -0
  6. dash_devtools/database.py +338 -0
  7. dash_devtools/dbdiagram.py +183 -0
  8. dash_devtools/e2e.py +329 -0
  9. dash_devtools/fixers/__init__.py +57 -0
  10. dash_devtools/fixers/migration_fixer.py +115 -0
  11. dash_devtools/fixers/ux_fixer.py +106 -0
  12. dash_devtools/fixers/version_bumper.py +115 -0
  13. dash_devtools/gas_mes_test.py +1241 -0
  14. dash_devtools/generators/__init__.py +84 -0
  15. dash_devtools/health.py +476 -0
  16. dash_devtools/hooks/__init__.py +250 -0
  17. dash_devtools/hooks/pre_commit.py +161 -0
  18. dash_devtools/hooks/pre_push.py +275 -0
  19. dash_devtools/init_test.py +352 -0
  20. dash_devtools/markdown_report.py +309 -0
  21. dash_devtools/migrators/__init__.py +21 -0
  22. dash_devtools/perf.py +321 -0
  23. dash_devtools/report.py +667 -0
  24. dash_devtools/reporters/__init__.py +11 -0
  25. dash_devtools/spec.py +230 -0
  26. dash_devtools/stats.py +355 -0
  27. dash_devtools/test_suite.py +690 -0
  28. dash_devtools/testing.py +416 -0
  29. dash_devtools/validators/__init__.py +157 -0
  30. dash_devtools/validators/backend/__init__.py +12 -0
  31. dash_devtools/validators/backend/nodejs.py +245 -0
  32. dash_devtools/validators/backend/python.py +439 -0
  33. dash_devtools/validators/code_quality.py +243 -0
  34. dash_devtools/validators/common/__init__.py +11 -0
  35. dash_devtools/validators/common/quality.py +319 -0
  36. dash_devtools/validators/common/security.py +270 -0
  37. dash_devtools/validators/common/spec.py +273 -0
  38. dash_devtools/validators/detector.py +394 -0
  39. dash_devtools/validators/frontend/__init__.py +14 -0
  40. dash_devtools/validators/frontend/angular.py +245 -0
  41. dash_devtools/validators/frontend/gas.py +310 -0
  42. dash_devtools/validators/frontend/vite.py +539 -0
  43. dash_devtools/validators/migration.py +292 -0
  44. dash_devtools/validators/performance.py +167 -0
  45. dash_devtools/validators/security.py +205 -0
  46. dash_devtools/vision/__init__.py +368 -0
  47. dash_devtools/watch.py +266 -0
  48. dash_devtools/word_report.py +690 -0
  49. dash_devtools-1.0.0.dist-info/METADATA +834 -0
  50. dash_devtools-1.0.0.dist-info/RECORD +53 -0
  51. dash_devtools-1.0.0.dist-info/WHEEL +5 -0
  52. dash_devtools-1.0.0.dist-info/entry_points.txt +2 -0
  53. dash_devtools-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1241 @@
1
+ """
2
+ GAS MES 完整四大測試模組
3
+
4
+ 針對 Google Apps Script MES 系統的完整測試套件:
5
+ - UIT: 程式碼靜態分析 (85+ API、14 模組、HTML 品質)
6
+ - Smoke: 全模組頁面載入測試 (桌面版 + 手機版)
7
+ - E2E: 各模組 CRUD 功能驗證
8
+ - UAT: 6 種角色 × 14 模組權限驗證
9
+
10
+ 使用方式:
11
+ dash gas-test /path/to/gas/mes
12
+ dash gas-test --word report.docx
13
+ """
14
+
15
+ import re
16
+ import json
17
+ import subprocess
18
+ import tempfile
19
+ from pathlib import Path
20
+ from dataclasses import dataclass, field
21
+ from typing import Dict, List, Optional, Tuple, Set
22
+ from datetime import datetime
23
+ from rich.console import Console
24
+ from rich.table import Table
25
+ from rich.panel import Panel
26
+ from rich.progress import Progress, SpinnerColumn, TextColumn
27
+
28
+ console = Console()
29
+
30
+ # GAS MES 部署 URL
31
+ GAS_MES_URL = "https://script.google.com/macros/s/AKfycbwbX1uACKWhzRhe8JxlXwKEWbZ7ysduAQtf2R2drxIZm5X6acMX7WFUMEpCGouPELoKYw/exec"
32
+
33
+ # ========== 模組與 API 定義 ==========
34
+
35
+ # 14 個功能模組及其對應 API
36
+ MODULE_APIS = {
37
+ 'dashboard': {
38
+ 'name': '戰情中心',
39
+ 'tab_id': 'dashboard',
40
+ 'apis': ['getScheduleStats', 'getWorkOrders', 'getDispatches', 'getReports'],
41
+ 'required_apis': ['getScheduleStats'],
42
+ },
43
+ 'work-orders': {
44
+ 'name': '工單管理',
45
+ 'tab_id': 'work-orders',
46
+ 'apis': ['getWorkOrders', 'createWorkOrder', 'updateWorkOrder', 'deleteWorkOrder', 'cleanInvalidWorkOrders'],
47
+ 'required_apis': ['getWorkOrders', 'createWorkOrder', 'updateWorkOrder'],
48
+ 'crud': ['create', 'read', 'update', 'delete'],
49
+ },
50
+ 'dispatches': {
51
+ 'name': '現場派工',
52
+ 'tab_id': 'dispatches',
53
+ 'apis': ['getDispatches', 'createDispatch', 'updateDispatch', 'deleteDispatch', 'startDispatch', 'completeDispatch'],
54
+ 'required_apis': ['getDispatches', 'createDispatch', 'startDispatch', 'completeDispatch'],
55
+ 'crud': ['create', 'read', 'update', 'delete'],
56
+ },
57
+ 'reports': {
58
+ 'name': '報工紀錄',
59
+ 'tab_id': 'reports',
60
+ 'apis': ['getReports', 'createReport', 'getNgDetailsByReport', 'createNgDetail', 'getNgReasons', 'createNgReason', 'updateNgReason', 'deleteNgReason'],
61
+ 'required_apis': ['getReports', 'createReport'],
62
+ 'crud': ['create', 'read'],
63
+ },
64
+ 'outgassing': {
65
+ 'name': '釋氣檢驗',
66
+ 'tab_id': 'outgassing',
67
+ 'apis': ['getOutgassingTests', 'createOutgassingTest', 'getOutgassingSampleInfo'],
68
+ 'required_apis': ['getOutgassingTests', 'createOutgassingTest'],
69
+ 'crud': ['create', 'read'],
70
+ 'features': ['signature', 'sample_info'],
71
+ },
72
+ 'aoi': {
73
+ 'name': 'AOI 檢驗',
74
+ 'tab_id': 'aoi',
75
+ 'apis': ['getAoiInspections', 'createAoiInspection', 'importAoiCsv'],
76
+ 'required_apis': ['getAoiInspections', 'createAoiInspection'],
77
+ 'crud': ['create', 'read'],
78
+ 'features': ['csv_import'],
79
+ },
80
+ 'r0': {
81
+ 'name': '標籤管理',
82
+ 'tab_id': 'r0',
83
+ 'apis': ['getR0Labels', 'createR0Label', 'updateR0Label', 'syncR0Labels', 'getEpcHistory', 'createEpcHistory', 'checkEpc'],
84
+ 'required_apis': ['getR0Labels', 'createR0Label'],
85
+ 'crud': ['create', 'read', 'update'],
86
+ 'features': ['epc_check', 'sync'],
87
+ },
88
+ 'label-editor': {
89
+ 'name': '樣式設計',
90
+ 'tab_id': 'label-editor',
91
+ 'apis': [], # 前端功能,無後端 API
92
+ 'required_apis': [],
93
+ 'features': ['canvas_editor'],
94
+ },
95
+ 'schedule': {
96
+ 'name': '排程管理',
97
+ 'tab_id': 'schedule',
98
+ 'apis': [
99
+ 'getSchedulePlans', 'createSchedulePlan', 'updateSchedulePlan', 'getScheduleStats',
100
+ 'getShifts', 'createShift', 'updateShift', 'deleteShift', 'getShiftsByDate', 'getShiftsByDateRange', 'importShifts',
101
+ 'getEquipmentSchedules', 'createEquipmentSchedule', 'updateEquipmentSchedule', 'deleteEquipmentSchedule', 'getEquipmentSchedulesByDate'
102
+ ],
103
+ 'required_apis': ['getSchedulePlans', 'getShifts', 'getEquipmentSchedules'],
104
+ 'crud': ['create', 'read', 'update', 'delete'],
105
+ 'features': ['calendar', 'drag_drop', 'import'],
106
+ },
107
+ 'oven': {
108
+ 'name': '烘箱監控',
109
+ 'tab_id': 'oven',
110
+ 'apis': ['getEquipmentSchedules', 'getEquipmentSchedulesByDate'],
111
+ 'required_apis': ['getEquipmentSchedules'],
112
+ 'features': ['realtime_status'],
113
+ },
114
+ 'wms': {
115
+ 'name': '倉儲管理',
116
+ 'tab_id': 'wms',
117
+ 'apis': [
118
+ 'getWmsLocations', 'createWmsLocation', 'updateWmsLocation', 'deleteWmsLocation', 'initWmsLocations',
119
+ 'getWmsInventory', 'getWmsLocationSummary', 'getWmsMovements',
120
+ 'wmsInbound', 'wmsOutbound', 'wmsTransfer',
121
+ 'getWmsStockTakes', 'createWmsStockTake'
122
+ ],
123
+ 'required_apis': ['getWmsLocations', 'getWmsInventory', 'wmsInbound', 'wmsOutbound', 'wmsTransfer'],
124
+ 'crud': ['create', 'read', 'update', 'delete'],
125
+ 'features': ['inbound', 'outbound', 'transfer', 'stock_take'],
126
+ },
127
+ 'parts': {
128
+ 'name': '物料主檔',
129
+ 'tab_id': 'parts',
130
+ 'apis': ['getParts', 'createPart', 'updatePart', 'deletePart'],
131
+ 'required_apis': ['getParts', 'createPart'],
132
+ 'crud': ['create', 'read', 'update', 'delete'],
133
+ },
134
+ 'audit': {
135
+ 'name': '操作紀錄',
136
+ 'tab_id': 'audit',
137
+ 'apis': ['getAuditLogs', 'createAuditLog'],
138
+ 'required_apis': ['getAuditLogs', 'createAuditLog'],
139
+ 'features': ['iso27001', 'login_logout'],
140
+ },
141
+ 'settings': {
142
+ 'name': '設定',
143
+ 'tab_id': 'settings',
144
+ 'apis': [
145
+ 'getOperators', 'createOperator', 'updateOperator', 'deleteOperator',
146
+ 'getCustomers', 'createCustomer', 'updateCustomer', 'deleteCustomer',
147
+ 'getProducts', 'createProduct', 'updateProduct', 'deleteProduct',
148
+ 'syncDatabase', 'clearCache', 'fixColumnOrder', 'resetMes', 'generateTestData'
149
+ ],
150
+ 'required_apis': ['getOperators', 'getCustomers', 'getProducts', 'syncDatabase'],
151
+ 'crud': ['create', 'read', 'update', 'delete'],
152
+ 'features': ['operators', 'customers', 'products', 'sync', 'maintenance'],
153
+ },
154
+ }
155
+
156
+ # 系統層級 API
157
+ SYSTEM_APIS = ['getVersion', 'getAllData', 'getShortUrl', 'debugSheet', 'fixSignatureData', 'fixStationName']
158
+
159
+ # 角色權限定義
160
+ ROLE_PERMISSIONS = {
161
+ 'operator': ['work-orders-view', 'dispatches', 'reports'],
162
+ 'warehouse': ['work-orders-view', 'wms', 'parts'],
163
+ 'qc': ['work-orders-view', 'outgassing', 'aoi', 'r0'],
164
+ 'clerk': ['work-orders-view', 'wms', 'parts', 'audit', 'settings'],
165
+ 'supervisor': ['dashboard', 'work-orders-view', 'dispatches', 'reports',
166
+ 'outgassing', 'aoi', 'r0', 'schedule', 'oven', 'wms', 'parts', 'audit'],
167
+ 'admin': ['dashboard', 'work-orders', 'dispatches', 'reports', 'outgassing',
168
+ 'aoi', 'r0', 'label-editor', 'schedule', 'oven', 'wms', 'parts', 'audit', 'settings']
169
+ }
170
+
171
+ # 角色中文名稱
172
+ ROLE_NAMES = {
173
+ 'operator': '作業員',
174
+ 'warehouse': '倉庫',
175
+ 'qc': '品管',
176
+ 'clerk': '行政',
177
+ 'supervisor': '主管',
178
+ 'admin': '管理員'
179
+ }
180
+
181
+
182
+ @dataclass
183
+ class TestCase:
184
+ """測試案例"""
185
+ id: str
186
+ name: str
187
+ module: str = ''
188
+ status: str = 'pending' # pending, passed, failed, skipped
189
+ duration: float = 0.0
190
+ error: str = ''
191
+ screenshot: str = ''
192
+ details: str = ''
193
+
194
+
195
+ @dataclass
196
+ class TestTypeResult:
197
+ """測試類型結果"""
198
+ test_type: str
199
+ passed: int = 0
200
+ failed: int = 0
201
+ skipped: int = 0
202
+ duration: float = 0.0
203
+ success: bool = True
204
+ error: str = ''
205
+ test_cases: List[TestCase] = field(default_factory=list)
206
+ not_configured: bool = False
207
+
208
+
209
+ @dataclass
210
+ class GASMESTestResult:
211
+ """GAS MES 測試結果"""
212
+ project_name: str = 'GAS MES'
213
+ results: Dict[str, TestTypeResult] = field(default_factory=dict)
214
+ total_passed: int = 0
215
+ total_failed: int = 0
216
+ total_duration: float = 0.0
217
+ overall_success: bool = True
218
+ timestamp: str = ''
219
+ version: str = ''
220
+ api_count: int = 0
221
+ module_count: int = 14
222
+
223
+
224
+ class GASMESTestRunner:
225
+ """GAS MES 完整測試執行器"""
226
+
227
+ def __init__(self, project_path: str, url: str = None):
228
+ self.project_path = Path(project_path)
229
+ self.url = url or GAS_MES_URL
230
+ self.version = self._get_version()
231
+ self.screenshot_dir = Path(tempfile.mkdtemp(prefix='gas-mes-test-'))
232
+ self.code_content = self._read_file('Code.js')
233
+ self.db_content = self._read_file('Database.js')
234
+ self.app_content = self._read_file('app.html')
235
+ self.discovered_apis = self._discover_apis()
236
+
237
+ def _read_file(self, filename: str) -> str:
238
+ """讀取檔案內容"""
239
+ filepath = self.project_path / filename
240
+ if filepath.exists():
241
+ return filepath.read_text(encoding='utf-8')
242
+ return ''
243
+
244
+ def _get_version(self) -> str:
245
+ """從 Code.js 取得版本號"""
246
+ code_js = self.project_path / 'Code.js'
247
+ if code_js.exists():
248
+ content = code_js.read_text(encoding='utf-8')
249
+ match = re.search(r"case\s+['\"]getVersion['\"]:\s*\n?\s*return\s*{\s*success:\s*true,\s*data:\s*['\"]([^'\"]+)['\"]", content)
250
+ if match:
251
+ return match.group(1)
252
+ return 'unknown'
253
+
254
+ def _discover_apis(self) -> Set[str]:
255
+ """從 Code.js 發現所有 API"""
256
+ apis = set()
257
+ if self.code_content:
258
+ matches = re.findall(r"case\s+['\"](\w+)['\"]:", self.code_content)
259
+ apis = set(matches)
260
+ return apis
261
+
262
+ def _get_puppeteer_cwd(self) -> str:
263
+ """取得有安裝 Puppeteer 的目錄"""
264
+ check_dirs = [
265
+ '/Users/dash/Documents/github/smai-process-vision',
266
+ '/Users/dash/Documents/github/MES',
267
+ str(self.project_path),
268
+ ]
269
+ for check_dir in check_dirs:
270
+ node_modules = Path(check_dir) / 'node_modules' / 'puppeteer'
271
+ if node_modules.exists():
272
+ return check_dir
273
+ return '/Users/dash/Documents/github/smai-process-vision'
274
+
275
+ def _run_puppeteer_test(self, script: str, timeout: int = 60000) -> Dict:
276
+ """執行 Puppeteer 測試腳本"""
277
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False) as f:
278
+ f.write(script)
279
+ script_path = f.name
280
+
281
+ try:
282
+ puppeteer_cwd = self._get_puppeteer_cwd()
283
+ env = {**subprocess.os.environ, 'NODE_PATH': f"{puppeteer_cwd}/node_modules"}
284
+
285
+ proc = subprocess.run(
286
+ ['node', script_path],
287
+ capture_output=True,
288
+ text=True,
289
+ timeout=timeout / 1000 + 30,
290
+ cwd=puppeteer_cwd,
291
+ env=env
292
+ )
293
+
294
+ if proc.returncode != 0:
295
+ return {'success': False, 'error': proc.stderr[:500]}
296
+
297
+ return json.loads(proc.stdout.strip())
298
+ except subprocess.TimeoutExpired:
299
+ return {'success': False, 'error': 'Timeout'}
300
+ except json.JSONDecodeError as e:
301
+ return {'success': False, 'error': f'Invalid JSON: {str(e)[:100]}'}
302
+ finally:
303
+ Path(script_path).unlink(missing_ok=True)
304
+
305
+ # ========== UIT: 完整靜態分析 ==========
306
+
307
+ def run_uit(self) -> TestTypeResult:
308
+ """執行 UIT 靜態分析測試 - 涵蓋所有模組"""
309
+ result = TestTypeResult(test_type='UIT')
310
+ start_time = datetime.now()
311
+
312
+ # 1. 系統層級測試
313
+ system_tests = [
314
+ ('UIT-SYS-01', '系統', 'Code.js 版本管理', self._check_version_management),
315
+ ('UIT-SYS-02', '系統', 'Database.js 資料表定義', self._check_database_schema),
316
+ ('UIT-SYS-03', '系統', 'API 回應格式一致性', self._check_api_format),
317
+ ('UIT-SYS-04', '系統', 'appsscript.json 設定', self._check_appsscript_config),
318
+ ('UIT-SYS-05', '系統', 'HTML v-for :key 綁定', self._check_vfor_key),
319
+ ('UIT-SYS-06', '系統', 'HTML 標籤閉合', self._check_html_tags),
320
+ ('UIT-SYS-07', '系統', f'API 總數驗證 ({len(self.discovered_apis)} 個)', self._check_api_count),
321
+ ]
322
+
323
+ for test_id, module, test_name, test_func in system_tests:
324
+ tc = self._run_test_case(test_id, test_name, module, test_func)
325
+ result.test_cases.append(tc)
326
+ if tc.status == 'passed':
327
+ result.passed += 1
328
+ else:
329
+ result.failed += 1
330
+
331
+ # 2. 各模組 API 測試
332
+ for module_id, module_info in MODULE_APIS.items():
333
+ test_id = f'UIT-{module_id.upper()[:3]}-API'
334
+ test_name = f"{module_info['name']} API 完整性"
335
+
336
+ tc = TestCase(id=test_id, name=test_name, module=module_info['name'])
337
+ tc_start = datetime.now()
338
+
339
+ try:
340
+ required = module_info.get('required_apis', [])
341
+ if not required:
342
+ tc.status = 'passed'
343
+ tc.details = '無必要 API (前端功能)'
344
+ else:
345
+ missing = [api for api in required if api not in self.discovered_apis]
346
+ if missing:
347
+ tc.status = 'failed'
348
+ tc.error = f"缺少 API: {', '.join(missing)}"
349
+ else:
350
+ tc.status = 'passed'
351
+ tc.details = f"所有 {len(required)} 個必要 API 存在"
352
+
353
+ except Exception as e:
354
+ tc.status = 'failed'
355
+ tc.error = str(e)
356
+
357
+ tc.duration = (datetime.now() - tc_start).total_seconds()
358
+ result.test_cases.append(tc)
359
+ if tc.status == 'passed':
360
+ result.passed += 1
361
+ else:
362
+ result.failed += 1
363
+
364
+ # 3. 特殊功能測試
365
+ feature_tests = [
366
+ ('UIT-FEAT-01', '功能', '角色權限定義 (6 種角色)', self._check_role_permissions),
367
+ ('UIT-FEAT-02', '功能', 'ISO 27001 稽核日誌', self._check_audit_logging),
368
+ ('UIT-FEAT-03', '功能', 'PIN 碼驗證功能', self._check_pin_verification),
369
+ ('UIT-FEAT-04', '功能', '簽名功能 (Canvas)', self._check_signature_feature),
370
+ ('UIT-FEAT-05', '功能', 'CSV 匯入功能', self._check_csv_import),
371
+ ('UIT-FEAT-06', '功能', '同步功能 (syncDatabase)', self._check_sync_feature),
372
+ ]
373
+
374
+ for test_id, module, test_name, test_func in feature_tests:
375
+ tc = self._run_test_case(test_id, test_name, module, test_func)
376
+ result.test_cases.append(tc)
377
+ if tc.status == 'passed':
378
+ result.passed += 1
379
+ else:
380
+ result.failed += 1
381
+
382
+ result.duration = (datetime.now() - start_time).total_seconds()
383
+ result.success = result.failed == 0
384
+ return result
385
+
386
+ def _run_test_case(self, test_id: str, test_name: str, module: str, test_func) -> TestCase:
387
+ """執行單一測試案例"""
388
+ tc = TestCase(id=test_id, name=test_name, module=module)
389
+ tc_start = datetime.now()
390
+ try:
391
+ passed, details = test_func()
392
+ tc.status = 'passed' if passed else 'failed'
393
+ tc.details = details
394
+ if not passed:
395
+ tc.error = details
396
+ except Exception as e:
397
+ tc.status = 'failed'
398
+ tc.error = str(e)
399
+ tc.duration = (datetime.now() - tc_start).total_seconds()
400
+ return tc
401
+
402
+ def _check_version_management(self) -> Tuple[bool, str]:
403
+ if "case 'getVersion':" in self.code_content or 'case "getVersion":' in self.code_content:
404
+ return True, f'版本號: {self.version}'
405
+ return False, '缺少 getVersion API'
406
+
407
+ def _check_database_schema(self) -> Tuple[bool, str]:
408
+ if not self.db_content:
409
+ return False, 'Database.js 不存在'
410
+ has_config = 'DB_CONFIG' in self.db_content or 'SCHEMA' in self.db_content
411
+ if not has_config:
412
+ return False, '缺少資料庫設定定義'
413
+ tables = re.findall(r'(\w+):\s*{\s*name:\s*[\'"](\w+)[\'"]', self.db_content)
414
+ if tables:
415
+ return True, f'定義 {len(tables)} 個資料表'
416
+ headers_count = len(re.findall(r'headers:\s*\[', self.db_content))
417
+ if headers_count > 0:
418
+ return True, f'定義 {headers_count} 個資料表'
419
+ return False, '無法解析資料表定義'
420
+
421
+ def _check_api_format(self) -> Tuple[bool, str]:
422
+ success_returns = len(re.findall(r'return\s*{\s*success:\s*true', self.code_content))
423
+ error_returns = len(re.findall(r'return\s*{\s*success:\s*false', self.code_content))
424
+ if success_returns > 0 and error_returns > 0:
425
+ return True, f'統一格式: {success_returns} 成功 / {error_returns} 錯誤回應'
426
+ return False, '未使用統一 API 回應格式'
427
+
428
+ def _check_appsscript_config(self) -> Tuple[bool, str]:
429
+ config_file = self.project_path / 'appsscript.json'
430
+ if not config_file.exists():
431
+ return False, 'appsscript.json 不存在'
432
+ try:
433
+ config = json.loads(config_file.read_text(encoding='utf-8'))
434
+ runtime = config.get('runtimeVersion', 'DEPRECATED_ES5')
435
+ return True, f'runtime: {runtime}'
436
+ except json.JSONDecodeError as e:
437
+ return False, f'JSON 格式錯誤: {e}'
438
+
439
+ def _check_vfor_key(self) -> Tuple[bool, str]:
440
+ issues = []
441
+ for html_file in self.project_path.glob('*.html'):
442
+ content = html_file.read_text(encoding='utf-8')
443
+ v_for_without_key = re.findall(r'v-for="[^"]*"(?![^>]*:key)', content)
444
+ if v_for_without_key:
445
+ issues.append(f'{html_file.name}: {len(v_for_without_key)} 處')
446
+ if issues:
447
+ return False, f'缺少 :key: {", ".join(issues[:3])}'
448
+ return True, '所有 v-for 都有 :key'
449
+
450
+ def _check_html_tags(self) -> Tuple[bool, str]:
451
+ issues = []
452
+ tags_to_check = ['div', 'table', 'form']
453
+ for html_file in self.project_path.glob('*.html'):
454
+ content = html_file.read_text(encoding='utf-8')
455
+ for tag in tags_to_check:
456
+ open_count = len(re.findall(rf'<{tag}[^>]*(?<!/)>', content))
457
+ close_count = len(re.findall(rf'</{tag}>', content))
458
+ if open_count > close_count + 3:
459
+ issues.append(f'{html_file.name}: <{tag}>')
460
+ if issues:
461
+ return False, f'標籤可能未閉合: {"; ".join(issues[:3])}'
462
+ return True, 'HTML 標籤正確閉合'
463
+
464
+ def _check_api_count(self) -> Tuple[bool, str]:
465
+ count = len(self.discovered_apis)
466
+ if count >= 80:
467
+ return True, f'發現 {count} 個 API (預期 85+)'
468
+ return False, f'API 數量不足: {count} (預期 85+)'
469
+
470
+ def _check_role_permissions(self) -> Tuple[bool, str]:
471
+ roles = ['operator', 'warehouse', 'qc', 'clerk', 'supervisor', 'admin']
472
+ found_roles = [r for r in roles if f'{r}:' in self.app_content]
473
+ if len(found_roles) >= 6:
474
+ return True, f'定義 {len(found_roles)} 種角色'
475
+ return False, f'角色定義不完整: {found_roles}'
476
+
477
+ def _check_audit_logging(self) -> Tuple[bool, str]:
478
+ has_log = 'logAudit' in self.app_content or 'createAuditLog' in self.code_content
479
+ has_login = "'login'" in self.app_content or '"login"' in self.app_content
480
+ has_logout = "'logout'" in self.app_content or '"logout"' in self.app_content
481
+ if has_log and has_login and has_logout:
482
+ return True, 'ISO 27001: 登入/登出/操作記錄完整'
483
+ if has_log:
484
+ return True, '有稽核日誌功能 (建議加入登入/登出記錄)'
485
+ return False, '缺少稽核日誌功能'
486
+
487
+ def _check_pin_verification(self) -> Tuple[bool, str]:
488
+ has_pin = 'verifyPin' in self.app_content and 'showPinModal' in self.app_content
489
+ if has_pin:
490
+ return True, 'PIN 碼驗證功能已實作'
491
+ return False, '缺少 PIN 碼驗證功能'
492
+
493
+ def _check_signature_feature(self) -> Tuple[bool, str]:
494
+ has_signature = 'canvas' in self.app_content.lower() and 'signature' in self.app_content.lower()
495
+ if has_signature:
496
+ return True, '簽名功能 (Canvas) 已實作'
497
+ return False, '缺少簽名功能'
498
+
499
+ def _check_csv_import(self) -> Tuple[bool, str]:
500
+ has_import = 'importAoiCsv' in self.discovered_apis or 'importShifts' in self.discovered_apis
501
+ if has_import:
502
+ return True, 'CSV 匯入功能已實作'
503
+ return False, '缺少 CSV 匯入功能'
504
+
505
+ def _check_sync_feature(self) -> Tuple[bool, str]:
506
+ if 'syncDatabase' in self.discovered_apis:
507
+ return True, '同步功能 (syncDatabase) 已實作'
508
+ return False, '缺少同步功能'
509
+
510
+ # ========== Smoke: 全模組頁面載入測試 ==========
511
+
512
+ def run_smoke(self) -> TestTypeResult:
513
+ """執行 Smoke 煙霧測試 - 涵蓋所有模組"""
514
+ result = TestTypeResult(test_type='Smoke')
515
+ start_time = datetime.now()
516
+
517
+ # 1. 基礎載入測試
518
+ basic_tests = [
519
+ ('SMOKE-01', '系統', '應用程式載入', 'load'),
520
+ ('SMOKE-02', '系統', '無 JavaScript 錯誤', 'errors'),
521
+ ('SMOKE-03', '系統', '手機版無水平溢出', 'mobile'),
522
+ ('SMOKE-04', '系統', '版本號驗證', 'version'),
523
+ ]
524
+
525
+ for test_id, module, test_name, test_type in basic_tests:
526
+ tc = TestCase(id=test_id, name=test_name, module=module)
527
+ tc_start = datetime.now()
528
+
529
+ try:
530
+ if test_type == 'load':
531
+ passed, details, screenshot = self._test_page_load()
532
+ elif test_type == 'errors':
533
+ passed, details, screenshot = self._test_no_js_errors()
534
+ elif test_type == 'mobile':
535
+ passed, details, screenshot = self._test_mobile_overflow()
536
+ elif test_type == 'version':
537
+ passed, details, screenshot = self._test_version_display()
538
+ else:
539
+ passed, details, screenshot = False, '未知測試', ''
540
+
541
+ tc.status = 'passed' if passed else 'failed'
542
+ tc.details = details
543
+ tc.screenshot = screenshot
544
+ if not passed:
545
+ tc.error = details
546
+
547
+ except Exception as e:
548
+ tc.status = 'failed'
549
+ tc.error = str(e)
550
+
551
+ tc.duration = (datetime.now() - tc_start).total_seconds()
552
+ result.test_cases.append(tc)
553
+ if tc.status == 'passed':
554
+ result.passed += 1
555
+ else:
556
+ result.failed += 1
557
+
558
+ # 2. 各模組頁籤截圖測試
559
+ module_screenshots = self._test_all_module_screenshots()
560
+ module_names = {
561
+ 'dashboard': '戰情中心', 'work-orders': '工單管理', 'dispatches': '現場派工',
562
+ 'reports': '報工紀錄', 'outgassing': '釋氣檢驗', 'aoi': 'AOI檢驗',
563
+ 'r0': '標籤管理', 'label-editor': '樣式設計', 'schedule': '排程管理',
564
+ 'oven': '烘箱監控', 'wms': '倉儲管理', 'parts': '物料主檔',
565
+ 'audit': '操作紀錄', 'settings': '設定',
566
+ }
567
+
568
+ for tab_id, tab_name in module_names.items():
569
+ test_id = f'SMOKE-TAB-{tab_id.upper()[:3]}'
570
+ tc = TestCase(id=test_id, name=f'{tab_name} 頁籤截圖', module=tab_name)
571
+
572
+ if tab_id in module_screenshots:
573
+ tc.status = 'passed'
574
+ tc.details = f'截圖成功'
575
+ tc.screenshot = module_screenshots[tab_id]
576
+ result.passed += 1
577
+ else:
578
+ tc.status = 'failed'
579
+ tc.error = '截圖失敗或頁籤不存在'
580
+ result.failed += 1
581
+
582
+ result.test_cases.append(tc)
583
+
584
+ # 3. 各模組 HTML 檔案存在性測試
585
+ module_files = {
586
+ 'work-orders': 'tab-work-orders.html',
587
+ 'dispatches': 'tab-dispatches.html',
588
+ 'reports': 'tab-reports.html',
589
+ 'outgassing': 'tab-outgassing.html',
590
+ 'aoi': 'tab-aoi.html',
591
+ 'r0': 'tab-r0.html',
592
+ 'label-editor': 'tab-label-editor.html',
593
+ 'schedule': 'tab-schedule.html',
594
+ 'oven': 'tab-oven.html',
595
+ 'wms': 'tab-wms.html',
596
+ 'parts': 'tab-parts.html',
597
+ 'audit': 'tab-audit.html',
598
+ 'settings': 'tab-settings.html',
599
+ 'dashboard': 'tab-dashboard.html',
600
+ }
601
+
602
+ for module_id, filename in module_files.items():
603
+ module_name = MODULE_APIS.get(module_id, {}).get('name', module_id)
604
+ test_id = f'SMOKE-{module_id.upper()[:3]}'
605
+ tc = TestCase(id=test_id, name=f'{module_name} 頁面檔案', module=module_name)
606
+
607
+ filepath = self.project_path / filename
608
+ if filepath.exists():
609
+ content = filepath.read_text(encoding='utf-8')
610
+ # 檢查是否有實際內容
611
+ if len(content) > 100 and 'v-if' in content:
612
+ tc.status = 'passed'
613
+ tc.details = f'{filename} 存在 ({len(content)} bytes)'
614
+ else:
615
+ tc.status = 'passed'
616
+ tc.details = f'{filename} 存在 (簡易頁面)'
617
+ else:
618
+ tc.status = 'failed'
619
+ tc.error = f'{filename} 不存在'
620
+
621
+ result.test_cases.append(tc)
622
+ if tc.status == 'passed':
623
+ result.passed += 1
624
+ else:
625
+ result.failed += 1
626
+
627
+ result.duration = (datetime.now() - start_time).total_seconds()
628
+ result.success = result.failed == 0
629
+ return result
630
+
631
+ def _test_page_load(self) -> Tuple[bool, str, str]:
632
+ screenshot_path = str(self.screenshot_dir / 'smoke-01-load.png')
633
+ script = f'''
634
+ const puppeteer = require("puppeteer");
635
+ (async () => {{
636
+ const browser = await puppeteer.launch({{ headless: "new" }});
637
+ const page = await browser.newPage();
638
+ await page.setViewport({{ width: 1920, height: 1080 }});
639
+ const result = {{ success: false, loadTime: 0, status: 0 }};
640
+ try {{
641
+ const start = Date.now();
642
+ const response = await page.goto("{self.url}", {{ waitUntil: "networkidle0", timeout: 60000 }});
643
+ result.loadTime = Date.now() - start;
644
+ result.status = response ? response.status() : 0;
645
+ await new Promise(r => setTimeout(r, 5000));
646
+ await page.screenshot({{ path: "{screenshot_path}", fullPage: false }});
647
+ result.success = result.status === 200;
648
+ }} catch (e) {{ result.error = e.message; }}
649
+ await browser.close();
650
+ console.log(JSON.stringify(result));
651
+ }})();
652
+ '''
653
+ result = self._run_puppeteer_test(script)
654
+ if result.get('success'):
655
+ return True, f"載入時間: {result.get('loadTime', 0)}ms", screenshot_path
656
+ return False, result.get('error', '載入失敗'), screenshot_path
657
+
658
+ def _test_no_js_errors(self) -> Tuple[bool, str, str]:
659
+ screenshot_path = str(self.screenshot_dir / 'smoke-02-errors.png')
660
+ script = f'''
661
+ const puppeteer = require("puppeteer");
662
+ (async () => {{
663
+ const browser = await puppeteer.launch({{ headless: "new" }});
664
+ const page = await browser.newPage();
665
+ await page.setViewport({{ width: 1920, height: 1080 }});
666
+ const errors = [];
667
+ page.on("console", msg => {{
668
+ if (msg.type() === "error" && !msg.text().includes("favicon")) {{
669
+ errors.push(msg.text().substring(0, 150));
670
+ }}
671
+ }});
672
+ page.on("pageerror", err => errors.push(err.toString().substring(0, 150)));
673
+ try {{
674
+ await page.goto("{self.url}", {{ waitUntil: "networkidle0", timeout: 60000 }});
675
+ await new Promise(r => setTimeout(r, 5000));
676
+ await page.screenshot({{ path: "{screenshot_path}" }});
677
+ }} catch (e) {{ errors.push(e.message); }}
678
+ await browser.close();
679
+ console.log(JSON.stringify({{ success: errors.length === 0, errors }}));
680
+ }})();
681
+ '''
682
+ result = self._run_puppeteer_test(script)
683
+ if result.get('success'):
684
+ return True, '無 JavaScript 錯誤', screenshot_path
685
+ errors = result.get('errors', [])
686
+ return False, f"發現 {len(errors)} 個錯誤", screenshot_path
687
+
688
+ def _test_mobile_overflow(self) -> Tuple[bool, str, str]:
689
+ screenshot_path = str(self.screenshot_dir / 'smoke-03-mobile.png')
690
+ script = f'''
691
+ const puppeteer = require("puppeteer");
692
+ (async () => {{
693
+ const browser = await puppeteer.launch({{ headless: "new" }});
694
+ const page = await browser.newPage();
695
+ await page.setViewport({{ width: 375, height: 812, isMobile: true }});
696
+ try {{
697
+ await page.goto("{self.url}", {{ waitUntil: "networkidle0", timeout: 60000 }});
698
+ await new Promise(r => setTimeout(r, 5000));
699
+ const hasOverflow = await page.evaluate(() =>
700
+ document.documentElement.scrollWidth > document.documentElement.clientWidth
701
+ );
702
+ await page.screenshot({{ path: "{screenshot_path}", fullPage: true }});
703
+ console.log(JSON.stringify({{ success: !hasOverflow, hasOverflow }}));
704
+ }} catch (e) {{
705
+ console.log(JSON.stringify({{ success: false, error: e.message }}));
706
+ }}
707
+ await browser.close();
708
+ }})();
709
+ '''
710
+ result = self._run_puppeteer_test(script)
711
+ if result.get('success'):
712
+ return True, '手機版無水平溢出', screenshot_path
713
+ if result.get('hasOverflow'):
714
+ return False, '偵測到水平溢出', screenshot_path
715
+ return False, result.get('error', '測試失敗'), screenshot_path
716
+
717
+ def _test_version_display(self) -> Tuple[bool, str, str]:
718
+ screenshot_path = str(self.screenshot_dir / 'smoke-04-version.png')
719
+ if self.version and self.version != 'unknown':
720
+ script = f'''
721
+ const puppeteer = require("puppeteer");
722
+ (async () => {{
723
+ const browser = await puppeteer.launch({{ headless: "new" }});
724
+ const page = await browser.newPage();
725
+ await page.setViewport({{ width: 1920, height: 1080 }});
726
+ try {{
727
+ await page.goto("{self.url}", {{ waitUntil: "networkidle0", timeout: 60000 }});
728
+ await new Promise(r => setTimeout(r, 3000));
729
+ await page.screenshot({{ path: "{screenshot_path}" }});
730
+ console.log(JSON.stringify({{ success: true }}));
731
+ }} catch (e) {{
732
+ console.log(JSON.stringify({{ success: false, error: e.message }}));
733
+ }}
734
+ await browser.close();
735
+ }})();
736
+ '''
737
+ self._run_puppeteer_test(script)
738
+ return True, f'版本號: {self.version}', screenshot_path
739
+ return False, 'Code.js 缺少 getVersion API', screenshot_path
740
+
741
+ def _test_all_module_screenshots(self) -> Dict[str, str]:
742
+ """對所有模組頁籤進行截圖"""
743
+ tabs_to_capture = [
744
+ ('dashboard', '戰情中心'),
745
+ ('work-orders', '工單管理'),
746
+ ('dispatches', '現場派工'),
747
+ ('reports', '報工紀錄'),
748
+ ('outgassing', '釋氣檢驗'),
749
+ ('aoi', 'AOI檢驗'),
750
+ ('r0', '標籤管理'),
751
+ ('label-editor', '樣式設計'),
752
+ ('schedule', '排程管理'),
753
+ ('oven', '烘箱監控'),
754
+ ('wms', '倉儲管理'),
755
+ ('parts', '物料主檔'),
756
+ ('audit', '操作紀錄'),
757
+ ('settings', '設定'),
758
+ ]
759
+
760
+ screenshots = {}
761
+ screenshot_list = ','.join([f'"{t[0]}"' for t in tabs_to_capture])
762
+ screenshot_paths = {t[0]: str(self.screenshot_dir / f'tab-{t[0]}.png') for t in tabs_to_capture}
763
+ paths_json = ','.join([f'"{t[0]}": "{screenshot_paths[t[0]]}"' for t in tabs_to_capture])
764
+
765
+ script = f'''
766
+ const puppeteer = require("puppeteer");
767
+ (async () => {{
768
+ const browser = await puppeteer.launch({{ headless: "new" }});
769
+ const page = await browser.newPage();
770
+ await page.setViewport({{ width: 1920, height: 1080 }});
771
+ const tabs = [{screenshot_list}];
772
+ const paths = {{{paths_json}}};
773
+ const results = {{}};
774
+
775
+ try {{
776
+ await page.goto("{self.url}", {{ waitUntil: "networkidle0", timeout: 60000 }});
777
+ await new Promise(r => setTimeout(r, 12000));
778
+
779
+ // GAS Web App 結構: frame[0]=main, frame[1]=sandbox, frame[2]=userHtmlFrame (Vue app)
780
+ const frames = page.frames();
781
+ const appFrame = frames[2]; // Vue app is in the innermost frame
782
+
783
+ if (!appFrame) {{
784
+ results.error = "Cannot find app frame";
785
+ console.log(JSON.stringify(results));
786
+ await browser.close();
787
+ return;
788
+ }}
789
+
790
+ // 驗證 Vue app 存在
791
+ const hasVue = await appFrame.evaluate(() => {{
792
+ const appEl = document.getElementById("app");
793
+ return !!(appEl && appEl.__vue_app__);
794
+ }});
795
+
796
+ if (!hasVue) {{
797
+ results.error = "Vue app not found";
798
+ console.log(JSON.stringify(results));
799
+ await browser.close();
800
+ return;
801
+ }}
802
+
803
+ results.foundVue = true;
804
+
805
+ for (const tab of tabs) {{
806
+ try {{
807
+ // 使用 Vue 3 正確的 proxy 存取方式切換頁籤
808
+ const switched = await appFrame.evaluate((t) => {{
809
+ const appEl = document.getElementById("app");
810
+ if (appEl && appEl.__vue_app__) {{
811
+ const comp = appEl.__vue_app__._container?._vnode?.component;
812
+ if (comp && comp.proxy) {{
813
+ comp.proxy.currentTab = t;
814
+ return comp.proxy.currentTab === t;
815
+ }}
816
+ }}
817
+ return false;
818
+ }}, tab);
819
+
820
+ if (!switched) {{
821
+ results[tab] = "switch failed";
822
+ continue;
823
+ }}
824
+
825
+ // 等待 Vue 更新 DOM
826
+ await new Promise(r => setTimeout(r, 2000));
827
+
828
+ // 截圖
829
+ await page.screenshot({{ path: paths[tab], fullPage: false }});
830
+ results[tab] = "ok";
831
+ }} catch (e) {{
832
+ results[tab] = e.message;
833
+ }}
834
+ }}
835
+ }} catch (e) {{
836
+ results.error = e.message;
837
+ }}
838
+
839
+ await browser.close();
840
+ console.log(JSON.stringify(results));
841
+ }})();
842
+ '''
843
+ result = self._run_puppeteer_test(script)
844
+
845
+ for tab_id, tab_name in tabs_to_capture:
846
+ if result.get(tab_id) == 'ok' and Path(screenshot_paths[tab_id]).exists():
847
+ screenshots[tab_id] = screenshot_paths[tab_id]
848
+
849
+ return screenshots
850
+
851
+ # ========== E2E: 各模組 CRUD 功能測試 ==========
852
+
853
+ def run_e2e(self) -> TestTypeResult:
854
+ """執行 E2E 端對端測試 - 各模組 CRUD 驗證"""
855
+ result = TestTypeResult(test_type='E2E')
856
+ start_time = datetime.now()
857
+
858
+ # 測試每個模組的 CRUD API 是否存在於 Code.js
859
+ for module_id, module_info in MODULE_APIS.items():
860
+ module_name = module_info['name']
861
+ crud_ops = module_info.get('crud', [])
862
+
863
+ if not crud_ops:
864
+ continue # 跳過無 CRUD 的模組
865
+
866
+ for op in crud_ops:
867
+ test_id = f'E2E-{module_id.upper()[:3]}-{op.upper()[:1]}'
868
+ test_name = f'{module_name} - {op.upper()}'
869
+
870
+ tc = TestCase(id=test_id, name=test_name, module=module_name)
871
+
872
+ # 根據操作類型找對應 API
873
+ api_patterns = {
874
+ 'create': [f'create{module_id.title().replace("-", "")}', f'create'],
875
+ 'read': [f'get{module_id.title().replace("-", "")}', f'get'],
876
+ 'update': [f'update{module_id.title().replace("-", "")}', f'update'],
877
+ 'delete': [f'delete{module_id.title().replace("-", "")}', f'delete'],
878
+ }
879
+
880
+ # 檢查是否有對應 API
881
+ found_api = None
882
+ for api in module_info.get('apis', []):
883
+ api_lower = api.lower()
884
+ if op == 'create' and api_lower.startswith('create'):
885
+ found_api = api
886
+ break
887
+ elif op == 'read' and api_lower.startswith('get'):
888
+ found_api = api
889
+ break
890
+ elif op == 'update' and api_lower.startswith('update'):
891
+ found_api = api
892
+ break
893
+ elif op == 'delete' and api_lower.startswith('delete'):
894
+ found_api = api
895
+ break
896
+
897
+ if found_api and found_api in self.discovered_apis:
898
+ tc.status = 'passed'
899
+ tc.details = f'API: {found_api}'
900
+ elif found_api:
901
+ tc.status = 'failed'
902
+ tc.error = f'API {found_api} 未實作'
903
+ else:
904
+ tc.status = 'skipped'
905
+ tc.details = f'無 {op} 操作'
906
+ result.skipped += 1
907
+ result.test_cases.append(tc)
908
+ continue
909
+
910
+ result.test_cases.append(tc)
911
+ if tc.status == 'passed':
912
+ result.passed += 1
913
+ else:
914
+ result.failed += 1
915
+
916
+ # 測試特殊功能 API
917
+ special_features = [
918
+ ('E2E-WMS-IN', '倉儲管理', 'WMS 入庫', 'wmsInbound'),
919
+ ('E2E-WMS-OUT', '倉儲管理', 'WMS 出庫', 'wmsOutbound'),
920
+ ('E2E-WMS-TRF', '倉儲管理', 'WMS 移轉', 'wmsTransfer'),
921
+ ('E2E-DIS-START', '現場派工', '開始派工', 'startDispatch'),
922
+ ('E2E-DIS-COMP', '現場派工', '完成派工', 'completeDispatch'),
923
+ ('E2E-AOI-CSV', 'AOI 檢驗', 'CSV 匯入', 'importAoiCsv'),
924
+ ('E2E-SCH-IMP', '排程管理', '班表匯入', 'importShifts'),
925
+ ('E2E-R0-SYNC', '標籤管理', 'EPC 同步', 'syncR0Labels'),
926
+ ('E2E-R0-CHK', '標籤管理', 'EPC 檢查', 'checkEpc'),
927
+ ('E2E-SYS-SYNC', '系統', '資料庫同步', 'syncDatabase'),
928
+ ]
929
+
930
+ for test_id, module, test_name, api_name in special_features:
931
+ tc = TestCase(id=test_id, name=test_name, module=module)
932
+ if api_name in self.discovered_apis:
933
+ tc.status = 'passed'
934
+ tc.details = f'API: {api_name}'
935
+ result.passed += 1
936
+ else:
937
+ tc.status = 'failed'
938
+ tc.error = f'API {api_name} 不存在'
939
+ result.failed += 1
940
+ result.test_cases.append(tc)
941
+
942
+ result.duration = (datetime.now() - start_time).total_seconds()
943
+ result.success = result.failed == 0
944
+ return result
945
+
946
+ # ========== UAT: 角色權限驗證 ==========
947
+
948
+ def run_uat(self) -> TestTypeResult:
949
+ """執行 UAT 使用者驗收測試 - 6 角色 × 14 模組"""
950
+ result = TestTypeResult(test_type='UAT')
951
+ start_time = datetime.now()
952
+
953
+ # 1. 角色權限定義測試
954
+ for role, permissions in ROLE_PERMISSIONS.items():
955
+ role_name = ROLE_NAMES.get(role, role)
956
+ test_id = f'UAT-ROLE-{role.upper()[:3]}'
957
+ tc = TestCase(id=test_id, name=f'角色權限: {role_name}', module='權限')
958
+
959
+ # 檢查 app.html 中是否有該角色定義
960
+ if f'{role}:' in self.app_content:
961
+ tc.status = 'passed'
962
+ tc.details = f'{len(permissions)} 個頁籤權限'
963
+ result.passed += 1
964
+ else:
965
+ tc.status = 'failed'
966
+ tc.error = f'角色 {role} 未定義'
967
+ result.failed += 1
968
+
969
+ result.test_cases.append(tc)
970
+
971
+ # 2. 各模組 UI 元素測試
972
+ ui_elements = {
973
+ 'work-orders': ['工單', 'orderNumber', 'productModel'],
974
+ 'dispatches': ['派工', 'stationName', 'operatorName'],
975
+ 'reports': ['報工', 'goodQty', 'ngQty'],
976
+ 'outgassing': ['釋氣', 'signature', 'result'],
977
+ 'aoi': ['AOI', 'formAoi', 'aoiCsv'],
978
+ 'r0': ['標籤', 'rfidCode', 'epc'],
979
+ 'schedule': ['排程', 'schedulePlan', 'shift'],
980
+ 'wms': ['倉儲', 'locationCode', 'inventory'],
981
+ 'parts': ['物料', 'partNumber', 'partName'],
982
+ 'audit': ['稽核', 'auditLog', 'action'],
983
+ 'settings': ['設定', 'operator', 'customer'],
984
+ }
985
+
986
+ for module_id, keywords in ui_elements.items():
987
+ module_name = MODULE_APIS.get(module_id, {}).get('name', module_id)
988
+ test_id = f'UAT-UI-{module_id.upper()[:3]}'
989
+ tc = TestCase(id=test_id, name=f'{module_name} UI 元素', module=module_name)
990
+
991
+ tab_file = self.project_path / f'tab-{module_id}.html'
992
+ if tab_file.exists():
993
+ content = tab_file.read_text(encoding='utf-8')
994
+ found = [kw for kw in keywords if kw.lower() in content.lower()]
995
+ if len(found) >= 2:
996
+ tc.status = 'passed'
997
+ tc.details = f'包含關鍵字: {", ".join(found[:3])}'
998
+ result.passed += 1
999
+ else:
1000
+ tc.status = 'failed'
1001
+ tc.error = f'缺少關鍵 UI 元素'
1002
+ result.failed += 1
1003
+ else:
1004
+ tc.status = 'skipped'
1005
+ tc.details = f'tab-{module_id}.html 不存在'
1006
+ result.skipped += 1
1007
+
1008
+ result.test_cases.append(tc)
1009
+
1010
+ # 3. ISO 27001 合規測試
1011
+ iso_tests = [
1012
+ ('UAT-ISO-01', 'ISO 27001', '操作紀錄 (A.8.15)', 'getAuditLogs' in self.discovered_apis),
1013
+ ('UAT-ISO-02', 'ISO 27001', '存取控制 (A.9.2)', len(ROLE_PERMISSIONS) >= 6),
1014
+ ('UAT-ISO-03', 'ISO 27001', '身份驗證 (A.9.4)', 'verifyPin' in self.app_content),
1015
+ ('UAT-ISO-04', 'ISO 27001', '資料完整性 (A.14.1)', 'syncDatabase' in self.discovered_apis),
1016
+ ]
1017
+
1018
+ for test_id, module, test_name, condition in iso_tests:
1019
+ tc = TestCase(id=test_id, name=test_name, module=module)
1020
+ if condition:
1021
+ tc.status = 'passed'
1022
+ tc.details = '符合要求'
1023
+ result.passed += 1
1024
+ else:
1025
+ tc.status = 'failed'
1026
+ tc.error = '不符合要求'
1027
+ result.failed += 1
1028
+ result.test_cases.append(tc)
1029
+
1030
+ result.duration = (datetime.now() - start_time).total_seconds()
1031
+ result.success = result.failed == 0
1032
+ return result
1033
+
1034
+ # ========== 執行所有測試 ==========
1035
+
1036
+ def run_all(self, test_types: List[str] = None) -> GASMESTestResult:
1037
+ """執行所有測試類型"""
1038
+ if test_types is None:
1039
+ test_types = ['UIT', 'Smoke', 'E2E', 'UAT']
1040
+
1041
+ result = GASMESTestResult(
1042
+ project_name='GAS MES',
1043
+ timestamp=datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
1044
+ version=self.version,
1045
+ api_count=len(self.discovered_apis),
1046
+ module_count=len(MODULE_APIS)
1047
+ )
1048
+
1049
+ for test_type in test_types:
1050
+ test_type_upper = test_type.upper()
1051
+
1052
+ console.print(f" [dim]執行 {test_type_upper} 測試...[/dim]")
1053
+
1054
+ if test_type_upper == 'UIT':
1055
+ type_result = self.run_uit()
1056
+ elif test_type_upper == 'SMOKE':
1057
+ type_result = self.run_smoke()
1058
+ elif test_type_upper == 'E2E':
1059
+ type_result = self.run_e2e()
1060
+ elif test_type_upper == 'UAT':
1061
+ type_result = self.run_uat()
1062
+ else:
1063
+ continue
1064
+
1065
+ result.results[test_type_upper] = type_result
1066
+ result.total_passed += type_result.passed
1067
+ result.total_failed += type_result.failed
1068
+ result.total_duration += type_result.duration
1069
+
1070
+ if not type_result.success:
1071
+ result.overall_success = False
1072
+
1073
+ return result
1074
+
1075
+
1076
+ def render_gas_test_result(result: GASMESTestResult):
1077
+ """渲染 GAS MES 測試結果"""
1078
+ status_icon = "v" if result.overall_success else "x"
1079
+ status_color = "green" if result.overall_success else "red"
1080
+
1081
+ console.print(Panel(
1082
+ f"[bold]{result.project_name}[/bold] v{result.version} 測試報告\n"
1083
+ f"[dim]{result.timestamp} | {result.api_count} APIs | {result.module_count} 模組[/dim]",
1084
+ border_style="cyan"
1085
+ ))
1086
+
1087
+ # 結果表格
1088
+ table = Table(show_header=True, header_style="bold cyan")
1089
+ table.add_column("測試類型", style="white")
1090
+ table.add_column("狀態", justify="center")
1091
+ table.add_column("通過", justify="right", style="green")
1092
+ table.add_column("失敗", justify="right", style="red")
1093
+ table.add_column("跳過", justify="right", style="yellow")
1094
+ table.add_column("時間", justify="right", style="dim")
1095
+
1096
+ type_labels = {
1097
+ 'UIT': '靜態分析 (UIT)',
1098
+ 'SMOKE': '煙霧測試 (Smoke)',
1099
+ 'E2E': '功能測試 (E2E)',
1100
+ 'UAT': '驗收測試 (UAT)'
1101
+ }
1102
+
1103
+ for test_type, type_result in result.results.items():
1104
+ status = "[green]PASS[/green]" if type_result.success else "[red]FAIL[/red]"
1105
+ table.add_row(
1106
+ type_labels.get(test_type, test_type),
1107
+ status,
1108
+ str(type_result.passed),
1109
+ str(type_result.failed) if type_result.failed else "-",
1110
+ str(type_result.skipped) if type_result.skipped else "-",
1111
+ f"{type_result.duration:.1f}s"
1112
+ )
1113
+
1114
+ console.print(table)
1115
+
1116
+ # 總結
1117
+ total = result.total_passed + result.total_failed
1118
+ if total > 0:
1119
+ pass_rate = (result.total_passed / total) * 100
1120
+ console.print(f"\n 總計: [green]{result.total_passed} passed[/green] / "
1121
+ f"[{'red' if result.total_failed else 'dim'}]{result.total_failed} failed[/]"
1122
+ f" | 通過率: {pass_rate:.1f}% | {result.total_duration:.1f}s")
1123
+
1124
+ if result.overall_success:
1125
+ console.print(f"\n [{status_color}][{status_icon}] 所有測試通過[/{status_color}]\n")
1126
+ else:
1127
+ console.print(f"\n [{status_color}][{status_icon}] 部分測試失敗[/{status_color}]\n")
1128
+ # 顯示失敗詳情 (最多 10 個)
1129
+ failed_count = 0
1130
+ for test_type, type_result in result.results.items():
1131
+ if not type_result.success:
1132
+ for tc in type_result.test_cases:
1133
+ if tc.status == 'failed' and failed_count < 10:
1134
+ console.print(f" [red]x[/red] {tc.id}: {tc.name}")
1135
+ console.print(f" [dim]{tc.error[:80]}[/dim]")
1136
+ failed_count += 1
1137
+
1138
+ return {
1139
+ 'success': result.overall_success,
1140
+ 'total_passed': result.total_passed,
1141
+ 'total_failed': result.total_failed,
1142
+ 'version': result.version,
1143
+ 'api_count': result.api_count
1144
+ }
1145
+
1146
+
1147
+ def run_gas_test(project_path: str, test_types: List[str] = None, url: str = None) -> Dict:
1148
+ """執行 GAS MES 測試"""
1149
+ runner = GASMESTestRunner(project_path, url)
1150
+
1151
+ console.print(f"[cyan]GAS MES 完整測試套件[/cyan]")
1152
+ console.print(f"[dim] 專案: {project_path}[/dim]")
1153
+ console.print(f"[dim] 發現 {len(runner.discovered_apis)} 個 API[/dim]")
1154
+ console.print()
1155
+
1156
+ result = runner.run_all(test_types)
1157
+ return render_gas_test_result(result)
1158
+
1159
+
1160
+ def run_gas_test_with_report(
1161
+ project_path: str,
1162
+ output_path: str = None,
1163
+ test_types: List[str] = None,
1164
+ url: str = None
1165
+ ) -> Dict:
1166
+ """執行 GAS MES 測試並產生 Word 報告"""
1167
+ from .word_report import generate_word_report
1168
+
1169
+ runner = GASMESTestRunner(project_path, url)
1170
+
1171
+ console.print(f"[cyan]GAS MES 完整測試套件[/cyan]")
1172
+ console.print(f"[dim] 專案: {project_path}[/dim]")
1173
+ console.print(f"[dim] 發現 {len(runner.discovered_apis)} 個 API[/dim]")
1174
+ console.print()
1175
+
1176
+ result = runner.run_all(test_types)
1177
+ render_gas_test_result(result)
1178
+
1179
+ # 準備報告資料
1180
+ if not output_path:
1181
+ output_path = str(Path(project_path) / 'GAS_MES-test-report.docx')
1182
+
1183
+ test_results = {
1184
+ 'project': f'{result.project_name} v{result.version}',
1185
+ 'timestamp': result.timestamp,
1186
+ 'overall_success': result.overall_success,
1187
+ 'summary': {
1188
+ 'total_passed': result.total_passed,
1189
+ 'total_failed': result.total_failed,
1190
+ 'total_duration': result.total_duration,
1191
+ 'coverage': 0
1192
+ },
1193
+ 'tests': {
1194
+ k: {
1195
+ 'success': v.success,
1196
+ 'passed': v.passed,
1197
+ 'failed': v.failed,
1198
+ 'duration': v.duration,
1199
+ 'coverage': 0,
1200
+ 'not_configured': v.not_configured,
1201
+ 'test_cases': [
1202
+ {
1203
+ 'name': f"[{tc.module}] {tc.name}" if tc.module else tc.name,
1204
+ 'status': tc.status,
1205
+ 'duration': tc.duration,
1206
+ 'error': tc.error,
1207
+ 'screenshot': tc.screenshot,
1208
+ 'api_response': '',
1209
+ 'terminal_output': tc.details
1210
+ }
1211
+ for tc in v.test_cases
1212
+ ]
1213
+ }
1214
+ for k, v in result.results.items()
1215
+ }
1216
+ }
1217
+
1218
+ # 收集截圖
1219
+ screenshots = []
1220
+ for type_result in result.results.values():
1221
+ for tc in type_result.test_cases:
1222
+ if tc.screenshot and Path(tc.screenshot).exists():
1223
+ screenshots.append(tc.screenshot)
1224
+
1225
+ console.print(f"\n[cyan]產生 Word 報告... (收集到 {len(screenshots)} 張截圖)[/cyan]")
1226
+ report_path = generate_word_report(
1227
+ project_name=f'{result.project_name} v{result.version}',
1228
+ test_results=test_results,
1229
+ output_path=output_path,
1230
+ screenshots=screenshots, # 包含所有模組截圖
1231
+ include_charts=True
1232
+ )
1233
+
1234
+ console.print(f"[green]報告已產生: {report_path}[/green]")
1235
+
1236
+ return {
1237
+ 'success': result.overall_success,
1238
+ 'report_path': report_path,
1239
+ 'total_passed': result.total_passed,
1240
+ 'total_failed': result.total_failed
1241
+ }