feat(canvas): Studio Enhancement Phase 1 & 2 - v2.0 architecture and file structure
Phase 1 - Foundation:
- Add NodeConfigPanelV2 using useSpecStore for AtomizerSpec v2.0 mode
- Deprecate AtomizerCanvas and useCanvasStore with migration docs
- Add VITE_USE_LEGACY_CANVAS env var for emergency fallback
- Enhance NodePalette with collapse support, filtering, exports
- Add drag-drop support to SpecRenderer with default node data
- Setup test infrastructure (Vitest + Playwright configs)
- Add useSpecStore unit tests (15 tests)
Phase 2 - File Structure & Model:
- Create FileStructurePanel with tree view of study files
- Add ModelNodeV2 with collapsible file dependencies
- Add tabbed left sidebar (Components/Files tabs)
- Add GET /api/files/structure/{study_id} backend endpoint
- Auto-expand 1_setup folders in file tree
- Show model file introspection with solver type and expressions
Technical:
- All TypeScript checks pass
- All 15 unit tests pass
- Production build successful
This commit is contained in:
137
atomizer-dashboard/frontend/src/test/setup.ts
Normal file
137
atomizer-dashboard/frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Vitest Test Setup
|
||||
*
|
||||
* This file runs before each test file to set up the testing environment.
|
||||
*/
|
||||
|
||||
/// <reference types="vitest/globals" />
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
|
||||
// Type for global context
|
||||
declare const global: typeof globalThis;
|
||||
|
||||
// ============================================================================
|
||||
// Mock Browser APIs
|
||||
// ============================================================================
|
||||
|
||||
// Mock ResizeObserver (used by ReactFlow)
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock scrollTo
|
||||
Element.prototype.scrollTo = vi.fn();
|
||||
window.scrollTo = vi.fn();
|
||||
|
||||
// Mock fetch for API calls
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// ============================================================================
|
||||
// Mock localStorage
|
||||
// ============================================================================
|
||||
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// ============================================================================
|
||||
// Mock WebSocket
|
||||
// ============================================================================
|
||||
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
readonly CONNECTING = 0;
|
||||
readonly OPEN = 1;
|
||||
readonly CLOSING = 2;
|
||||
readonly CLOSED = 3;
|
||||
|
||||
url: string;
|
||||
readyState: number = MockWebSocket.CONNECTING;
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
// Simulate connection after a tick
|
||||
setTimeout(() => {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.onopen?.(new Event('open'));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
send = vi.fn();
|
||||
close = vi.fn(() => {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.onclose?.(new CloseEvent('close'));
|
||||
});
|
||||
}
|
||||
|
||||
global.WebSocket = MockWebSocket as any;
|
||||
|
||||
// ============================================================================
|
||||
// Console Suppression (optional)
|
||||
// ============================================================================
|
||||
|
||||
// Suppress console.error for expected test warnings
|
||||
const originalError = console.error;
|
||||
beforeAll(() => {
|
||||
console.error = (...args: any[]) => {
|
||||
// Suppress React act() warnings
|
||||
if (typeof args[0] === 'string' && args[0].includes('Warning: An update to')) {
|
||||
return;
|
||||
}
|
||||
originalError.call(console, ...args);
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Cleanup
|
||||
// ============================================================================
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.getItem.mockReset();
|
||||
localStorageMock.setItem.mockReset();
|
||||
});
|
||||
142
atomizer-dashboard/frontend/src/test/utils.tsx
Normal file
142
atomizer-dashboard/frontend/src/test/utils.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Test Utilities
|
||||
*
|
||||
* Provides custom render function with all necessary providers.
|
||||
*/
|
||||
|
||||
/// <reference types="vitest/globals" />
|
||||
|
||||
import { ReactElement, ReactNode } from 'react';
|
||||
import { render, RenderOptions } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { StudyProvider } from '../context/StudyContext';
|
||||
|
||||
// Type for global context
|
||||
declare const global: typeof globalThis;
|
||||
|
||||
/**
|
||||
* All providers needed for testing components
|
||||
*/
|
||||
function AllProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<StudyProvider>
|
||||
{children}
|
||||
</StudyProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom render function that wraps component with all providers
|
||||
*/
|
||||
const customRender = (
|
||||
ui: ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'>
|
||||
) => render(ui, { wrapper: AllProviders, ...options });
|
||||
|
||||
// Re-export everything from RTL
|
||||
export * from '@testing-library/react';
|
||||
export { userEvent } from '@testing-library/user-event';
|
||||
|
||||
// Override render with our custom one
|
||||
export { customRender as render };
|
||||
|
||||
/**
|
||||
* Create a mock AtomizerSpec for testing
|
||||
*/
|
||||
export function createMockSpec(overrides: Partial<any> = {}): any {
|
||||
return {
|
||||
meta: {
|
||||
version: '2.0',
|
||||
study_name: 'test_study',
|
||||
created_by: 'test',
|
||||
created_at: new Date().toISOString(),
|
||||
...overrides.meta,
|
||||
},
|
||||
model: {
|
||||
sim: {
|
||||
path: 'model.sim',
|
||||
solver: 'nastran',
|
||||
solution_type: 'SOL101',
|
||||
},
|
||||
...overrides.model,
|
||||
},
|
||||
design_variables: overrides.design_variables ?? [
|
||||
{
|
||||
id: 'dv_001',
|
||||
name: 'thickness',
|
||||
expression_name: 'wall_thickness',
|
||||
type: 'continuous',
|
||||
bounds: { min: 1, max: 10 },
|
||||
baseline: 5,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
extractors: overrides.extractors ?? [
|
||||
{
|
||||
id: 'ext_001',
|
||||
name: 'displacement',
|
||||
type: 'displacement',
|
||||
outputs: ['max_disp'],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
objectives: overrides.objectives ?? [
|
||||
{
|
||||
id: 'obj_001',
|
||||
name: 'minimize_mass',
|
||||
type: 'minimize',
|
||||
source: { extractor_id: 'ext_001', output: 'max_disp' },
|
||||
weight: 1.0,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
constraints: overrides.constraints ?? [],
|
||||
optimization: {
|
||||
algorithm: { type: 'TPE' },
|
||||
budget: { max_trials: 100 },
|
||||
...overrides.optimization,
|
||||
},
|
||||
canvas: {
|
||||
edges: [],
|
||||
layout_version: '2.0',
|
||||
...overrides.canvas,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock API response
|
||||
*/
|
||||
export function mockFetch(responses: Record<string, any>) {
|
||||
return (global.fetch as any).mockImplementation((url: string, options?: RequestInit) => {
|
||||
const method = options?.method || 'GET';
|
||||
const key = `${method} ${url}`;
|
||||
|
||||
// Find matching response
|
||||
for (const [pattern, response] of Object.entries(responses)) {
|
||||
if (key.includes(pattern) || url.includes(pattern)) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(response),
|
||||
text: () => Promise.resolve(JSON.stringify(response)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Default 404
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: () => Promise.resolve({ detail: 'Not found' }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for async state updates
|
||||
*/
|
||||
export async function waitForStateUpdate() {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
Reference in New Issue
Block a user