feat: complete dashboard-vite parity and fix portal health/csp regressions
This commit is contained in:
@@ -5,19 +5,18 @@ These tests simulate real user workflows through the admin authentication system
|
||||
Run with: pytest tests/e2e/test_admin_auth_e2e.py -v --run-integration
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'src'))
|
||||
|
||||
import mes_dashboard.core.database as db
|
||||
from mes_dashboard.app import create_app
|
||||
from mes_dashboard.services import page_registry
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'src'))
|
||||
|
||||
import mes_dashboard.core.database as db
|
||||
from mes_dashboard.app import create_app
|
||||
from mes_dashboard.services import page_registry
|
||||
from mes_dashboard.routes import auth_routes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -39,54 +38,57 @@ def temp_page_status(tmp_path):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(temp_page_status):
|
||||
"""Create application for testing."""
|
||||
db._ENGINE = None
|
||||
|
||||
def app(temp_page_status):
|
||||
"""Create application for testing."""
|
||||
db._ENGINE = None
|
||||
|
||||
# Mock page registry
|
||||
original_data_file = page_registry.DATA_FILE
|
||||
original_cache = page_registry._cache
|
||||
page_registry.DATA_FILE = temp_page_status
|
||||
page_registry._cache = None
|
||||
|
||||
app = create_app('testing')
|
||||
app.config['TESTING'] = True
|
||||
app.config['WTF_CSRF_ENABLED'] = False
|
||||
|
||||
yield app
|
||||
|
||||
page_registry.DATA_FILE = original_data_file
|
||||
page_registry._cache = original_cache
|
||||
app = create_app('testing')
|
||||
app.config['TESTING'] = True
|
||||
app.config['CSRF_ENABLED'] = False
|
||||
|
||||
yield app
|
||||
|
||||
page_registry.DATA_FILE = original_data_file
|
||||
page_registry._cache = original_cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client."""
|
||||
return app.test_client()
|
||||
def client(app):
|
||||
"""Create test client."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_login_rate_limit():
|
||||
"""Reset in-memory login attempts to avoid cross-test interference."""
|
||||
auth_routes._login_attempts.clear()
|
||||
yield
|
||||
auth_routes._login_attempts.clear()
|
||||
|
||||
|
||||
def _mock_admin_user(mail: str = "ymirliu@panjit.com.tw") -> dict:
|
||||
return {
|
||||
"username": "92367",
|
||||
"displayName": "Test Admin",
|
||||
"mail": mail,
|
||||
"department": "Test Department",
|
||||
}
|
||||
|
||||
|
||||
def mock_ldap_success(mail="ymirliu@panjit.com.tw"):
|
||||
"""Helper to create mock for successful LDAP auth."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"success": True,
|
||||
"user": {
|
||||
"username": "92367",
|
||||
"displayName": "Test Admin",
|
||||
"mail": mail,
|
||||
"department": "Test Department"
|
||||
}
|
||||
}
|
||||
return mock_response
|
||||
|
||||
|
||||
class TestFullLoginLogoutFlow:
|
||||
"""E2E tests for complete login/logout flow."""
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_complete_admin_login_workflow(self, mock_post, client):
|
||||
"""Test complete admin login workflow."""
|
||||
mock_post.return_value = mock_ldap_success()
|
||||
class TestFullLoginLogoutFlow:
|
||||
"""E2E tests for complete login/logout flow."""
|
||||
|
||||
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
|
||||
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
||||
def test_complete_admin_login_workflow(self, mock_auth, _mock_is_admin, client):
|
||||
"""Test complete admin login workflow."""
|
||||
mock_auth.return_value = _mock_admin_user()
|
||||
|
||||
# 1. Access portal - should see login link
|
||||
response = client.get("/")
|
||||
@@ -131,7 +133,7 @@ class TestFullLoginLogoutFlow:
|
||||
assert response.status_code == 302
|
||||
|
||||
|
||||
class TestPageAccessControlFlow:
|
||||
class TestPageAccessControlFlow:
|
||||
"""E2E tests for page access control flow."""
|
||||
|
||||
def test_non_admin_cannot_access_dev_pages(self, client, temp_page_status):
|
||||
@@ -146,10 +148,11 @@ class TestPageAccessControlFlow:
|
||||
content = response.data.decode("utf-8")
|
||||
assert "開發中" in content or "403" in content
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_admin_can_access_all_pages(self, mock_post, client, temp_page_status):
|
||||
"""Test admin users can access all pages."""
|
||||
mock_post.return_value = mock_ldap_success()
|
||||
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
|
||||
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
||||
def test_admin_can_access_all_pages(self, mock_auth, _mock_is_admin, client, temp_page_status):
|
||||
"""Test admin users can access all pages."""
|
||||
mock_auth.return_value = _mock_admin_user()
|
||||
|
||||
# 1. Login as admin
|
||||
client.post("/admin/login", data={
|
||||
@@ -166,13 +169,14 @@ class TestPageAccessControlFlow:
|
||||
assert response.status_code != 403
|
||||
|
||||
|
||||
class TestPageManagementFlow:
|
||||
class TestPageManagementFlow:
|
||||
"""E2E tests for page management flow."""
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_admin_can_change_page_status(self, mock_post, client, temp_page_status):
|
||||
"""Test admin can change page status via management interface."""
|
||||
mock_post.return_value = mock_ldap_success()
|
||||
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
|
||||
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
||||
def test_admin_can_change_page_status(self, mock_auth, _mock_is_admin, client, temp_page_status):
|
||||
"""Test admin can change page status via management interface."""
|
||||
mock_auth.return_value = _mock_admin_user()
|
||||
|
||||
# 1. Login as admin
|
||||
client.post("/admin/login", data={
|
||||
@@ -206,10 +210,11 @@ class TestPageManagementFlow:
|
||||
response = client.get("/wip-overview")
|
||||
assert response.status_code == 403
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_release_dev_page_makes_it_public(self, mock_post, client, temp_page_status):
|
||||
"""Test releasing a dev page makes it publicly accessible."""
|
||||
mock_post.return_value = mock_ldap_success()
|
||||
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
|
||||
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
||||
def test_release_dev_page_makes_it_public(self, mock_auth, _mock_is_admin, client, temp_page_status):
|
||||
"""Test releasing a dev page makes it publicly accessible."""
|
||||
mock_auth.return_value = _mock_admin_user()
|
||||
|
||||
# 1. Verify /tables is currently dev (403 for non-admin)
|
||||
response = client.get("/tables")
|
||||
@@ -238,7 +243,7 @@ class TestPageManagementFlow:
|
||||
assert response.status_code != 403
|
||||
|
||||
|
||||
class TestPortalDynamicTabs:
|
||||
class TestPortalDynamicTabs:
|
||||
"""E2E tests for dynamic portal tabs based on page status."""
|
||||
|
||||
def test_portal_hides_dev_tabs_for_non_admin(self, client, temp_page_status):
|
||||
@@ -253,10 +258,11 @@ class TestPortalDynamicTabs:
|
||||
# Dev pages should NOT show (tables and resource are dev)
|
||||
# Note: This depends on the can_view_page implementation in portal.html
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_portal_shows_all_tabs_for_admin(self, mock_post, client, temp_page_status):
|
||||
"""Test portal shows all tabs for admin users."""
|
||||
mock_post.return_value = mock_ldap_success()
|
||||
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
|
||||
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
||||
def test_portal_shows_all_tabs_for_admin(self, mock_auth, _mock_is_admin, client, temp_page_status):
|
||||
"""Test portal shows all tabs for admin users."""
|
||||
mock_auth.return_value = _mock_admin_user()
|
||||
|
||||
# Login as admin
|
||||
client.post("/admin/login", data={
|
||||
@@ -272,13 +278,14 @@ class TestPortalDynamicTabs:
|
||||
assert "WIP 即時概況" in content
|
||||
|
||||
|
||||
class TestSessionPersistence:
|
||||
class TestSessionPersistence:
|
||||
"""E2E tests for session persistence."""
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_session_persists_across_requests(self, mock_post, client):
|
||||
"""Test admin session persists across multiple requests."""
|
||||
mock_post.return_value = mock_ldap_success()
|
||||
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
|
||||
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
||||
def test_session_persists_across_requests(self, mock_auth, _mock_is_admin, client):
|
||||
"""Test admin session persists across multiple requests."""
|
||||
mock_auth.return_value = _mock_admin_user()
|
||||
|
||||
# Login
|
||||
client.post("/admin/login", data={
|
||||
@@ -296,39 +303,34 @@ class TestSessionPersistence:
|
||||
assert "admin" in sess
|
||||
|
||||
|
||||
class TestSecurityScenarios:
|
||||
class TestSecurityScenarios:
|
||||
"""E2E tests for security scenarios."""
|
||||
|
||||
def test_cannot_access_admin_api_without_login(self, client):
|
||||
"""Test admin APIs are protected."""
|
||||
# Try to get pages without login
|
||||
response = client.get("/admin/api/pages", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
|
||||
# Try to update page without login
|
||||
response = client.put(
|
||||
"/admin/api/pages/wip-overview",
|
||||
data=json.dumps({"status": "dev"}),
|
||||
content_type="application/json",
|
||||
follow_redirects=False
|
||||
)
|
||||
assert response.status_code == 302
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_non_admin_user_cannot_login(self, mock_post, client):
|
||||
"""Test non-admin user cannot access admin features."""
|
||||
# Mock LDAP success but with non-admin email
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"success": True,
|
||||
"user": {
|
||||
"username": "99999",
|
||||
"displayName": "Regular User",
|
||||
"mail": "regular@panjit.com.tw",
|
||||
"department": "Test"
|
||||
}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
def test_cannot_access_admin_api_without_login(self, client):
|
||||
"""Test admin APIs are protected."""
|
||||
# Try to get pages without login
|
||||
response = client.get("/admin/api/pages", follow_redirects=False)
|
||||
assert response.status_code in (302, 401)
|
||||
|
||||
# Try to update page without login
|
||||
response = client.put(
|
||||
"/admin/api/pages/wip-overview",
|
||||
data=json.dumps({"status": "dev"}),
|
||||
content_type="application/json",
|
||||
follow_redirects=False
|
||||
)
|
||||
assert response.status_code in (302, 401)
|
||||
|
||||
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=False)
|
||||
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
||||
def test_non_admin_user_cannot_login(self, mock_auth, _mock_is_admin, client):
|
||||
"""Test non-admin user cannot access admin features."""
|
||||
mock_auth.return_value = {
|
||||
"username": "99999",
|
||||
"displayName": "Regular User",
|
||||
"mail": "regular@panjit.com.tw",
|
||||
"department": "Test",
|
||||
}
|
||||
|
||||
# Try to login
|
||||
response = client.post("/admin/login", data={
|
||||
|
||||
@@ -196,11 +196,11 @@ class TestSearchEndpointsE2E:
|
||||
def test_search_workorders(self, api_base_url):
|
||||
"""Test workorder search returns results."""
|
||||
# Use a common pattern that should exist
|
||||
response = requests.get(
|
||||
f"{api_base_url}/wip/meta/search",
|
||||
params={'type': 'workorder', 'q': 'WO', 'limit': 10},
|
||||
timeout=30
|
||||
)
|
||||
response = requests.get(
|
||||
f"{api_base_url}/wip/meta/search",
|
||||
params={'field': 'workorder', 'q': 'WO', 'limit': 10},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = self._unwrap(response.json())
|
||||
@@ -208,11 +208,11 @@ class TestSearchEndpointsE2E:
|
||||
|
||||
def test_search_lotids(self, api_base_url):
|
||||
"""Test lot ID search returns results."""
|
||||
response = requests.get(
|
||||
f"{api_base_url}/wip/meta/search",
|
||||
params={'type': 'lotid', 'q': 'LOT', 'limit': 10},
|
||||
timeout=30
|
||||
)
|
||||
response = requests.get(
|
||||
f"{api_base_url}/wip/meta/search",
|
||||
params={'field': 'lotid', 'q': 'LOT', 'limit': 10},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = self._unwrap(response.json())
|
||||
@@ -220,11 +220,11 @@ class TestSearchEndpointsE2E:
|
||||
|
||||
def test_search_with_short_query_returns_empty(self, api_base_url):
|
||||
"""Test search with short query returns empty list."""
|
||||
response = requests.get(
|
||||
f"{api_base_url}/wip/meta/search",
|
||||
params={'type': 'workorder', 'q': 'W'}, # Too short
|
||||
timeout=30
|
||||
)
|
||||
response = requests.get(
|
||||
f"{api_base_url}/wip/meta/search",
|
||||
params={'field': 'workorder', 'q': 'W'}, # Too short
|
||||
timeout=30
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = self._unwrap(response.json())
|
||||
|
||||
@@ -23,25 +23,36 @@ class TestPortalPage:
|
||||
# Wait for page to load
|
||||
expect(page.locator('h1')).to_contain_text('MES 報表入口')
|
||||
|
||||
def test_portal_has_all_tabs(self, page: Page, app_server: str):
|
||||
"""Portal should have all navigation tabs."""
|
||||
page.goto(app_server)
|
||||
def test_portal_has_all_tabs(self, page: Page, app_server: str):
|
||||
"""Portal should have all navigation tabs."""
|
||||
page.goto(app_server)
|
||||
|
||||
# Check released tabs exist
|
||||
expect(page.locator('.tab:has-text("WIP 即時概況")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("設備即時概況")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("設備歷史績效")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("設備維修查詢")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("批次追蹤工具")')).to_be_visible()
|
||||
|
||||
# Check all tabs exist
|
||||
expect(page.locator('.tab:has-text("WIP 即時概況")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("機台狀態報表")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("數據表查詢工具")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("Excel 批次查詢")')).to_be_visible()
|
||||
|
||||
def test_portal_tab_switching(self, page: Page, app_server: str):
|
||||
"""Portal tabs should switch iframe content."""
|
||||
page.goto(app_server)
|
||||
|
||||
# Click on a different tab
|
||||
page.locator('.tab:has-text("機台狀態報表")').click()
|
||||
|
||||
# Verify the tab is active
|
||||
expect(page.locator('.tab:has-text("機台狀態報表")')).to_have_class(re.compile(r'active'))
|
||||
def test_portal_tab_switching(self, page: Page, app_server: str):
|
||||
"""Portal tabs should switch iframe content."""
|
||||
page.goto(app_server)
|
||||
|
||||
# Click on a different tab
|
||||
page.locator('.tab:has-text("設備即時概況")').click()
|
||||
|
||||
# Verify the tab is active
|
||||
expect(page.locator('.tab:has-text("設備即時概況")')).to_have_class(re.compile(r'active'))
|
||||
|
||||
def test_portal_health_popup_clickable(self, page: Page, app_server: str):
|
||||
"""Health status pill should toggle popup visibility on click."""
|
||||
page.goto(app_server)
|
||||
|
||||
popup = page.locator('#healthPopup')
|
||||
expect(popup).not_to_have_class(re.compile(r'show'))
|
||||
|
||||
page.locator('#healthStatus').click()
|
||||
expect(popup).to_have_class(re.compile(r'show'))
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@@ -240,11 +251,16 @@ class TestWIPDetailPage:
|
||||
class TestTablesPage:
|
||||
"""E2E tests for Tables page."""
|
||||
|
||||
def test_tables_page_loads(self, page: Page, app_server: str):
|
||||
"""Tables page should load."""
|
||||
page.goto(f"{app_server}/tables")
|
||||
|
||||
expect(page.locator('h1')).to_contain_text('MES 數據表查詢工具')
|
||||
def test_tables_page_loads(self, page: Page, app_server: str):
|
||||
"""Tables page should load."""
|
||||
page.goto(f"{app_server}/tables")
|
||||
header = page.locator('h1')
|
||||
expect(header).to_be_visible()
|
||||
text = header.inner_text()
|
||||
assert (
|
||||
'MES 數據表查詢工具' in text
|
||||
or '頁面開發中' in text
|
||||
)
|
||||
|
||||
def test_tables_has_toast_system(self, page: Page, app_server: str):
|
||||
"""Tables page should have Toast system loaded."""
|
||||
|
||||
@@ -5,11 +5,11 @@ These tests simulate real user workflows through the resource history analysis f
|
||||
Run with: pytest tests/e2e/test_resource_history_e2e.py -v --run-integration
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
|
||||
import sys
|
||||
import os
|
||||
@@ -94,19 +94,20 @@ class TestResourceHistoryPageAccess:
|
||||
assert 'exportBtn' in content
|
||||
|
||||
|
||||
class TestResourceHistoryAPIWorkflow:
|
||||
"""E2E tests for API workflows."""
|
||||
|
||||
@patch('mes_dashboard.services.filter_cache.get_workcenter_groups')
|
||||
@patch('mes_dashboard.services.filter_cache.get_resource_families')
|
||||
def test_filter_options_workflow(self, mock_families, mock_groups, client):
|
||||
"""Filter options should be loadable."""
|
||||
mock_groups.return_value = [
|
||||
{'name': '焊接_DB', 'sequence': 1},
|
||||
{'name': '焊接_WB', 'sequence': 2},
|
||||
{'name': '成型', 'sequence': 4},
|
||||
]
|
||||
mock_families.return_value = ['FAM001', 'FAM002']
|
||||
class TestResourceHistoryAPIWorkflow:
|
||||
"""E2E tests for API workflows."""
|
||||
|
||||
@patch('mes_dashboard.services.resource_history_service.get_filter_options')
|
||||
def test_filter_options_workflow(self, mock_get_filter_options, client):
|
||||
"""Filter options should be loadable."""
|
||||
mock_get_filter_options.return_value = {
|
||||
'workcenter_groups': [
|
||||
{'name': '焊接_DB', 'sequence': 1},
|
||||
{'name': '焊接_WB', 'sequence': 2},
|
||||
{'name': '成型', 'sequence': 4},
|
||||
],
|
||||
'families': ['FAM001', 'FAM002'],
|
||||
}
|
||||
|
||||
response = client.get('/api/resource/history/options')
|
||||
|
||||
@@ -116,15 +117,31 @@ class TestResourceHistoryAPIWorkflow:
|
||||
assert 'workcenter_groups' in data['data']
|
||||
assert 'families' in data['data']
|
||||
|
||||
@patch('mes_dashboard.services.resource_history_service.read_sql_df')
|
||||
def test_complete_query_workflow(self, mock_read_sql, client):
|
||||
"""Complete query workflow should return all data sections."""
|
||||
# Mock responses for the 4 queries in query_summary
|
||||
kpi_df = pd.DataFrame([{
|
||||
'PRD_HOURS': 8000, 'SBY_HOURS': 1000, 'UDT_HOURS': 500,
|
||||
'SDT_HOURS': 300, 'EGT_HOURS': 200, 'NST_HOURS': 1000,
|
||||
'MACHINE_COUNT': 100
|
||||
}])
|
||||
@patch('mes_dashboard.services.resource_history_service._get_filtered_resources')
|
||||
@patch('mes_dashboard.services.resource_history_service.read_sql_df')
|
||||
def test_complete_query_workflow(self, mock_read_sql, mock_resources, client):
|
||||
"""Complete query workflow should return all data sections."""
|
||||
mock_resources.return_value = [
|
||||
{
|
||||
'RESOURCEID': 'RES001',
|
||||
'WORKCENTERNAME': '焊接_DB',
|
||||
'RESOURCEFAMILYNAME': 'FAM001',
|
||||
'RESOURCENAME': 'RES001',
|
||||
},
|
||||
{
|
||||
'RESOURCEID': 'RES002',
|
||||
'WORKCENTERNAME': '成型',
|
||||
'RESOURCEFAMILYNAME': 'FAM002',
|
||||
'RESOURCENAME': 'RES002',
|
||||
},
|
||||
]
|
||||
|
||||
# Mock responses for the 3 queries in query_summary
|
||||
kpi_df = pd.DataFrame([{
|
||||
'PRD_HOURS': 8000, 'SBY_HOURS': 1000, 'UDT_HOURS': 500,
|
||||
'SDT_HOURS': 300, 'EGT_HOURS': 200, 'NST_HOURS': 1000,
|
||||
'MACHINE_COUNT': 100
|
||||
}])
|
||||
|
||||
trend_df = pd.DataFrame([
|
||||
{'DATA_DATE': datetime(2024, 1, 1), 'PRD_HOURS': 1000, 'SBY_HOURS': 100,
|
||||
@@ -133,33 +150,24 @@ class TestResourceHistoryAPIWorkflow:
|
||||
'UDT_HOURS': 40, 'SDT_HOURS': 25, 'EGT_HOURS': 15, 'NST_HOURS': 100, 'MACHINE_COUNT': 100},
|
||||
])
|
||||
|
||||
heatmap_df = pd.DataFrame([
|
||||
{'WORKCENTERNAME': '焊接_DB', 'DATA_DATE': datetime(2024, 1, 1),
|
||||
'PRD_HOURS': 400, 'SBY_HOURS': 50, 'UDT_HOURS': 25, 'SDT_HOURS': 15, 'EGT_HOURS': 10},
|
||||
{'WORKCENTERNAME': '成型', 'DATA_DATE': datetime(2024, 1, 1),
|
||||
'PRD_HOURS': 600, 'SBY_HOURS': 50, 'UDT_HOURS': 25, 'SDT_HOURS': 15, 'EGT_HOURS': 10},
|
||||
])
|
||||
|
||||
comparison_df = pd.DataFrame([
|
||||
{'WORKCENTERNAME': '焊接_DB', 'PRD_HOURS': 4000, 'SBY_HOURS': 500,
|
||||
'UDT_HOURS': 250, 'SDT_HOURS': 150, 'EGT_HOURS': 100, 'MACHINE_COUNT': 50},
|
||||
{'WORKCENTERNAME': '成型', 'PRD_HOURS': 4000, 'SBY_HOURS': 500,
|
||||
'UDT_HOURS': 250, 'SDT_HOURS': 150, 'EGT_HOURS': 100, 'MACHINE_COUNT': 50},
|
||||
])
|
||||
|
||||
# Use function-based side_effect for ThreadPoolExecutor parallel queries
|
||||
def mock_sql(sql):
|
||||
sql_upper = sql.upper()
|
||||
if 'DATA_DATE' in sql_upper and 'WORKCENTERNAME' in sql_upper:
|
||||
return heatmap_df
|
||||
elif 'DATA_DATE' in sql_upper:
|
||||
return trend_df
|
||||
elif 'WORKCENTERNAME' in sql_upper:
|
||||
return comparison_df
|
||||
else:
|
||||
return kpi_df
|
||||
|
||||
mock_read_sql.side_effect = mock_sql
|
||||
heatmap_raw_df = pd.DataFrame([
|
||||
{'HISTORYID': 'RES001', 'DATA_DATE': datetime(2024, 1, 1),
|
||||
'PRD_HOURS': 400, 'SBY_HOURS': 50, 'UDT_HOURS': 25, 'SDT_HOURS': 15, 'EGT_HOURS': 10, 'NST_HOURS': 20},
|
||||
{'HISTORYID': 'RES002', 'DATA_DATE': datetime(2024, 1, 1),
|
||||
'PRD_HOURS': 600, 'SBY_HOURS': 50, 'UDT_HOURS': 25, 'SDT_HOURS': 15, 'EGT_HOURS': 10, 'NST_HOURS': 30},
|
||||
])
|
||||
|
||||
# Use function-based side_effect for ThreadPoolExecutor parallel queries
|
||||
def mock_sql(sql, _params=None):
|
||||
sql_upper = sql.upper()
|
||||
if 'HISTORYID' in sql_upper and 'DATA_DATE' in sql_upper:
|
||||
return heatmap_raw_df
|
||||
elif 'DATA_DATE' in sql_upper:
|
||||
return trend_df
|
||||
else:
|
||||
return kpi_df
|
||||
|
||||
mock_read_sql.side_effect = mock_sql
|
||||
|
||||
response = client.get(
|
||||
'/api/resource/history/summary'
|
||||
@@ -183,23 +191,39 @@ class TestResourceHistoryAPIWorkflow:
|
||||
# Trend should also have availability_pct
|
||||
assert 'availability_pct' in data['data']['trend'][0]
|
||||
|
||||
# Verify heatmap
|
||||
assert len(data['data']['heatmap']) == 2
|
||||
|
||||
# Verify comparison
|
||||
assert len(data['data']['workcenter_comparison']) == 2
|
||||
|
||||
@patch('mes_dashboard.services.resource_history_service.read_sql_df')
|
||||
def test_detail_query_workflow(self, mock_read_sql, client):
|
||||
"""Detail query workflow should return hierarchical data."""
|
||||
detail_df = pd.DataFrame([
|
||||
{'WORKCENTERNAME': '焊接_DB', 'RESOURCEFAMILYNAME': 'FAM001', 'RESOURCENAME': 'RES001',
|
||||
'PRD_HOURS': 80, 'SBY_HOURS': 10, 'UDT_HOURS': 5, 'SDT_HOURS': 3, 'EGT_HOURS': 2,
|
||||
'NST_HOURS': 10, 'TOTAL_HOURS': 110},
|
||||
{'WORKCENTERNAME': '焊接_DB', 'RESOURCEFAMILYNAME': 'FAM001', 'RESOURCENAME': 'RES002',
|
||||
'PRD_HOURS': 75, 'SBY_HOURS': 15, 'UDT_HOURS': 5, 'SDT_HOURS': 3, 'EGT_HOURS': 2,
|
||||
'NST_HOURS': 10, 'TOTAL_HOURS': 110},
|
||||
])
|
||||
# Verify heatmap
|
||||
assert len(data['data']['heatmap']) == 2
|
||||
|
||||
# Verify comparison
|
||||
assert len(data['data']['workcenter_comparison']) == 2
|
||||
|
||||
@patch('mes_dashboard.services.resource_history_service._get_filtered_resources')
|
||||
@patch('mes_dashboard.services.resource_history_service.read_sql_df')
|
||||
def test_detail_query_workflow(self, mock_read_sql, mock_resources, client):
|
||||
"""Detail query workflow should return hierarchical data."""
|
||||
mock_resources.return_value = [
|
||||
{
|
||||
'RESOURCEID': 'RES001',
|
||||
'WORKCENTERNAME': '焊接_DB',
|
||||
'RESOURCEFAMILYNAME': 'FAM001',
|
||||
'RESOURCENAME': 'RES001',
|
||||
},
|
||||
{
|
||||
'RESOURCEID': 'RES002',
|
||||
'WORKCENTERNAME': '焊接_DB',
|
||||
'RESOURCEFAMILYNAME': 'FAM001',
|
||||
'RESOURCENAME': 'RES002',
|
||||
},
|
||||
]
|
||||
|
||||
detail_df = pd.DataFrame([
|
||||
{'HISTORYID': 'RES001',
|
||||
'PRD_HOURS': 80, 'SBY_HOURS': 10, 'UDT_HOURS': 5, 'SDT_HOURS': 3, 'EGT_HOURS': 2,
|
||||
'NST_HOURS': 10, 'TOTAL_HOURS': 110},
|
||||
{'HISTORYID': 'RES002',
|
||||
'PRD_HOURS': 75, 'SBY_HOURS': 15, 'UDT_HOURS': 5, 'SDT_HOURS': 3, 'EGT_HOURS': 2,
|
||||
'NST_HOURS': 10, 'TOTAL_HOURS': 110},
|
||||
])
|
||||
|
||||
mock_read_sql.return_value = detail_df
|
||||
|
||||
@@ -226,14 +250,23 @@ class TestResourceHistoryAPIWorkflow:
|
||||
assert 'prd_hours' in first_row
|
||||
assert 'prd_pct' in first_row
|
||||
|
||||
@patch('mes_dashboard.services.resource_history_service.read_sql_df')
|
||||
def test_export_workflow(self, mock_read_sql, client):
|
||||
"""Export workflow should return valid CSV."""
|
||||
mock_read_sql.return_value = pd.DataFrame([
|
||||
{'WORKCENTERNAME': '焊接_DB', 'RESOURCEFAMILYNAME': 'FAM001', 'RESOURCENAME': 'RES001',
|
||||
'PRD_HOURS': 80, 'SBY_HOURS': 10, 'UDT_HOURS': 5, 'SDT_HOURS': 3, 'EGT_HOURS': 2,
|
||||
'NST_HOURS': 10, 'TOTAL_HOURS': 110},
|
||||
])
|
||||
@patch('mes_dashboard.services.resource_history_service._get_filtered_resources')
|
||||
@patch('mes_dashboard.services.resource_history_service.read_sql_df')
|
||||
def test_export_workflow(self, mock_read_sql, mock_resources, client):
|
||||
"""Export workflow should return valid CSV."""
|
||||
mock_resources.return_value = [
|
||||
{
|
||||
'RESOURCEID': 'RES001',
|
||||
'WORKCENTERNAME': '焊接_DB',
|
||||
'RESOURCEFAMILYNAME': 'FAM001',
|
||||
'RESOURCENAME': 'RES001',
|
||||
}
|
||||
]
|
||||
mock_read_sql.return_value = pd.DataFrame([
|
||||
{'HISTORYID': 'RES001',
|
||||
'PRD_HOURS': 80, 'SBY_HOURS': 10, 'UDT_HOURS': 5, 'SDT_HOURS': 3, 'EGT_HOURS': 2,
|
||||
'NST_HOURS': 10, 'TOTAL_HOURS': 110},
|
||||
])
|
||||
|
||||
response = client.get(
|
||||
'/api/resource/history/export'
|
||||
@@ -281,21 +314,47 @@ class TestResourceHistoryValidation:
|
||||
data = json.loads(response.data)
|
||||
assert data['success'] is False
|
||||
|
||||
@patch('mes_dashboard.services.resource_history_service.read_sql_df')
|
||||
def test_granularity_options(self, mock_read_sql, client):
|
||||
"""Different granularity options should work."""
|
||||
mock_df = pd.DataFrame([{
|
||||
'PRD_HOURS': 100, 'SBY_HOURS': 10, 'UDT_HOURS': 5,
|
||||
'SDT_HOURS': 3, 'EGT_HOURS': 2, 'NST_HOURS': 10, 'MACHINE_COUNT': 5
|
||||
}])
|
||||
mock_read_sql.return_value = mock_df
|
||||
|
||||
for granularity in ['day', 'week', 'month', 'year']:
|
||||
mock_read_sql.side_effect = [mock_df, pd.DataFrame(), pd.DataFrame(), pd.DataFrame()]
|
||||
|
||||
response = client.get(
|
||||
f'/api/resource/history/summary'
|
||||
f'?start_date=2024-01-01'
|
||||
@patch('mes_dashboard.services.resource_history_service._get_filtered_resources')
|
||||
@patch('mes_dashboard.services.resource_history_service.read_sql_df')
|
||||
def test_granularity_options(self, mock_read_sql, mock_resources, client):
|
||||
"""Different granularity options should work."""
|
||||
mock_resources.return_value = [{
|
||||
'RESOURCEID': 'RES001',
|
||||
'WORKCENTERNAME': '焊接_DB',
|
||||
'RESOURCEFAMILYNAME': 'FAM001',
|
||||
'RESOURCENAME': 'RES001',
|
||||
}]
|
||||
kpi_df = pd.DataFrame([{
|
||||
'PRD_HOURS': 100, 'SBY_HOURS': 10, 'UDT_HOURS': 5,
|
||||
'SDT_HOURS': 3, 'EGT_HOURS': 2, 'NST_HOURS': 10, 'MACHINE_COUNT': 5
|
||||
}])
|
||||
trend_df = pd.DataFrame([{
|
||||
'DATA_DATE': datetime(2024, 1, 1),
|
||||
'PRD_HOURS': 100, 'SBY_HOURS': 10, 'UDT_HOURS': 5,
|
||||
'SDT_HOURS': 3, 'EGT_HOURS': 2, 'NST_HOURS': 10,
|
||||
'MACHINE_COUNT': 5
|
||||
}])
|
||||
heatmap_raw_df = pd.DataFrame([{
|
||||
'HISTORYID': 'RES001',
|
||||
'DATA_DATE': datetime(2024, 1, 1),
|
||||
'PRD_HOURS': 100, 'SBY_HOURS': 10, 'UDT_HOURS': 5,
|
||||
'SDT_HOURS': 3, 'EGT_HOURS': 2, 'NST_HOURS': 10
|
||||
}])
|
||||
|
||||
for granularity in ['day', 'week', 'month', 'year']:
|
||||
def mock_sql(sql, _params=None):
|
||||
sql_upper = sql.upper()
|
||||
if 'HISTORYID' in sql_upper and 'DATA_DATE' in sql_upper:
|
||||
return heatmap_raw_df
|
||||
if 'DATA_DATE' in sql_upper:
|
||||
return trend_df
|
||||
return kpi_df
|
||||
|
||||
mock_read_sql.side_effect = mock_sql
|
||||
|
||||
response = client.get(
|
||||
f'/api/resource/history/summary'
|
||||
f'?start_date=2024-01-01'
|
||||
f'&end_date=2024-01-31'
|
||||
f'&granularity={granularity}'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user