{ "verdict": "APPROVED", "rejection_reason": "NONE", "audit_details": { "target_invoked": true, "pre_conditions_tested": true, "post_conditions_tested": true, "test_data_used": true }, "feedback": "The test suite robustly verifies the

MigrationEngine
 contracts. It avoids Tautologies by cleanly substituting IdMappingService without mocking the engine itself. Cross-filter parsing asserts against hard-coded, predefined validation dictionaries (no Logic Mirroring). It successfully addresses @PRE negative cases (e.g. invalid zip paths, missing YAMLs) and rigorously validates @POST file transformations (e.g. in-place UUID substitutions and archive reconstruction)." }
This commit is contained in:
2026-02-25 17:47:55 +03:00
parent 590ba49ddb
commit 99f19ac305
20 changed files with 1211 additions and 308 deletions

View File

@@ -148,17 +148,28 @@
// Migration Settings State
let migrationCron = "0 2 * * *";
let displayMappings = [];
let mappingsTotal = 0;
let mappingsPage = 0;
let mappingsPageSize = 25;
let mappingsSearch = "";
let mappingsEnvFilter = "";
let mappingsTypeFilter = "";
let isSavingMigration = false;
let isLoadingMigration = false;
let isSyncing = false;
let searchTimeout = null;
$: mappingsTotalPages = Math.max(
1,
Math.ceil(mappingsTotal / mappingsPageSize),
);
async function loadMigrationSettings() {
isLoadingMigration = true;
try {
const settingsRes = await api.requestApi("/migration/settings");
migrationCron = settingsRes.cron;
const mappingsRes = await api.requestApi("/migration/mappings-data");
displayMappings = mappingsRes;
await loadMappingsPage();
} catch (err) {
console.error("[SettingsPage][Migration] Failed to load:", err);
} finally {
@@ -166,6 +177,43 @@
}
}
async function loadMappingsPage() {
try {
const skip = mappingsPage * mappingsPageSize;
let url = `/migration/mappings-data?skip=${skip}&limit=${mappingsPageSize}`;
if (mappingsSearch)
url += `&search=${encodeURIComponent(mappingsSearch)}`;
if (mappingsEnvFilter)
url += `&env_id=${encodeURIComponent(mappingsEnvFilter)}`;
if (mappingsTypeFilter)
url += `&resource_type=${encodeURIComponent(mappingsTypeFilter)}`;
const res = await api.requestApi(url);
displayMappings = res.items || [];
mappingsTotal = res.total || 0;
} catch (err) {
console.error("[SettingsPage][Migration] Failed to load mappings:", err);
}
}
function onMappingsSearchInput(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
mappingsPage = 0;
loadMappingsPage();
}, 300);
}
function onMappingsFilterChange() {
mappingsPage = 0;
loadMappingsPage();
}
function goToMappingsPage(page) {
if (page < 0 || page >= mappingsTotalPages) return;
mappingsPage = page;
loadMappingsPage();
}
async function saveMigrationSettings() {
isSavingMigration = true;
try {
@@ -1076,7 +1124,12 @@
<h3
class="text-lg font-medium mb-4 flex items-center justify-between"
>
<span>Synchronized Resources</span>
<span
>Synchronized Resources <span
class="text-sm font-normal text-gray-500"
>({mappingsTotal})</span
></span
>
<button
on:click={loadMigrationSettings}
class="text-sm text-indigo-600 hover:text-indigo-800 flex items-center gap-1"
@@ -1098,6 +1151,38 @@
</button>
</h3>
<!-- Search and Filters -->
<div class="flex flex-wrap gap-3 mb-4">
<div class="flex-1 min-w-[200px]">
<input
type="text"
bind:value={mappingsSearch}
on:input={onMappingsSearchInput}
placeholder="Search by name or UUID..."
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<select
bind:value={mappingsEnvFilter}
on:change={onMappingsFilterChange}
class="px-3 py-2 border border-gray-300 rounded-md text-sm bg-white focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">All Environments</option>
<option value="ss1">ss1</option>
<option value="ss2">ss2</option>
</select>
<select
bind:value={mappingsTypeFilter}
on:change={onMappingsFilterChange}
class="px-3 py-2 border border-gray-300 rounded-md text-sm bg-white focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">All Types</option>
<option value="chart">Chart</option>
<option value="dataset">Dataset</option>
<option value="dashboard">Dashboard</option>
</select>
</div>
<div class="overflow-x-auto border border-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-100">
@@ -1138,7 +1223,11 @@
><td
colspan="5"
class="px-6 py-8 text-center text-gray-500"
>No synchronized resources found.</td
>{mappingsSearch ||
mappingsEnvFilter ||
mappingsTypeFilter
? "No matching resources found."
: "No synchronized resources found."}</td
></tr
>
{:else}
@@ -1150,7 +1239,12 @@
>
<td class="px-6 py-4 whitespace-nowrap"
><span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800"
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {mapping.resource_type ===
'chart'
? 'bg-blue-100 text-blue-800'
: mapping.resource_type === 'dataset'
? 'bg-green-100 text-green-800'
: 'bg-purple-100 text-purple-800'}"
>{mapping.resource_type}</span
></td
>
@@ -1171,6 +1265,55 @@
</tbody>
</table>
</div>
<!-- Pagination Controls -->
{#if mappingsTotal > mappingsPageSize}
<div
class="flex items-center justify-between mt-4 text-sm text-gray-600"
>
<span
>Showing {mappingsPage * mappingsPageSize + 1}{Math.min(
(mappingsPage + 1) * mappingsPageSize,
mappingsTotal,
)} of {mappingsTotal}</span
>
<div class="flex items-center gap-2">
<button
on:click={() => goToMappingsPage(0)}
disabled={mappingsPage === 0}
class="px-2 py-1 rounded border {mappingsPage === 0
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white hover:bg-gray-50 text-gray-700'}">«</button
>
<button
on:click={() => goToMappingsPage(mappingsPage - 1)}
disabled={mappingsPage === 0}
class="px-2 py-1 rounded border {mappingsPage === 0
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white hover:bg-gray-50 text-gray-700'}"></button
>
<span class="px-3 py-1 font-medium"
>{mappingsPage + 1} / {mappingsTotalPages}</span
>
<button
on:click={() => goToMappingsPage(mappingsPage + 1)}
disabled={mappingsPage >= mappingsTotalPages - 1}
class="px-2 py-1 rounded border {mappingsPage >=
mappingsTotalPages - 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white hover:bg-gray-50 text-gray-700'}"></button
>
<button
on:click={() => goToMappingsPage(mappingsTotalPages - 1)}
disabled={mappingsPage >= mappingsTotalPages - 1}
class="px-2 py-1 rounded border {mappingsPage >=
mappingsTotalPages - 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white hover:bg-gray-50 text-gray-700'}">»</button
>
</div>
</div>
{/if}
</div>
</div>
{:else if activeTab === "storage"}

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import SettingsPage from '../+page.svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/toasts';
vi.mock('$lib/api', () => ({
api: {
getConsolidatedSettings: vi.fn(),
requestApi: vi.fn(),
putApi: vi.fn(),
postApi: vi.fn(),
updateConsolidatedSettings: vi.fn()
}
}));
vi.mock('$lib/toasts', () => ({
addToast: vi.fn()
}));
vi.mock('$lib/i18n', () => ({
t: {
subscribe: (fn) => {
fn({
settings: {
title: 'Settings',
migration: 'Migration Sync',
save_success: 'Settings saved',
save_failed: 'Failed'
},
common: { refresh: 'Refresh' }
});
return () => { };
}
},
_: vi.fn((key) => key)
}));
// Mock child components
vi.mock('../../components/llm/ProviderConfig.svelte', () => ({
default: class ProviderConfigMock {
constructor(options) {
options.target.innerHTML = `<div data-testid="provider-config"></div>`;
}
}
}));
describe('SettingsPage.integration.test.js', () => {
const mockSettings = {
environments: [],
logging: { level: 'INFO', task_log_level: 'INFO', enable_belief_state: false },
connections: [],
llm: {}
};
const mockMigrationSettings = {
cron: "0 2 * * *"
};
beforeEach(() => {
vi.clearAllMocks();
api.getConsolidatedSettings.mockResolvedValue(mockSettings);
api.requestApi.mockImplementation((url) => {
if (url === '/migration/settings') return Promise.resolve(mockMigrationSettings);
if (url.includes('/migration/mappings-data')) return Promise.resolve({ items: [], total: 0 });
return Promise.resolve({});
});
});
it('renders and fetches consolidated settings and migration settings on mount', async () => {
render(SettingsPage);
await waitFor(() => {
expect(api.getConsolidatedSettings).toHaveBeenCalled();
expect(api.requestApi).toHaveBeenCalledWith('/migration/settings');
});
});
it('navigates to the migration tab and checks content', async () => {
render(SettingsPage);
await waitFor(() => expect(api.getConsolidatedSettings).toHaveBeenCalled());
// Find the migration tab button by text
const migrationTabBtn = screen.getByText('Migration Sync');
await fireEvent.click(migrationTabBtn);
// Verify migration settings UI is shown
expect(screen.getByText('Cross-Environment ID Synchronization')).toBeTruthy();
expect(screen.getByDisplayValue('0 2 * * *')).toBeTruthy();
});
it('triggers a sync now action successfully', async () => {
api.postApi.mockResolvedValue({ synced_count: 1, failed_count: 0 });
render(SettingsPage);
await waitFor(() => expect(api.getConsolidatedSettings).toHaveBeenCalled());
const migrationTabBtn = screen.getByText('Migration Sync');
await fireEvent.click(migrationTabBtn);
const syncNowBtn = screen.getByText('Sync Now');
await fireEvent.click(syncNowBtn);
expect(api.postApi).toHaveBeenCalledWith('/migration/sync-now', {});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith('Synced 1 environment(s)', 'success');
});
});
});

View File

@@ -0,0 +1,178 @@
// [DEF:__tests__/settings_page_ux_test:Module]
// @RELATION: VERIFIES -> ../+page.svelte
// @PURPOSE: Test UX states and transitions
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import SettingsPage from '../+page.svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/toasts';
vi.mock('$lib/api', () => ({
api: {
getConsolidatedSettings: vi.fn(),
requestApi: vi.fn(),
putApi: vi.fn(),
postApi: vi.fn(),
updateConsolidatedSettings: vi.fn()
}
}));
vi.mock('$lib/toasts', () => ({
addToast: vi.fn()
}));
vi.mock('$lib/i18n', () => ({
t: {
subscribe: (fn) => {
fn({
settings: {
title: 'Settings',
migration: 'Migration Sync',
save_success: 'Settings saved',
save_failed: 'Failed'
},
common: { refresh: 'Refresh', retry: 'Retry' }
});
return () => { };
}
},
_: vi.fn((key) => key)
}));
// Mock child components
vi.mock('../../components/llm/ProviderConfig.svelte', () => ({
default: class ProviderConfigMock {
constructor(options) {
options.target.innerHTML = `<div data-testid="provider-config"></div>`;
}
}
}));
describe('SettingsPage UX Contracts', () => {
const mockSettings = {
environments: [],
logging: { level: 'INFO', task_log_level: 'INFO', enable_belief_state: false },
connections: [],
llm: {}
};
const mockMigrationSettings = {
cron: "0 2 * * *"
};
beforeEach(() => {
vi.clearAllMocks();
});
// @UX_STATE: Loading -> Shows skeleton loader
// @UX_STATE: Loaded -> Shows tabbed settings interface
it('should transition from Loading to Loaded state', async () => {
// Delay resolution to capture loading state
let resolveSettings;
api.getConsolidatedSettings.mockImplementation(() => new Promise(resolve => {
resolveSettings = resolve;
}));
api.requestApi.mockResolvedValue(mockMigrationSettings);
render(SettingsPage);
// Assert Loading skeleton is present (by checking for the pulse class)
// Note: checking for classes used in skeleton
const skeletonElements = document.querySelectorAll('.animate-pulse');
expect(skeletonElements.length).toBeGreaterThan(0);
// Resolve the API call
resolveSettings(mockSettings);
// Assert Loaded state
await waitFor(() => {
expect(screen.getByText('Settings')).toBeTruthy();
expect(screen.getByText('Environments')).toBeTruthy();
});
});
// @UX_STATE: Error -> Shows error banner with retry button
it('should show error banner when loading fails', async () => {
api.getConsolidatedSettings.mockRejectedValue(new Error('Network Error'));
api.requestApi.mockResolvedValue(mockMigrationSettings);
render(SettingsPage);
await waitFor(() => {
expect(screen.getByText('Network Error')).toBeTruthy();
expect(screen.getByText('Retry')).toBeTruthy();
});
});
// @UX_RECOVERY: Refresh button reloads settings data
it('should reload settings data when retry button is clicked', async () => {
let callCount = 0;
api.getConsolidatedSettings.mockImplementation(async () => {
callCount++;
if (callCount === 1) throw new Error('First call failed');
return mockSettings;
});
api.requestApi.mockResolvedValue(mockMigrationSettings);
render(SettingsPage);
// Wait for error state
await waitFor(() => {
expect(screen.getByText('First call failed')).toBeTruthy();
});
// Click retry
const retryBtn = screen.getByText('Retry');
await fireEvent.click(retryBtn);
// Verify recovery (Loaded state)
await waitFor(() => {
expect(screen.queryByText('First call failed')).toBeNull();
expect(screen.getByText('Environments')).toBeTruthy();
});
// We expect it to have been called twice (1. initial mount, 2. retry click)
expect(api.getConsolidatedSettings).toHaveBeenCalledTimes(2);
});
// @UX_FEEDBACK: Toast notifications on save success/failure
it('should show success toast when settings are saved', async () => {
api.getConsolidatedSettings.mockResolvedValue(mockSettings);
api.requestApi.mockResolvedValue(mockMigrationSettings);
api.updateConsolidatedSettings.mockResolvedValue({});
render(SettingsPage);
await waitFor(() => expect(screen.getByText('Settings')).toBeTruthy());
// Navigate to Logging tab where the Save button is
await fireEvent.click(screen.getByText('Logging'));
const saveBtn = screen.getByText('Save Logging Config');
await fireEvent.click(saveBtn);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith('Settings saved', 'success');
});
});
it('should show error toast when settings save fails', async () => {
api.getConsolidatedSettings.mockResolvedValue(mockSettings);
api.requestApi.mockResolvedValue(mockMigrationSettings);
api.updateConsolidatedSettings.mockRejectedValue(new Error('Save Error'));
render(SettingsPage);
await waitFor(() => expect(screen.getByText('Settings')).toBeTruthy());
// Navigate to Logging tab where the Save button is
await fireEvent.click(screen.getByText('Logging'));
const saveBtn = screen.getByText('Save Logging Config');
await fireEvent.click(saveBtn);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith('Failed', 'error');
});
});
});