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:
2026-01-20 11:53:26 -05:00
parent ea437d360e
commit c4a3cff91a
16 changed files with 4067 additions and 239 deletions

View 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();
});

View 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));
}