feat: complete dashboard-vite parity and fix portal health/csp regressions

This commit is contained in:
egg
2026-02-09 09:22:23 +08:00
parent cf194bc3a3
commit 1e6d6dbd31
57 changed files with 13347 additions and 312 deletions

View File

@@ -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={

View File

@@ -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())

View File

@@ -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."""

View File

@@ -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}'
)