diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..fb000c69 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,203 @@ +name: CI - Build and Test Application + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + python-version: ['3.10', '3.11'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache Python dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Install Node.js dependencies + run: npm ci + + - name: Lint frontend code + run: npm run lint + continue-on-error: true + + - name: Build frontend + run: npm run build + + - name: Run Python tests + run: | + python -m pytest test/ -v + + - name: Start backend server and health check + run: | + # Start the backend server in the background + python -m src.backend & + BACKEND_PID=$! + + # Wait for the server to start + echo "Waiting for backend server to start..." + sleep 10 + + # Health check - test if the server is responding + max_attempts=30 + attempt=1 + + while [ $attempt -le $max_attempts ]; do + if curl -f http://localhost:8000/health 2>/dev/null; then + echo "Backend server is healthy!" + break + elif curl -f http://localhost:8000/ 2>/dev/null; then + echo "Backend server is responding!" + break + else + echo "Attempt $attempt/$max_attempts: Backend not ready yet..." + sleep 2 + attempt=$((attempt + 1)) + fi + done + + if [ $attempt -gt $max_attempts ]; then + echo "Backend server failed to start properly" + kill $BACKEND_PID 2>/dev/null || true + exit 1 + fi + + # Test basic API endpoints if they exist + echo "Testing backend endpoints..." + + # Clean up + kill $BACKEND_PID 2>/dev/null || true + echo "Backend server test completed successfully!" + + - name: Test frontend build serves correctly + run: | + # Start the preview server in the background + npm run preview & + FRONTEND_PID=$! + + # Wait for the server to start + echo "Waiting for frontend server to start..." + sleep 5 + + # Health check for frontend + max_attempts=15 + attempt=1 + + while [ $attempt -le $max_attempts ]; do + if curl -f http://localhost:4173/ 2>/dev/null; then + echo "Frontend server is serving correctly!" + break + else + echo "Attempt $attempt/$max_attempts: Frontend not ready yet..." + sleep 2 + attempt=$((attempt + 1)) + fi + done + + if [ $attempt -gt $max_attempts ]; then + echo "Frontend server failed to start properly" + kill $FRONTEND_PID 2>/dev/null || true + exit 1 + fi + + # Clean up + kill $FRONTEND_PID 2>/dev/null || true + echo "Frontend server test completed successfully!" + + integration-test: + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + npm ci + + - name: Build frontend + run: npm run build + + - name: Test full application stack + run: | + # Start both frontend and backend + echo "Starting full application stack..." + + # Start backend + python -m src.backend & + BACKEND_PID=$! + + # Start frontend preview + npm run preview & + FRONTEND_PID=$! + + # Wait for both services + sleep 15 + + # Test both services are running + echo "Testing backend..." + if ! curl -f http://localhost:8000/ 2>/dev/null; then + echo "Backend failed to start" + kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true + exit 1 + fi + + echo "Testing frontend..." + if ! curl -f http://localhost:4173/ 2>/dev/null; then + echo "Frontend failed to start" + kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true + exit 1 + fi + + echo "Both services are running successfully!" + + # Clean up + kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true + + echo "Integration test completed successfully!" \ No newline at end of file diff --git a/package.json b/package.json index e4f013cb..b16423e9 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "vite build", "lint": "eslint .", "preview": "vite preview", - "start:backend": "python ./src/backend.py", + "start:backend": "python -m src.backend", "start:both": "concurrently \"npm run dev\" \"npm run start:backend\"" }, "dependencies": { diff --git a/requirements.txt b/requirements.txt index 39c99271..d9e63044 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ Werkzeug==3.1.3 pathsim==0.7.0 matplotlib==3.7.0 numpy==1.24.0 -plotly~=6 \ No newline at end of file +plotly~=6.0 +pytest \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..8309909e --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,3 @@ +from . import custom_pathsim_blocks +from . import convert_to_python +from . import backend diff --git a/src/backend.py b/src/backend.py index 0ce1e2f8..1b58970a 100644 --- a/src/backend.py +++ b/src/backend.py @@ -2,7 +2,6 @@ import json from flask import Flask, request, jsonify from flask_cors import CORS -from convert_to_python import convert_graph_to_python import math import numpy as np import plotly.graph_objects as go @@ -11,6 +10,7 @@ import json as plotly_json from pathsim import Simulation, Connection +from pathsim.events import Event import pathsim.solvers from pathsim.blocks import ( Scope, @@ -28,7 +28,8 @@ PID, Schedule, ) -from custom_pathsim_blocks import Process, Splitter +from .custom_pathsim_blocks import Process, Splitter +from .convert_to_python import convert_graph_to_python NAME_TO_SOLVER = { "SSPRK22": pathsim.solvers.SSPRK22, @@ -36,6 +37,28 @@ "RKF21": pathsim.solvers.RKF21, } +map_str_to_object = { + "constant": Constant, + "stepsource": StepSource, + "pulsesource": PulseSource, + "amplifier": Amplifier, + "amplifier_reverse": Amplifier, + "scope": Scope, + "splitter2": Splitter, + "splitter3": Splitter, + "adder": Adder, + "adder_reverse": Adder, + "multiplier": Multiplier, + "process": Process, + "process_horizontal": Process, + "rng": RNG, + "pid": PID, + "integrator": Integrator, + "function": Function, + "delay": Delay, +} + + # app = Flask(__name__) # CORS(app, supports_credentials=True) @@ -52,6 +75,15 @@ os.makedirs(SAVE_DIR, exist_ok=True) +# Health check endpoint for CI/CD +@app.route("/", methods=["GET"]) +@app.route("/health", methods=["GET"]) +def health_check(): + return jsonify( + {"status": "healthy", "message": "Fuel Cycle Simulator Backend is running"} + ), 200 + + # Function to save graphs @app.route("/save", methods=["POST"]) def save_graph(): @@ -384,29 +416,48 @@ def make_solver_params(solver_prms, eval_namespace=None): return solver_prms, extra_params, duration -map_str_to_object = { - "constant": Constant, - "stepsource": StepSource, - "pulsesource": PulseSource, - "amplifier": Amplifier, - "amplifier_reverse": Amplifier, - "scope": Scope, - "splitter2": Splitter, - "splitter3": Splitter, - "adder": Adder, - "adder_reverse": Adder, - "multiplier": Multiplier, - "process": Process, - "process_horizontal": Process, - "rng": RNG, - "pid": PID, - "integrator": Integrator, - "function": Function, - "delay": Delay, -} +def auto_block_construction(node: dict, eval_namespace: dict = None) -> Block: + """ + Automatically constructs a block object from a node dictionary. + + Args: + node: The node dictionary containing block information. + eval_namespace: A namespace for evaluating expressions. Defaults to None. + Raises: + ValueError: If the block type is unknown or if there are issues with evaluation. -def make_blocks(nodes, edges, eval_namespace=None): + Returns: + The constructed block object. + """ + if eval_namespace is None: + eval_namespace = globals() + + block_type = node["type"] + + if eval_namespace is None: + eval_namespace = globals() + + block_type = node["type"] + if block_type not in map_str_to_object: + raise ValueError(f"Unknown block type: {block_type}") + + block_class = map_str_to_object[block_type] + + # skip 'self' + parameters_for_class = block_class.__init__.__code__.co_varnames[1:] + + parameters = { + k: eval(v, eval_namespace) + for k, v in node["data"].items() + if k in parameters_for_class + } + return block_class(**parameters) + + +def make_blocks( + nodes: list[dict], edges: list[dict], eval_namespace: dict = None +) -> tuple[list[Block], list[Event]]: blocks, events = [], [] for node in nodes: @@ -445,17 +496,7 @@ def make_blocks(nodes, edges, eval_namespace=None): ], ) else: # try automated construction - block_class = map_str_to_object[block_type] - - # skip 'self' - parameters_for_class = block_class.__init__.__code__.co_varnames[1:] - - parameters = { - k: eval(v, eval_namespace) - for k, v in node["data"].items() - if k in parameters_for_class - } - block = block_class(**parameters) + block = auto_block_construction(node, eval_namespace) block.id = node["id"] block.label = node["data"]["label"] diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/test_backend.py b/test/test_backend.py new file mode 100644 index 00000000..795ae478 --- /dev/null +++ b/test/test_backend.py @@ -0,0 +1,165 @@ +from src.backend import create_integrator, auto_block_construction, create_function +from src.custom_pathsim_blocks import Process, Splitter + +import pathsim.blocks + +import pytest + + +# Node templates with constructor parameters + label for each block type +NODE_TEMPLATES = { + "constant": {"type": "constant", "data": {"value": "1.0", "label": "Constant"}}, + "stepsource": { + "type": "stepsource", + "data": {"amplitude": "1.0", "tau": "1.0", "label": "Step Source"}, + }, + "pulsesource": { + "type": "pulsesource", + "data": {"amplitude": "1.0", "tau": "1.0", "label": "Pulse Source"}, + }, + "amplifier": {"type": "amplifier", "data": {"gain": "2.0", "label": "Amplifier"}}, + "adder": {"type": "adder", "data": {"label": "Adder"}}, + "multiplier": {"type": "multiplier", "data": {"label": "Multiplier"}}, + "integrator": { + "type": "integrator", + "data": {"initial_value": "0.0", "label": "Integrator", "reset_times": ""}, + }, + "function": { + "type": "function", + "data": {"expression": "3*x**2", "label": "Function"}, + }, + "delay": {"type": "delay", "data": {"tau": "1.0", "label": "Delay"}}, + "rng": {"type": "rng", "data": {"seed": "42", "label": "RNG"}}, + "pid": { + "type": "pid", + "data": {"kp": "1.0", "ki": "0.0", "kd": "0.0", "label": "PID"}, + }, + "process": { + "type": "process", + "data": { + "residence_time": "1.0", + "ic": "0.0", + "gen": "0.0", + "label": "Process", + }, + }, + "splitter2": { + "type": "splitter2", + "data": {"f1": "0.5", "f2": "0.5", "label": "Splitter 2"}, + }, + "splitter3": { + "type": "splitter3", + "data": {"f1": "1/3", "f2": "1/3", "f3": "1/3", "label": "Splitter 3"}, + }, + "scope": {"type": "scope", "data": {"label": "Scope"}}, +} + + +@pytest.fixture +def node_factory(): + """ + Factory fixture that creates node dictionaries for different pathsim block types. + + Usage: + def test_something(node_factory): + integrator_node = node_factory("integrator", id="test_1") + constant_node = node_factory("constant", id="test_2", data_overrides={"value": "5.0"}) + """ + + def _create_node(block_type: str, id: str = "1", data_overrides: dict = None): + if block_type not in NODE_TEMPLATES: + available_types = list(NODE_TEMPLATES.keys()) + raise ValueError( + f"Unknown block type: {block_type}. Available types: {available_types}" + ) + + # Start with template + node = { + "id": id, + "type": block_type, + "data": NODE_TEMPLATES[block_type]["data"].copy(), + } + + # Apply any data overrides + if data_overrides: + node["data"].update(data_overrides) + + return node + + return _create_node + + +def test_create_integrator(): + node = { + "data": {"initial_value": "", "label": "IV vial 1", "reset_times": ""}, + "id": "9", + "type": "integrator", + } + integrator, events = create_integrator(node) + + assert isinstance(integrator, pathsim.blocks.Integrator) + assert integrator.initial_value == 0 + for event in events: + assert isinstance(event, pathsim.blocks.Schedule) + + +@pytest.mark.parametrize( + "block_type,expected_class", + [ + ("constant", pathsim.blocks.Constant), + ("amplifier", pathsim.blocks.Amplifier), + ("adder", pathsim.blocks.Adder), + ("multiplier", pathsim.blocks.Multiplier), + ("rng", pathsim.blocks.RNG), + ("pid", pathsim.blocks.PID), + ("process", Process), + ("splitter2", Splitter), + ("splitter3", Splitter), + ], +) +def test_auto_block_construction(node_factory, block_type, expected_class): + """Test auto_block_construction for various block types. + Using the node_factory fixture to create nodes dynamically. + """ + node = node_factory(block_type) + block = auto_block_construction(node) + assert isinstance(block, expected_class) + + +@pytest.mark.parametrize( + "block_type,expected_class", + [ + ("constant", pathsim.blocks.Constant), + ("amplifier", pathsim.blocks.Amplifier), + ("adder", pathsim.blocks.Adder), + ("multiplier", pathsim.blocks.Multiplier), + ("rng", pathsim.blocks.RNG), + ("pid", pathsim.blocks.PID), + ("process", Process), + ("splitter2", Splitter), + ("splitter3", Splitter), + ], +) +def test_auto_block_construction_with_var(node_factory, block_type, expected_class): + """ + Test auto_block_construction with a variable in the data. + This simulates a case where a data value is replaced with a variable expression.""" + node = node_factory(block_type) + # replace one data value with "2*var1" + for k, v in node["data"].items(): + if k != "label": + node["data"][k] = "2*var1" + break + block = auto_block_construction(node, eval_namespace={"var1": 5.5}) + assert isinstance(block, expected_class) + + +def test_create_function(): + node = { + "data": {"expression": "3*x**2 + b", "label": "Function"}, + "id": "10", + "type": "function", + } + block = create_function(node, eval_namespace={"b": 2.5}) + assert isinstance(block, pathsim.blocks.Function) + assert block.func(2) == 3 * 2**2 + 2.5