feat: finalize no-iframe portal shell route-view migration
This commit is contained in:
28
frontend/tests/portal-shell-app-contract.test.js
Normal file
28
frontend/tests/portal-shell-app-contract.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
function readSource(relativePath) {
|
||||
return readFileSync(resolve(process.cwd(), relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('portal shell app renders health summary component and admin entry controls', () => {
|
||||
const source = readSource('src/portal-shell/App.vue');
|
||||
|
||||
assert.match(source, /import HealthStatus from '\.\/components\/HealthStatus\.vue';/);
|
||||
assert.match(source, /<HealthStatus \/>/);
|
||||
|
||||
assert.match(source, /管理後台/);
|
||||
assert.match(source, /管理員登入/);
|
||||
assert.match(source, /登出/);
|
||||
assert.match(source, /adminLinks\?\.pages/);
|
||||
});
|
||||
|
||||
test('portal shell app keeps fallback notice and route sync wiring', () => {
|
||||
const source = readSource('src/portal-shell/App.vue');
|
||||
|
||||
assert.doesNotMatch(source, /class="mode-badge"/);
|
||||
assert.match(source, /consumeNavigationNotice/);
|
||||
assert.match(source, /syncNavigationRoutes\(payload\.drawers/);
|
||||
});
|
||||
66
frontend/tests/portal-shell-health-summary.test.js
Normal file
66
frontend/tests/portal-shell-health-summary.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
buildHealthFallbackDetail,
|
||||
labelFromHealthStatus,
|
||||
normalizeFrontendShellHealth,
|
||||
} from '../src/portal-shell/healthSummary.js';
|
||||
|
||||
function readSource(relativePath) {
|
||||
return readFileSync(resolve(process.cwd(), relativePath), 'utf8');
|
||||
}
|
||||
|
||||
|
||||
test('labelFromHealthStatus maps healthy/degraded/unhealthy states', () => {
|
||||
assert.equal(labelFromHealthStatus('healthy'), '連線正常');
|
||||
assert.equal(labelFromHealthStatus('degraded'), '部分降級');
|
||||
assert.equal(labelFromHealthStatus('unhealthy'), '連線異常');
|
||||
});
|
||||
|
||||
|
||||
test('normalizeFrontendShellHealth reads summary/detail contract', () => {
|
||||
const normalized = normalizeFrontendShellHealth({
|
||||
summary: { status: 'healthy' },
|
||||
detail: { errors: ['none'] },
|
||||
});
|
||||
|
||||
assert.equal(normalized.status, 'healthy');
|
||||
assert.deepEqual(normalized.errors, ['none']);
|
||||
});
|
||||
|
||||
|
||||
test('normalizeFrontendShellHealth supports legacy flat payload shape', () => {
|
||||
const normalized = normalizeFrontendShellHealth({
|
||||
status: 'degraded',
|
||||
errors: ['asset missing'],
|
||||
});
|
||||
|
||||
assert.equal(normalized.status, 'degraded');
|
||||
assert.deepEqual(normalized.errors, ['asset missing']);
|
||||
});
|
||||
|
||||
|
||||
test('buildHealthFallbackDetail returns deterministic fallback contract', () => {
|
||||
const fallback = buildHealthFallbackDetail();
|
||||
assert.equal(fallback.status, 'unhealthy');
|
||||
assert.equal(fallback.label, '無法連線');
|
||||
assert.equal(fallback.detail.database, '無法確認');
|
||||
assert.equal(fallback.detail.routeCacheMode, '--');
|
||||
});
|
||||
|
||||
|
||||
test('HealthStatus component keeps summary-first trigger and detail panel interactions', () => {
|
||||
const source = readSource('src/portal-shell/components/HealthStatus.vue');
|
||||
|
||||
assert.match(source, /class=\"health-trigger\"/);
|
||||
assert.match(source, /meta-toggle\">詳情/);
|
||||
assert.match(source, /v-if=\"popupOpen\"/);
|
||||
|
||||
// Close-on-outside-click and ESC behavior remain part of the UX contract.
|
||||
assert.match(source, /document\.addEventListener\('click', onDocumentClick\)/);
|
||||
assert.match(source, /document\.addEventListener\('keydown', onDocumentKeydown\)/);
|
||||
assert.match(source, /event\.key === 'Escape'/);
|
||||
});
|
||||
107
frontend/tests/portal-shell-navigation.test.js
Normal file
107
frontend/tests/portal-shell-navigation.test.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildDynamicNavigationState,
|
||||
normalizeNavigationDrawers,
|
||||
} from '../src/portal-shell/navigationState.js';
|
||||
|
||||
|
||||
test('normalizeNavigationDrawers enforces deterministic order and visibility', () => {
|
||||
const input = [
|
||||
{
|
||||
id: 'dev-tools',
|
||||
name: 'Dev',
|
||||
order: 3,
|
||||
admin_only: true,
|
||||
pages: [{ route: '/admin/pages', name: 'Admin Pages', status: 'dev', order: 2 }],
|
||||
},
|
||||
{
|
||||
id: 'reports',
|
||||
name: 'Reports',
|
||||
order: 1,
|
||||
admin_only: false,
|
||||
pages: [
|
||||
{ route: '/wip-overview', name: 'WIP', status: 'released', order: 2 },
|
||||
{ route: '/hold-overview', name: 'Hold', status: 'dev', order: 1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const nonAdmin = normalizeNavigationDrawers(input, { isAdmin: false });
|
||||
assert.deepEqual(nonAdmin.map((d) => d.id), ['reports']);
|
||||
assert.deepEqual(nonAdmin[0].pages.map((p) => p.route), ['/wip-overview']);
|
||||
|
||||
const admin = normalizeNavigationDrawers(input, { isAdmin: true });
|
||||
assert.deepEqual(admin.map((d) => d.id), ['reports', 'dev-tools']);
|
||||
assert.deepEqual(admin[0].pages.map((p) => p.route), ['/hold-overview', '/wip-overview']);
|
||||
});
|
||||
|
||||
|
||||
test('buildDynamicNavigationState resolves render mode via route contract', () => {
|
||||
const state = buildDynamicNavigationState(
|
||||
[
|
||||
{
|
||||
id: 'reports',
|
||||
name: 'Reports',
|
||||
order: 1,
|
||||
pages: [{ route: '/wip-overview', name: 'WIP', status: 'released', order: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
name: 'Tools',
|
||||
order: 2,
|
||||
pages: [{ route: '/job-query', name: 'Job Query', status: 'released', order: 1 }],
|
||||
},
|
||||
],
|
||||
{ isAdmin: true },
|
||||
);
|
||||
|
||||
const renderModes = Object.fromEntries(
|
||||
state.dynamicRoutes.map((route) => [route.targetRoute, route.renderMode]),
|
||||
);
|
||||
assert.equal(renderModes['/wip-overview'], 'native');
|
||||
assert.equal(renderModes['/job-query'], 'native');
|
||||
assert.equal(state.diagnostics.missingContractRoutes.length, 0);
|
||||
});
|
||||
|
||||
|
||||
test('buildDynamicNavigationState tracks routes missing from contract and keeps native fallback mode', () => {
|
||||
const state = buildDynamicNavigationState(
|
||||
[
|
||||
{
|
||||
id: 'reports',
|
||||
name: 'Reports',
|
||||
order: 1,
|
||||
pages: [{ route: '/legacy-unknown', name: 'Legacy', status: 'released', order: 1 }],
|
||||
},
|
||||
],
|
||||
{ isAdmin: false },
|
||||
);
|
||||
|
||||
assert.deepEqual(state.diagnostics.missingContractRoutes, ['/legacy-unknown']);
|
||||
assert.equal(state.dynamicRoutes[0].renderMode, 'native');
|
||||
assert.deepEqual(state.allowedPaths.sort(), ['/', '/legacy-unknown']);
|
||||
});
|
||||
|
||||
|
||||
test('buildDynamicNavigationState can include standalone drilldown routes without drawer entries', () => {
|
||||
const state = buildDynamicNavigationState(
|
||||
[
|
||||
{
|
||||
id: 'reports',
|
||||
name: 'Reports',
|
||||
order: 1,
|
||||
pages: [{ route: '/wip-overview', name: 'WIP', status: 'released', order: 1 }],
|
||||
},
|
||||
],
|
||||
{ isAdmin: false, includeStandaloneDrilldown: true },
|
||||
);
|
||||
|
||||
const targetRoutes = state.dynamicRoutes.map((route) => route.targetRoute);
|
||||
assert.equal(targetRoutes.includes('/wip-overview'), true);
|
||||
assert.equal(targetRoutes.includes('/wip-detail'), true);
|
||||
assert.equal(targetRoutes.includes('/hold-detail'), true);
|
||||
assert.equal(state.allowedPaths.includes('/wip-detail'), true);
|
||||
assert.equal(state.allowedPaths.includes('/hold-detail'), true);
|
||||
});
|
||||
30
frontend/tests/portal-shell-no-iframe.test.js
Normal file
30
frontend/tests/portal-shell-no-iframe.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
function readSource(relativePath) {
|
||||
return readFileSync(resolve(process.cwd(), relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('shell route host sources do not contain iframe rendering paths', () => {
|
||||
const files = [
|
||||
'src/portal-shell/App.vue',
|
||||
'src/portal-shell/views/NativeRouteView.vue',
|
||||
'src/portal-shell/router.js',
|
||||
'src/portal-shell/navigationState.js',
|
||||
];
|
||||
|
||||
files.forEach((filePath) => {
|
||||
const source = readSource(filePath).toLowerCase();
|
||||
assert.doesNotMatch(source, /<iframe/);
|
||||
});
|
||||
});
|
||||
|
||||
test('page bridge host is removed after native route-view decommission', () => {
|
||||
const routerSource = readSource('src/portal-shell/router.js');
|
||||
assert.doesNotMatch(routerSource, /PageBridgeView/);
|
||||
|
||||
const appPySource = readSource('../src/mes_dashboard/app.py');
|
||||
assert.doesNotMatch(appPySource, /\/api\/portal\/wrapper-telemetry/);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
function readSource(relativePath) {
|
||||
return readFileSync(resolve(process.cwd(), relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('table parity: Wave B native pages keep deterministic column and empty-state handling', () => {
|
||||
const jobSource = readSource('src/job-query/App.vue');
|
||||
assert.match(jobSource, /jobsColumns/);
|
||||
assert.match(jobSource, /txnColumns/);
|
||||
assert.match(jobSource, /目前無資料/);
|
||||
|
||||
const excelSource = readSource('src/excel-query/App.vue');
|
||||
assert.match(excelSource, /queryResult\.columns/);
|
||||
assert.match(excelSource, /queryResult\.rows\.length === 0/);
|
||||
|
||||
const queryToolSource = readSource('src/query-tool/App.vue');
|
||||
assert.match(queryToolSource, /resolvedColumns/);
|
||||
assert.match(queryToolSource, /historyColumns/);
|
||||
assert.match(queryToolSource, /associationColumns/);
|
||||
assert.match(queryToolSource, /equipmentColumns/);
|
||||
});
|
||||
|
||||
test('table parity: list/detail pages preserve pagination and sort continuity hooks', () => {
|
||||
const wipDetailSource = readSource('src/wip-detail/App.vue');
|
||||
assert.match(wipDetailSource, /const page = ref\(1\)/);
|
||||
assert.match(wipDetailSource, /page_size|pageSize/);
|
||||
|
||||
const holdDetailSource = readSource('src/hold-detail/App.vue');
|
||||
assert.match(holdDetailSource, /page|currentPage|perPage/);
|
||||
assert.match(holdDetailSource, /distribution|lots/i);
|
||||
|
||||
const tmttTableSource = readSource('src/tmtt-defect/components/TmttDetailTable.vue');
|
||||
assert.match(tmttTableSource, /sort/i);
|
||||
});
|
||||
|
||||
test('chart parity: chart pages keep tooltip, legend, autoresize and click linkage', () => {
|
||||
const qcChartSource = readSource('src/qc-gate/components/QcGateChart.vue');
|
||||
assert.match(qcChartSource, /tooltip\s*:/);
|
||||
assert.match(qcChartSource, /legend\s*:/);
|
||||
assert.match(qcChartSource, /autoresize/);
|
||||
assert.match(qcChartSource, /@click="handleChartClick"/);
|
||||
|
||||
const holdParetoSource = readSource('src/hold-history/components/ReasonPareto.vue');
|
||||
assert.match(holdParetoSource, /tooltip\s*:/);
|
||||
assert.match(holdParetoSource, /legend\s*:/);
|
||||
assert.match(holdParetoSource, /@click="handleChartClick"/);
|
||||
|
||||
const tmttChartSource = readSource('src/tmtt-defect/components/TmttChartCard.vue');
|
||||
assert.match(tmttChartSource, /tooltip\s*:/);
|
||||
assert.match(tmttChartSource, /legend\s*:/);
|
||||
assert.match(tmttChartSource, /autoresize/);
|
||||
});
|
||||
|
||||
test('matrix interaction parity: selection/highlight/drill handlers remain present', () => {
|
||||
const wipMatrixSource = readSource('src/wip-overview/components/MatrixTable.vue');
|
||||
assert.match(wipMatrixSource, /emit\('drilldown'/);
|
||||
|
||||
const holdMatrixSource = readSource('src/hold-overview/components/HoldMatrix.vue');
|
||||
assert.match(holdMatrixSource, /emit\('select'/);
|
||||
assert.match(holdMatrixSource, /isCellActive|isRowActive|isColumnActive/);
|
||||
|
||||
const resourceMatrixSource = readSource('src/resource-status/components/MatrixSection.vue');
|
||||
assert.match(resourceMatrixSource, /cell-filter/);
|
||||
assert.match(resourceMatrixSource, /selectedColumns/);
|
||||
assert.match(resourceMatrixSource, /toggle-all/);
|
||||
});
|
||||
63
frontend/tests/portal-shell-route-query-compat.test.js
Normal file
63
frontend/tests/portal-shell-route-query-compat.test.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildLaunchHref } from '../src/portal-shell/routeQuery.js';
|
||||
import { toRuntimeRoute } from '../src/core/shell-navigation.js';
|
||||
|
||||
test('list-detail workflow preserves wip filters across launch href and shell runtime prefix', () => {
|
||||
const detailHref = buildLaunchHref('/wip-detail', {
|
||||
workcenter: 'WB12',
|
||||
workorder: 'WO-001',
|
||||
lotid: 'LOT-001',
|
||||
status: 'queue',
|
||||
});
|
||||
assert.equal(
|
||||
detailHref,
|
||||
'/wip-detail?workcenter=WB12&workorder=WO-001&lotid=LOT-001&status=queue',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
toRuntimeRoute(detailHref, { currentPathname: '/portal-shell/wip-overview' }),
|
||||
'/portal-shell/wip-detail?workcenter=WB12&workorder=WO-001&lotid=LOT-001&status=queue',
|
||||
);
|
||||
});
|
||||
|
||||
test('hold list-detail workflow keeps reason/workcenter/package query continuity', () => {
|
||||
const holdDetailHref = buildLaunchHref('/hold-detail', {
|
||||
reason: 'YieldLimit',
|
||||
workcenter: 'DA',
|
||||
package: 'QFN',
|
||||
page: '2',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
holdDetailHref,
|
||||
'/hold-detail?reason=YieldLimit&workcenter=DA&package=QFN&page=2',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
toRuntimeRoute(holdDetailHref, { currentPathname: '/portal-shell/hold-overview' }),
|
||||
'/portal-shell/hold-detail?reason=YieldLimit&workcenter=DA&package=QFN&page=2',
|
||||
);
|
||||
});
|
||||
|
||||
test('resource history multi-value filters remain compatible in shell links', () => {
|
||||
const historyHref = buildLaunchHref('/resource-history', {
|
||||
start_date: '2026-02-01',
|
||||
end_date: '2026-02-11',
|
||||
workcenter_groups: ['DB', 'WB'],
|
||||
families: ['DIP'],
|
||||
resource_ids: ['EQ-01', 'EQ-02'],
|
||||
granularity: 'day',
|
||||
});
|
||||
|
||||
assert.ok(historyHref.includes('workcenter_groups=DB'));
|
||||
assert.ok(historyHref.includes('workcenter_groups=WB'));
|
||||
assert.ok(historyHref.includes('resource_ids=EQ-01'));
|
||||
assert.ok(historyHref.includes('resource_ids=EQ-02'));
|
||||
|
||||
assert.equal(
|
||||
toRuntimeRoute(historyHref, { currentPathname: '/portal-shell/resource' }),
|
||||
`/portal-shell${historyHref}`,
|
||||
);
|
||||
});
|
||||
36
frontend/tests/portal-shell-route-query.test.js
Normal file
36
frontend/tests/portal-shell-route-query.test.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildLaunchHref } from '../src/portal-shell/routeQuery.js';
|
||||
|
||||
test('buildLaunchHref keeps base target route without query payload', () => {
|
||||
assert.equal(buildLaunchHref('/job-query'), '/job-query');
|
||||
});
|
||||
|
||||
test('buildLaunchHref appends scalar query values', () => {
|
||||
assert.equal(
|
||||
buildLaunchHref('/job-query', { q: 'ABCD', page: '2' }),
|
||||
'/job-query?q=ABCD&page=2',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildLaunchHref supports repeated query keys from array values', () => {
|
||||
assert.equal(
|
||||
buildLaunchHref('/excel-query', { lotid: ['L1', 'L2'], mode: 'upload' }),
|
||||
'/excel-query?lotid=L1&lotid=L2&mode=upload',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildLaunchHref replaces existing query keys with latest runtime values', () => {
|
||||
assert.equal(
|
||||
buildLaunchHref('/query-tool?mode=legacy&page=1', { mode: 'runtime', page: '3' }),
|
||||
'/query-tool?mode=runtime&page=3',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildLaunchHref ignores empty and null-like query values', () => {
|
||||
assert.equal(
|
||||
buildLaunchHref('/tmtt-defect', { start_date: '', end_date: null, shift: undefined }),
|
||||
'/tmtt-defect',
|
||||
);
|
||||
});
|
||||
48
frontend/tests/portal-shell-wave-a-chart-lifecycle.test.js
Normal file
48
frontend/tests/portal-shell-wave-a-chart-lifecycle.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
function readSource(relativePath) {
|
||||
return readFileSync(resolve(process.cwd(), relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('shell route view uses direct RouterView host (no transition blank-state)', () => {
|
||||
const appSource = readSource('src/portal-shell/App.vue');
|
||||
assert.match(appSource, /<RouterView \/>/);
|
||||
assert.doesNotMatch(appSource, /<Transition name=\"route-fade\" mode=\"out-in\">/);
|
||||
});
|
||||
|
||||
test('Wave A chart components keep autoresize and tooltip configuration', () => {
|
||||
const chartFiles = [
|
||||
'src/wip-overview/components/ParetoSection.vue',
|
||||
'src/qc-gate/components/QcGateChart.vue',
|
||||
'src/hold-history/components/DailyTrend.vue',
|
||||
'src/hold-history/components/ReasonPareto.vue',
|
||||
'src/hold-history/components/DurationChart.vue',
|
||||
'src/resource-history/components/TrendChart.vue',
|
||||
'src/resource-history/components/StackedChart.vue',
|
||||
'src/resource-history/components/HeatmapChart.vue',
|
||||
'src/resource-history/components/ComparisonChart.vue',
|
||||
];
|
||||
|
||||
chartFiles.forEach((filePath) => {
|
||||
const source = readSource(filePath);
|
||||
assert.match(source, /tooltip\s*:/, `missing tooltip config: ${filePath}`);
|
||||
assert.match(source, /autoresize/, `missing autoresize lifecycle hook: ${filePath}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('QC Gate keeps linked chart-table interaction guards', () => {
|
||||
const source = readSource('src/qc-gate/App.vue');
|
||||
assert.match(source, /const activeFilter = ref\(null\)/);
|
||||
assert.match(source, /const filteredLots = computed\(\(\) =>/);
|
||||
assert.match(source, /function handleChartSelect\(filter\)/);
|
||||
assert.match(source, /activeFilter\.value = null/);
|
||||
});
|
||||
|
||||
test('resource tooltip lifecycle keeps resize listener cleanup', () => {
|
||||
const source = readSource('src/resource-status/components/FloatingTooltip.vue');
|
||||
assert.match(source, /window\.addEventListener\('resize', positionTooltip\)/);
|
||||
assert.match(source, /window\.removeEventListener\('resize', positionTooltip\)/);
|
||||
});
|
||||
87
frontend/tests/portal-shell-wave-a-smoke.test.js
Normal file
87
frontend/tests/portal-shell-wave-a-smoke.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { toRuntimeRoute } from '../src/core/shell-navigation.js';
|
||||
import { getNativeModuleLoader } from '../src/portal-shell/nativeModuleRegistry.js';
|
||||
import { buildDynamicNavigationState } from '../src/portal-shell/navigationState.js';
|
||||
import { getRouteContract } from '../src/portal-shell/routeContracts.js';
|
||||
|
||||
const WAVE_A_ROUTES = Object.freeze([
|
||||
'/wip-overview',
|
||||
'/wip-detail',
|
||||
'/hold-overview',
|
||||
'/hold-detail',
|
||||
'/hold-history',
|
||||
'/resource',
|
||||
'/resource-history',
|
||||
'/qc-gate',
|
||||
'/tmtt-defect',
|
||||
]);
|
||||
|
||||
const WAVE_B_NATIVE_ROUTES = Object.freeze([
|
||||
'/job-query',
|
||||
'/excel-query',
|
||||
'/query-tool',
|
||||
]);
|
||||
|
||||
test('Wave A contracts resolve to native mode with native module loaders', () => {
|
||||
WAVE_A_ROUTES.forEach((routePath) => {
|
||||
const contract = getRouteContract(routePath);
|
||||
assert.ok(contract, `missing contract: ${routePath}`);
|
||||
assert.equal(contract.renderMode, 'native', `route mode mismatch: ${routePath}`);
|
||||
assert.equal(typeof getNativeModuleLoader(routePath), 'function', `missing native loader: ${routePath}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('Wave B contracts resolve to native mode with native module loaders after rewrite', () => {
|
||||
WAVE_B_NATIVE_ROUTES.forEach((routePath) => {
|
||||
const contract = getRouteContract(routePath);
|
||||
assert.ok(contract, `missing contract: ${routePath}`);
|
||||
assert.equal(contract.renderMode, 'native', `route mode mismatch: ${routePath}`);
|
||||
assert.equal(typeof getNativeModuleLoader(routePath), 'function', `missing native loader: ${routePath}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('Wave A routes register as native routes from navigation payload', () => {
|
||||
const state = buildDynamicNavigationState(
|
||||
[
|
||||
{
|
||||
id: 'reports',
|
||||
name: 'Reports',
|
||||
order: 1,
|
||||
pages: WAVE_A_ROUTES.map((route, index) => ({
|
||||
route,
|
||||
name: route,
|
||||
status: 'released',
|
||||
order: index + 1,
|
||||
})),
|
||||
},
|
||||
],
|
||||
{ isAdmin: false },
|
||||
);
|
||||
|
||||
assert.equal(state.dynamicRoutes.length, WAVE_A_ROUTES.length);
|
||||
assert.deepEqual(state.diagnostics.missingContractRoutes, []);
|
||||
assert.deepEqual(
|
||||
state.dynamicRoutes.map((entry) => entry.renderMode),
|
||||
Array(WAVE_A_ROUTES.length).fill('native'),
|
||||
);
|
||||
});
|
||||
|
||||
test('Wave A deep links preserve query string in shell runtime paths', () => {
|
||||
const sampleDeepLinks = [
|
||||
'/wip-overview?workorder=AA001&status=all',
|
||||
'/wip-detail?workcenter=WB12&lotid=L01',
|
||||
'/hold-overview?hold_type=quality&reason=QC',
|
||||
'/hold-detail?reason=QC&workcenter=WB12',
|
||||
'/hold-history?start_date=2026-02-01&end_date=2026-02-11&record_type=new,release',
|
||||
'/resource-history?start_date=2026-02-01&end_date=2026-02-11&granularity=day',
|
||||
];
|
||||
|
||||
sampleDeepLinks.forEach((routePath) => {
|
||||
assert.equal(
|
||||
toRuntimeRoute(routePath, { currentPathname: '/portal-shell/wip-overview' }),
|
||||
`/portal-shell${routePath}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
81
frontend/tests/portal-shell-wave-b-native-smoke.test.js
Normal file
81
frontend/tests/portal-shell-wave-b-native-smoke.test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildDynamicNavigationState } from '../src/portal-shell/navigationState.js';
|
||||
import { buildLaunchHref } from '../src/portal-shell/routeQuery.js';
|
||||
import { getRouteContract } from '../src/portal-shell/routeContracts.js';
|
||||
|
||||
const WAVE_B_NATIVE_CASES = Object.freeze([
|
||||
{
|
||||
route: '/job-query',
|
||||
page: '設備維修查詢',
|
||||
query: {
|
||||
start_date: '2026-02-01',
|
||||
end_date: '2026-02-11',
|
||||
resource_ids: ['EQ-01', 'EQ-02'],
|
||||
},
|
||||
expectedParams: ['start_date=2026-02-01', 'end_date=2026-02-11', 'resource_ids=EQ-01', 'resource_ids=EQ-02'],
|
||||
},
|
||||
{
|
||||
route: '/excel-query',
|
||||
page: 'Excel 查詢工具',
|
||||
query: {
|
||||
mode: 'upload',
|
||||
table_name: 'DWH.DW_MES_WIP',
|
||||
search_column: 'LOT_ID',
|
||||
},
|
||||
expectedParams: ['mode=upload', 'table_name=DWH.DW_MES_WIP', 'search_column=LOT_ID'],
|
||||
},
|
||||
{
|
||||
route: '/query-tool',
|
||||
page: 'Query Tool',
|
||||
query: {
|
||||
input_type: 'lot_id',
|
||||
values: ['GA23100020-A00-001'],
|
||||
},
|
||||
expectedParams: ['input_type=lot_id', 'values=GA23100020-A00-001'],
|
||||
},
|
||||
]);
|
||||
|
||||
test('Wave B routes use native mode after rewrite cutover', () => {
|
||||
WAVE_B_NATIVE_CASES.forEach(({ route }) => {
|
||||
const contract = getRouteContract(route);
|
||||
assert.ok(contract, `missing contract for ${route}`);
|
||||
assert.equal(contract.renderMode, 'native', `expected native mode for ${route}`);
|
||||
assert.equal(contract.rollbackStrategy, 'fallback_to_legacy_route');
|
||||
});
|
||||
});
|
||||
|
||||
test('Wave B shell launch href keeps workflow query context', () => {
|
||||
WAVE_B_NATIVE_CASES.forEach(({ route, query, expectedParams }) => {
|
||||
const href = buildLaunchHref(route, query);
|
||||
assert.ok(href.startsWith(route), `unexpected href path for ${route}: ${href}`);
|
||||
expectedParams.forEach((token) => {
|
||||
assert.ok(href.includes(token), `missing query token ${token} in ${href}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('Wave B routes are registered as native targets from navigation payload', () => {
|
||||
const state = buildDynamicNavigationState(
|
||||
[
|
||||
{
|
||||
id: 'native-tools',
|
||||
name: 'Native Tools',
|
||||
order: 1,
|
||||
pages: WAVE_B_NATIVE_CASES.map((entry, index) => ({
|
||||
route: entry.route,
|
||||
name: entry.page,
|
||||
status: 'released',
|
||||
order: index + 1,
|
||||
})),
|
||||
},
|
||||
],
|
||||
{ isAdmin: true },
|
||||
);
|
||||
|
||||
assert.equal(state.dynamicRoutes.length, WAVE_B_NATIVE_CASES.length);
|
||||
state.dynamicRoutes.forEach((entry) => {
|
||||
assert.equal(entry.renderMode, 'native');
|
||||
});
|
||||
});
|
||||
89
frontend/tests/shell-navigation.test.js
Normal file
89
frontend/tests/shell-navigation.test.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
isPortalShellRuntime,
|
||||
navigateToRuntimeRoute,
|
||||
toRuntimeRoute,
|
||||
} from '../src/core/shell-navigation.js';
|
||||
|
||||
|
||||
test('isPortalShellRuntime detects portal-shell path prefix', () => {
|
||||
assert.equal(isPortalShellRuntime('/portal-shell'), true);
|
||||
assert.equal(isPortalShellRuntime('/portal-shell/wip-overview'), true);
|
||||
assert.equal(isPortalShellRuntime('/wip-overview'), false);
|
||||
});
|
||||
|
||||
|
||||
test('toRuntimeRoute keeps legacy route outside shell runtime', () => {
|
||||
assert.equal(
|
||||
toRuntimeRoute('/wip-overview?status=run', { currentPathname: '/wip-overview' }),
|
||||
'/wip-overview?status=run',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test('toRuntimeRoute prefixes target route inside shell runtime', () => {
|
||||
assert.equal(
|
||||
toRuntimeRoute('/wip-overview?status=run', { currentPathname: '/portal-shell/wip-overview' }),
|
||||
'/portal-shell/wip-overview?status=run',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test('toRuntimeRoute avoids double-prefix for already-prefixed path', () => {
|
||||
assert.equal(
|
||||
toRuntimeRoute('/portal-shell/wip-overview', { currentPathname: '/portal-shell' }),
|
||||
'/portal-shell/wip-overview',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test('navigateToRuntimeRoute uses shell router bridge in portal runtime', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
const calls = [];
|
||||
|
||||
globalThis.window = {
|
||||
location: {
|
||||
pathname: '/portal-shell/wip-overview',
|
||||
href: '/portal-shell/wip-overview',
|
||||
replace: (value) => calls.push({ kind: 'replace-location', value }),
|
||||
},
|
||||
__MES_PORTAL_SHELL_NAVIGATE__: (target, options) => {
|
||||
calls.push({ kind: 'bridge', target, options });
|
||||
},
|
||||
};
|
||||
|
||||
navigateToRuntimeRoute('/wip-detail?workcenter=WB12');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
kind: 'bridge',
|
||||
target: '/wip-detail?workcenter=WB12',
|
||||
options: { replace: false },
|
||||
},
|
||||
]);
|
||||
|
||||
globalThis.window = originalWindow;
|
||||
});
|
||||
|
||||
|
||||
test('navigateToRuntimeRoute falls back to location when bridge is unavailable', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
const calls = [];
|
||||
|
||||
globalThis.window = {
|
||||
location: {
|
||||
pathname: '/wip-overview',
|
||||
href: '/wip-overview',
|
||||
replace: (value) => calls.push({ kind: 'replace-location', value }),
|
||||
},
|
||||
};
|
||||
|
||||
navigateToRuntimeRoute('/wip-detail?workcenter=WB12');
|
||||
|
||||
assert.equal(globalThis.window.location.href, '/wip-detail?workcenter=WB12');
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
globalThis.window = originalWindow;
|
||||
});
|
||||
Reference in New Issue
Block a user