diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 875bf3e..60bf525 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,4 @@ + name: FiberLab CI on: @@ -30,8 +31,10 @@ jobs: with: python-version: "3.12" - - name: Install Ruff - run: python -m pip install --upgrade ruff + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt ruff - name: Run Ruff lint run: ruff check . @@ -55,7 +58,7 @@ jobs: - name: Install project dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + python -m pip install -r requirements.txt - name: Run test suite run: python -m pytest -v diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..25a0cc7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,64 @@ +# FiberLab Guardian β€” Security Notes + +## 1. Purpose + +FiberLab Guardian inspects candidate code changes, validates module contracts, prepares an isolated integration workspace, and runs permitted quality checks. + +It is designed as a project-level code inspection and integration assistant. It is not a hardened security sandbox. + +## 2. Implemented Protections + +The integration engine currently implements the following protections: + +- Rejects absolute candidate paths. +- Rejects candidate paths containing `..` traversal components. +- Checks candidate path components for symbolic links. +- Resolves candidate paths and verifies that they remain within the candidate root. +- Checks the destination path for an existing symbolic link before path resolution. +- Creates integration workspaces separately from the original project. +- Excludes Git metadata, virtual environments, caches, and other configured directories from workspace copies. + +## 3. Known Limitations + +### 3.1 Time-of-Check to Time-of-Use (TOCTOU) + +Candidate path validation, containment checks, and file copying are separate filesystem operations. + +A concurrent local process could modify a validated candidate or destination path between these operations. This may allow filesystem access outside the intended roots. + +**Status:** Known and accepted limitation for the current project scope. + +**Severity:** Medium. + +**Mitigation:** Guardian should only operate on trusted local workspaces. Stronger descriptor-relative filesystem operations and race-resistant copying may be considered in a future security-hardening phase. + +### 3.2 No OS-Level Execution Sandbox + +The integration workspace is a separate project copy, not an operating-system security boundary. + +Subprocess timeouts limit execution duration but do not prevent filesystem access, network access, or other potentially unsafe operations. + +Guardian must not execute untrusted pull-request code in the normal inspection workflow. + +## 4. Pull Request Inspection Policy + +- Download candidate files from the exact pull-request commit. +- Perform static inspection before integration. +- Do not execute untrusted pull-request code. +- Disable quality-check execution for untrusted PR candidates. +- Require human review before accepting or merging changes. + +## 5. Future Security Improvements + +Potential future improvements include: + +- Race-resistant filesystem operations. +- OS-level sandboxing for untrusted code execution. +- More comprehensive filesystem permission checks. +- Additional security-focused automated tests. + +## 6. Security Review Disclaimer + +Guardian reports are automated engineering assessments. A passing report does not guarantee that code is free from vulnerabilities, constitute security certification, or approve a pull request for merging. + +Human review remains mandatory. \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..8c576f6 --- /dev/null +++ b/app.py @@ -0,0 +1,74 @@ +"""FiberLab Streamlit application entry point.""" + +from __future__ import annotations + +import streamlit as st + +from pages.attenuation_laboratory import render as render_attenuation_laboratory +from pages.fiber_explorer import render as render_fiber_explorer +from pages.guardian_dashboard import render as render_guardian +from pages.home import render as render_home +from pages.signal_propagation import render as render_signal_propagation + +PAGE_DEFINITIONS = ( + { + "render": render_home, + "title": "FiberLab", + "icon": ":material/home:", + "url_path": "home", + "default": True, + }, + { + "render": render_fiber_explorer, + "title": "Fiber Explorer", + "icon": ":material/explore:", + "url_path": "fiber-explorer", + }, + { + "render": render_attenuation_laboratory, + "title": "Attenuation Laboratory", + "icon": ":material/science:", + "url_path": "attenuation-laboratory", + }, + { + "render": render_signal_propagation, + "title": "Signal Propagation", + "icon": ":material/graphic_eq:", + "url_path": "signal-propagation", + }, + { + "render": render_guardian, + "title": "Guardian", + "icon": ":material/shield:", + "url_path": "guardian", + }, +) + + +def build_navigation(): + """Build the native Streamlit navigation entries.""" + return st.navigation( + [ + st.Page( + definition["render"], + title=definition["title"], + icon=definition["icon"], + url_path=definition["url_path"], + default=definition.get("default", False), + ) + for definition in PAGE_DEFINITIONS + ], + position="sidebar", + ) + + +def main() -> None: + """Configure and render the Streamlit application.""" + st.set_page_config(page_title="FiberLab", page_icon="πŸ”¬", layout="wide") + + navigation = build_navigation() + navigation.run() + + +if __name__ == "__main__": + main() diff --git a/components/__init__.py b/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/page_layout.py b/components/page_layout.py new file mode 100644 index 0000000..6d0a6ab --- /dev/null +++ b/components/page_layout.py @@ -0,0 +1,17 @@ +"""Reusable page layout components for FiberLab.""" + +import streamlit as st + + +def render_page_header(title: str, caption: str, description: str) -> None: + """Render a consistent header across laboratory pages.""" + st.title(title) + st.caption(caption) + st.write(description) + st.divider() + + +def render_workspace(message: str) -> None: + """Render a standard laboratory workspace section.""" + st.subheader("Laboratory workspace") + st.info(message) diff --git a/components/result_display.py b/components/result_display.py new file mode 100644 index 0000000..dcb974c --- /dev/null +++ b/components/result_display.py @@ -0,0 +1,16 @@ +"""Reusable result display components for FiberLab.""" + +import streamlit as st + + +def render_result_metrics(results: dict[str, str]) -> None: + """Display simulation results as a row of metrics.""" + if not results: + st.info("No simulation results available yet.") + return + + columns = st.columns(len(results)) + + for column, (label, value) in zip(columns, results.items()): + with column: + st.metric(label=label, value=value) diff --git a/components/validation.py b/components/validation.py new file mode 100644 index 0000000..53f950a --- /dev/null +++ b/components/validation.py @@ -0,0 +1,39 @@ +"""Input validation utilities for FiberLab.""" + +import math + + +def validate_positive(value: float, name: str) -> None: + """Ensure a value is finite and greater than zero.""" + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value <= 0 + ): + raise ValueError(f"{name} must be a finite positive number.") + + +def validate_non_negative(value: float, name: str) -> None: + """Ensure a value is finite and non-negative.""" + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value < 0 + ): + raise ValueError(f"{name} must be a finite non-negative number.") + + +def validate_refractive_indices( + n_core: float, + n_cladding: float, +) -> None: + """Validate core and cladding refractive indices.""" + validate_positive(n_core, "Core refractive index") + validate_positive(n_cladding, "Cladding refractive index") + + if n_core <= n_cladding: + raise ValueError( + "Core refractive index must be greater than cladding refractive index." + ) diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md new file mode 100644 index 0000000..8a9bd13 --- /dev/null +++ b/docs/DEVELOPER_GUIDE.md @@ -0,0 +1,196 @@ + +# FiberLab Developer Guide + +## 1. Project Overview + +FiberLab is an interactive Optical Fiber Communication Simulator built using Python and Streamlit. + +It helps users explore optical fiber fundamentals, attenuation, and basic signal propagation through interactive simulations. + +## 2. Technology Stack + +- Python β€” Core programming language +- Streamlit β€” Web application framework +- NumPy and SciPy β€” Numerical computing +- Plotly β€” Interactive visualizations +- Matplotlib β€” Scientific plotting +- Pytest β€” Automated testing +- Ruff β€” Code quality and linting +- Git and GitHub β€” Version control and collaboration + +## 3. Project Architecture + +FiberLab follows a modular architecture that separates application logic, physics calculations, visualization, and reusable UI components. + + +## 4. Project Folder Structure + +```text +FiberLab/ +β”œβ”€β”€ app.py +β”œβ”€β”€ components/ +β”œβ”€β”€ pages/ +β”œβ”€β”€ physics/ +β”œβ”€β”€ visualization/ +β”œβ”€β”€ tests/ +β”œβ”€β”€ docs/ +β”‚ └── integration/ +β”œβ”€β”€ .github/ +β”‚ └── workflows/ +β”œβ”€β”€ requirements.txt +β”œβ”€β”€ README.md +└── .gitignore +``` + +### 4.1 Folder Responsibilities + +- `app.py`: Application entry point and navigation. +- `components/`: Reusable Streamlit UI components. +- `pages/`: Individual simulator pages. +- `physics/`: Scientific calculations and mathematical models. +- `visualization/`: Interactive graphs and visual simulations. +- `tests/`: Automated tests. +- `docs/`: Technical and developer documentation. +- `.github/workflows/`: GitHub Actions and CI configuration. + + +## 5. Development Environment Setup + +### Prerequisites + +- Python 3.12 or compatible version. +- Git. +- Visual Studio Code (recommended). + +### Clone the Repository + +```bash +git clone https://github.com/CodingBunny06/FiberLab.git +cd FiberLab +``` + +### Create a Virtual Environment + +```bash +python3 -m venv .venv +``` + +### Activate the Environment + +macOS/Linux: + +```bash +source .venv/bin/activate +``` + +### Install Dependencies + +```bash +python -m pip install --upgrade pip +pip install -r requirements.txt +``` + + +## 6. Running FiberLab + +Start the application using: + +```bash +streamlit run app.py +``` + +Streamlit will provide a local URL to access the application in your browser. + +To stop the application, press `Ctrl + C` in the terminal. + + +## 7. Development Workflow + +All development work should follow the project's Git collaboration process. + +### 7.1 Branching Strategy + +- `main`: Stable integration branch. +- `feature/`: New features and development work. +- `fix/`: Bug fixes. +- `docs/`: Documentation changes. + +Create a feature branch before starting new work: + +```bash +git switch main +git pull origin main +git switch -c feature/your-feature-name +``` + +### 7.2 Development Process + +1. Understand the assigned task. +2. Implement the changes. +3. Run code quality checks. +4. Run automated tests. +5. Commit the changes. +6. Push the branch. +7. Open a pull request for review. + + +## 8. Testing and Code Quality + +Before submitting changes, run the following checks. + +### Run Ruff + +```bash +ruff check . +``` + +### Run Pytest + +```bash +python -m pytest -v +``` + +All checks must pass before submitting a pull request. + +New functionality should include appropriate automated tests. + + +## 9. Coding Conventions + +- Follow consistent Python naming conventions. +- Use meaningful variable and function names. +- Include type hints wherever practical. +- Write docstrings for functions and classes. +- Keep functions focused on a single responsibility. +- Avoid duplicating existing functionality. +- Keep physics calculations independent of Streamlit. +- Keep visualization logic separate from scientific calculations. +- Document units for physical quantities. + +## 10. Module Integration Guidelines + +FiberLab uses separate modules for scientific calculations and visualizations. + +### Physics Module + +- Responsible for optical fiber calculations. +- Uses documented physical units. +- Must not depend on Streamlit. +- Must include independent unit tests. + +### Visualization Module + +- Responsible for graphical representations. +- Uses Plotly for interactive figures. +- Receives calculated data from the physics module. +- Must not duplicate scientific calculations. + +### Application Layer + +- Collects and validates user inputs. +- Calls physics functions. +- Passes results to visualization functions. +- Displays results through Streamlit. + +Refer to `docs/integration/MODULE_CONTRACTS.md` +for detailed function specifications and integration agreements. \ No newline at end of file diff --git a/docs/integration/INTEGRATION_CHECKLIST.md b/docs/integration/INTEGRATION_CHECKLIST.md new file mode 100644 index 0000000..7ecefdd --- /dev/null +++ b/docs/integration/INTEGRATION_CHECKLIST.md @@ -0,0 +1,50 @@ +# FiberLab v1.0 Integration Checklist + +Mark an item complete only after verifying it against implementation and test evidence. + +## A. Contract agreement +- [ ] Physics owner reviewed and agreed to Section 8 of `MODULE_CONTRACTS.md`. +- [ ] Visualization owner reviewed and agreed to Section 9. +- [ ] Function names, parameters, return types, units, and exceptions are confirmed. +- [ ] Ray-data and waveform-data structures have documented examples. +- [ ] Unit conversions occur at the application boundary. + +## B. Physics integration +- [ ] Physics modules import without Streamlit. +- [ ] Numerical-aperture and critical-angle functions match the agreed contract. +- [ ] Attenuation functions match the agreed contract. +- [ ] Signal-propagation outputs have documented units and compatible dimensions. +- [ ] Invalid inputs raise documented exceptions. +- [ ] Unit tests cover normal, boundary, and invalid inputs. + +## C. Visualization integration +- [ ] Visualization consumes calculated data and does not reimplement physics. +- [ ] Fiber-ray function accepts the agreed ray-data structure. +- [ ] Attenuation graph receives compatible distance and power arrays. +- [ ] Signal graph receives compatible input, output, and time-axis arrays. +- [ ] Functions return Plotly Figure objects. +- [ ] Empty and invalid data are handled predictably. +- [ ] Tests inspect figure type, traces, labels, and data. + +## D. Application integration +- [ ] `app.py` owns navigation. +- [ ] Pages call the agreed physics API and pass results to the agreed visualization API. +- [ ] UI values are validated before calculations. +- [ ] UI-to-SI conversions occur at the application boundary. +- [ ] Errors are shown clearly without crashing the application. +- [ ] Physics and visualization modules do not depend on Streamlit. + +## E. Feature walkthrough +- [ ] Fiber Explorer: numerical aperture, critical angle, acceptance angle, and TIR conditions. +- [ ] Attenuation Laboratory: output power, total loss, and graph. +- [ ] Signal Propagation: input/output signal flow and comparison graph. +- [ ] Learning Lab: educational content and navigation. +- [ ] Navigation between all pages works. +- [ ] Valid, invalid, boundary, and empty inputs are exercised. + +## F. Final evidence +- [ ] Full test suite passes on the final integration commit. +- [ ] Ruff lint and format checks pass. +- [ ] CI passes on the exact commit proposed for merge. +- [ ] No unresolved high-severity findings remain. +- [ ] Final commit SHA and test results are recorded in the PR. diff --git a/docs/integration/MODULE_CONTRACTS.md b/docs/integration/MODULE_CONTRACTS.md new file mode 100644 index 0000000..8407ed9 --- /dev/null +++ b/docs/integration/MODULE_CONTRACTS.md @@ -0,0 +1,289 @@ + +# FiberLab Module Integration Contracts + +## 1. Purpose + +This document defines how the physics, visualization, and Streamlit application modules communicate. + +All modules must follow these conventions to ensure smooth integration. + +## 2. General Conventions + +- Python is the implementation language. +- SI units are used internally wherever practical. +- UI input conversions must happen at the application boundary. +- Physics calculations must not depend on Streamlit. +- Visualization functions must receive calculated data rather than independently implementing physics. +- Functions must have clear names, type hints, and docstrings. +- Invalid inputs must raise appropriate exceptions. + +## 3. Physics Module Contract + +Location: `physics/` + +Responsibilities: +- Optical fiber calculations. +- Numerical aperture and acceptance angle. +- Critical angle and total internal reflection. +- Optical attenuation. +- Basic signal propagation and pulse broadening. + +Expected conventions: +- Functions accept numerical inputs. +- Functions return numerical results or structured result objects. +- Internal units must be documented. +- Physics calculations must be independently testable. + +## 4. Visualization Module Contract + +Location: `visualization/` + +Responsibilities: +- Fiber ray visualizations. +- Optical power graphs. +- Signal waveform graphs. +- Pulse broadening visualizations. + +Expected conventions: +- Visualization functions receive inputs or results from the physics module. +- Plotly figures should be returned for Streamlit rendering. +- Visualization code must not duplicate physics calculations. + +## 5. Application Integration Contract + +Location: `app.py`, `pages/`, and `components/` + +Responsibilities: +- Collect user inputs. +- Validate UI values. +- Convert UI units into internal units. +- Call physics functions. +- Pass calculated results to visualization functions. +- Display results and explanations. + +Streamlit must remain in the application and presentation layers. + +## 6. Testing Requirements + +- Physics functions require unit tests. +- Visualization functions require appropriate output tests. +- Application pages require rendering and integration tests. +- All pull requests must pass Ruff and Pytest checks. + +## 7. Collaboration Rules + +- Each team member works on their assigned feature branch. +- Shared interfaces must be agreed upon before implementation. +- Changes must be submitted through pull requests. +- Do not modify another member's assigned modules without coordination. + + +## 8. Physics API β€” Function Specifications + +The physics module provides numerical calculations independently of Streamlit. + +### 8.1 Numerical Aperture + +File: `physics/numerical_aperture.py` + +Function: +```python +def calculate_numerical_aperture(n_core: float, n_cladding: float) -> float: ... +``` + +Inputs: +- `n_core`: Core refractive index (dimensionless). +- `n_cladding`: Cladding refractive index (dimensionless). + +Output: +- Numerical aperture (dimensionless). + +Assumption: +- The v1.0 calculation assumes the external medium is air + (refractive index n_external = 1.0). +- Therefore, the numerical aperture is calculated as: + + NA = sqrt(n_coreΒ² - n_claddingΒ²) + +- Supporting other external media may be considered in a future version. + +### 8.2 Critical Angle + +File: `physics/numerical_aperture.py` + +Function: +```python +def calculate_critical_angle(n_core: float, n_cladding: float) -> float: ... +``` + +Output: +- Critical angle in radians. + +### 8.3 Attenuation + +File: `physics/attenuation.py` + +Function: +```python +def calculate_output_power( + input_power_mw: float, attenuation_db_per_km: float, length_km: float +) -> float: ... +``` + +Output: +- Output optical power in mW. + +### 8.4 Total Power Loss + +File: `physics/attenuation.py` + +Function: +```python +def calculate_total_loss(attenuation_db_per_km: float, length_km: float) -> float: ... +``` + +Output: +- Total attenuation in dB. + +### 8.5 General Requirements + +- Validate all input values. +- Require `n_core > n_cladding > 0`. +- Require positive optical power and fiber length. +- Require non-negative attenuation. +- Raise `ValueError` for invalid inputs. +- Use radians internally for angle calculations. +- Include type hints and docstrings. +- Write independent unit tests for every function. + +These signatures define the proposed v1.0 interface. Implementation remains the responsibility of the physics module owner. + + + +## 9. Visualization API β€” Function Specifications + +The visualization module converts simulation data into interactive visual outputs. + +### 9.1 Fiber Ray Visualization + +File: `visualization/fiber_animation.py` + +Proposed function: + +```python +def create_fiber_ray_figure(ray_data): ... +``` + +### 9.2 Attenuation Graph + +File: `visualization/signal_graphs.py` + +Proposed function: + +```python +def create_attenuation_figure(distance_km, power_mw): ... +``` + +### 9.3 Signal Waveform Graph + +File: `visualization/signal_graphs.py` + +Proposed function: + +```python +def create_signal_waveform_figure(input_signal, output_signal, time_axis): ... +``` + +Output: +- Plotly Figure comparing input and output waveforms. + +Additional requirements: +- Input waveform, output waveform, and time-axis arrays must have compatible lengths. +- The exact data types and units must be agreed upon before implementation. + +### 9.4 General Requirements + +- Use Plotly for interactive graphs. +- Return Plotly Figure objects. +- Do not implement independent physics calculations. +- Receive calculated values or simulation data from the physics module. +- Include axis labels, units, legends and meaningful titles. +- Handle empty or invalid input data appropriately. +- Include tests for figure creation and output structure. + +These signatures are proposed v1.0 interfaces. The visualization module owner must review and agree to them before implementation. + + + +## 10. Application Integration API + +The application layer connects user inputs, physics calculations, and visualizations. + +### 10.1 Responsibilities + +The application layer must: + +1. Collect inputs from the user through Streamlit. +2. Validate input values. +3. Convert UI units into internal units. +4. Call the relevant physics functions. +5. Pass calculated results to visualization functions. +6. Display numerical results, graphs, and explanations. + +### 10.2 Fiber Explorer + +Page: `pages/fiber_explorer.py` + +Integration flow: + +User inputs +β†’ Physics calculations +β†’ Numerical results +β†’ Fiber visualization +β†’ Streamlit display + +Expected calculations: +- Numerical aperture +- Critical angle +- Acceptance angle +- Total internal reflection conditions + +### 10.3 Attenuation Laboratory + +Page: `pages/attenuation_laboratory.py` + +Integration flow: + +Input power and fiber parameters +β†’ Attenuation calculations +β†’ Output power and total loss +β†’ Attenuation graph +β†’ Streamlit display + +### 10.4 Signal Propagation + +Page: `pages/signal_propagation.py` + +Integration flow: + +Input waveform and fiber parameters +β†’ Signal propagation model +β†’ Output waveform +β†’ Input/output comparison graph +β†’ Streamlit display + +### 10.5 Error Handling + +- Display understandable error messages for invalid user inputs. +- Do not allow invalid values to reach physics calculations. +- Handle calculation errors without crashing the application. +- Keep error handling separate from physics calculations. + +### 10.6 Architectural Rules + +- `app.py` controls application navigation. +- `pages/` handles user interaction and orchestration. +- `physics/` handles scientific calculations. +- `visualization/` handles graphical representations. +- `components/` contains reusable UI components. +- Physics and visualization modules must not depend on Streamlit. diff --git a/docs/release/RELEASE_CHECKLIST.md b/docs/release/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..fb2b326 --- /dev/null +++ b/docs/release/RELEASE_CHECKLIST.md @@ -0,0 +1,39 @@ +# FiberLab v1.0 Release Readiness Checklist + +## A. Scope freeze +- [ ] v1.0 feature scope is confirmed. +- [ ] Deferred features are excluded or labelled as future work. +- [ ] Physics and visualization contracts are approved by their owners. +- [ ] No unresolved duplicate or competing implementation PRs remain. + +## B. Code and integration +- [ ] Application pages use the agreed module APIs. +- [ ] Physics and visualization remain independent of Streamlit. +- [ ] Required v1.0 features are integrated. +- [ ] No placeholder is presented as a completed simulation. +- [ ] Error handling and input validation are verified. + +## C. Quality gates +- [ ] Ruff lint passes on the release-candidate commit. +- [ ] Ruff format check passes on the release-candidate commit. +- [ ] Pytest passes on the release-candidate commit. +- [ ] Manual feature walkthrough passes. +- [ ] CI passes on the exact release-candidate commit. +- [ ] No unresolved blocking or high-severity findings remain. + +## D. Documentation +- [ ] README accurately describes implemented features. +- [ ] Installation and launch instructions are verified. +- [ ] Module contracts reflect agreed interfaces. +- [ ] Known limitations are documented. +- [ ] Release notes match shipped implementation. +- [ ] Contributors and ownership are represented accurately. + +## E. Release approval +- [ ] Required PR reviews are complete. +- [ ] Project lead explicitly approves the merge. +- [ ] Main branch is verified after merge. +- [ ] Release tag is created only after final validation. +- [ ] Release artifacts and notes are published. + +Do not mark the release ready until every required gate has evidence. diff --git a/docs/release/RELEASE_NOTES_v1.0.0.md b/docs/release/RELEASE_NOTES_v1.0.0.md new file mode 100644 index 0000000..6b4e029 --- /dev/null +++ b/docs/release/RELEASE_NOTES_v1.0.0.md @@ -0,0 +1,40 @@ +# FiberLab v1.0.0 β€” Release Notes (Draft) + +> Draft only. Confirm every item against the final merged implementation before publishing. + +## Overview + +FiberLab is a Streamlit-based optical-fiber learning and simulation project. Version 1.0 is intended to provide a modular application structure for exploring optical-fiber concepts, with separate physics, visualization, and application layers. + +## Intended v1.0 feature areas + +- Fiber Explorer +- Attenuation Laboratory +- Basic Signal Propagation +- Learning Lab + +## Architecture + +- `physics/` β€” scientific calculations and simulation models. +- `visualization/` β€” Plotly-based visual representations of calculated data. +- `pages/` β€” Streamlit page-level interaction and orchestration. +- `components/` β€” reusable UI components. +- `guardian/` β€” project inspection and integration-support workflows. + +## Validation record + +Add the exact release-candidate commit SHA and verified results before publishing: + +- Ruff lint: +- Ruff format: +- Pytest: +- Manual application walkthrough: +- CI run: + +## Known limitations + +Document functionality that is incomplete, simplified, or deferred. Do not claim dispersion, modulation, BER, SNR, or eye-diagram capabilities unless those models are implemented and validated. + +## Contributors + +Add the final contributor list and contributions after confirming the merged work. diff --git a/docs/review/PR_REVIEW_CHECKLIST.md b/docs/review/PR_REVIEW_CHECKLIST.md new file mode 100644 index 0000000..431c814 --- /dev/null +++ b/docs/review/PR_REVIEW_CHECKLIST.md @@ -0,0 +1,36 @@ +# FiberLab Pull Request Review Checklist + +## Scope and ownership +- [ ] PR has a clear purpose and limited scope. +- [ ] Changed files belong to the contributor's assigned area, or cross-team changes were agreed. +- [ ] PR does not silently replace, duplicate, or conflict with another active PR. +- [ ] Description identifies dependencies and related PRs. + +## Architecture +- [ ] Physics remains independent of Streamlit. +- [ ] Visualization consumes calculated data rather than duplicating physics. +- [ ] Application pages orchestrate inputs, calculations, and display. +- [ ] Shared interfaces match `docs/integration/MODULE_CONTRACTS.md`. +- [ ] Units and angle conventions are consistent with the contract. + +## Code quality and security +- [ ] Names, type hints, and docstrings are clear. +- [ ] Invalid-input handling is explicit and consistent. +- [ ] No unnecessary duplication or unrelated changes. +- [ ] No secrets or credentials are committed. +- [ ] Untrusted PR code is not executed during static inspection. + +## Tests and automation +- [ ] Relevant tests were added or updated. +- [ ] Boundary and invalid-input cases are covered. +- [ ] Ruff lint and format checks pass. +- [ ] Pytest passes. +- [ ] CI results correspond to the latest PR head commit. + +## Review outcome +- [ ] No unresolved blocking findings. +- [ ] Required changes are addressed and re-reviewed. +- [ ] PR head SHA is checked after the final update. +- [ ] Project lead explicitly approves the merge. + +Green CI is necessary but does not replace code review. Do not merge automatically. diff --git a/docs/testing/INTEGRATION_TEST_PLAN.md b/docs/testing/INTEGRATION_TEST_PLAN.md new file mode 100644 index 0000000..d13cca2 --- /dev/null +++ b/docs/testing/INTEGRATION_TEST_PLAN.md @@ -0,0 +1,92 @@ +# FiberLab v1.0 Integration Test Plan + +## 1. Purpose + +Verify that the application, physics, visualization, and Guardian workflows operate together through their agreed interfaces. This is a test plan, not evidence that tests have already been executed. + +## 2. Test environment record + +- Operating system: +- Python version: +- Virtual environment: +- Streamlit version: +- Git commit SHA: +- Dependency installation command: +- Test date: + +## 3. Automated checks + +Run from the repository root: + +```bash +ruff check . +ruff format --check . +python -m pytest -v +``` + +Record exact output, test count, skipped tests, and commit SHA. Report a check as passing only if it completed successfully on the commit under review. + +## 4. Physics unit tests + +Verify valid refractive-index inputs; `n_core > n_cladding > 0`; numerical-aperture and critical-angle results; radians convention; attenuation and output-power calculations; positive power and fiber-length requirements; non-negative attenuation; invalid and non-finite values. + +## 5. Visualization tests + +Verify Plotly Figure return types; expected traces and data; axis titles and units; legends and titles; compatible array lengths; predictable handling of empty and invalid data. + +## 6. Application workflows + +### Fiber Explorer +- [ ] Page renders. +- [ ] Valid inputs produce numerical results. +- [ ] Invalid inputs produce understandable feedback. +- [ ] Ray visualization receives the agreed ray-data structure. + +### Attenuation Laboratory +- [ ] Page renders. +- [ ] Input power, attenuation, and length are validated. +- [ ] Output power and total loss are displayed. +- [ ] Graph data matches calculated values. + +### Signal Propagation +- [ ] Page renders. +- [ ] Input waveform and fiber parameters are validated. +- [ ] Output waveform is generated by the physics layer. +- [ ] Comparison visualization receives compatible arrays. + +### Learning Lab +- [ ] Page renders. +- [ ] Learning content is accessible. +- [ ] Navigation works. + +## 7. Guardian workflows + +- [ ] Local project inspection completes. +- [ ] Diagnostics show file and recommendation details. +- [ ] Integration workspace creation is tested. +- [ ] Candidate selection and integration are tested. +- [ ] Timeout and failure reporting are tested. +- [ ] GitHub PR inspection remains read-only unless explicit comment publishing is selected. +- [ ] Untrusted candidate code is not executed during static PR inspection. + +## 8. Manual regression + +- [ ] Launch application using documented command. +- [ ] Navigate through every page. +- [ ] Exercise valid, invalid, boundary, and empty inputs. +- [ ] Confirm displayed units and labels. +- [ ] Confirm normal workflows do not crash. +- [ ] Confirm Guardian actions match their confirmation text. + +## 9. Results record + +| Check | Result | Commit SHA | Notes | +|---|---|---|---| +| Ruff lint | Not run | β€” | | +| Ruff format | Not run | β€” | | +| Pytest | Not run | β€” | | +| App smoke test | Not run | β€” | | +| Feature walkthrough | Not run | β€” | | +| Guardian workflow | Not run | β€” | | + +Update this table only with observed results. diff --git a/guardian/__init__.py b/guardian/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/guardian/candidate_downloader.py b/guardian/candidate_downloader.py new file mode 100644 index 0000000..a707a89 --- /dev/null +++ b/guardian/candidate_downloader.py @@ -0,0 +1,148 @@ +"""Download selected GitHub pull request files into an isolated workspace.""" + +import os +import re +import shutil +import ssl +from pathlib import Path, PurePosixPath +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + +import certifi + +MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 + + +class CandidateDownloadError(Exception): + """Raised when a candidate file cannot be downloaded.""" + + +def _validate_repository(repository: str) -> None: + if not isinstance(repository, str) or not re.fullmatch( + r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", + repository, + ): + raise ValueError("Repository must use the owner/repository format.") + + +def _validate_commit_sha(commit_sha: str) -> None: + if not isinstance(commit_sha, str) or not re.fullmatch( + r"[0-9a-fA-F]{40}", + commit_sha, + ): + raise ValueError("Commit SHA must be a valid 40-character SHA-1.") + + +def _validate_file_path(file_path: str) -> PurePosixPath: + if not isinstance(file_path, str) or not file_path: + raise ValueError("Candidate file path cannot be empty.") + + if "\\" in file_path: + raise ValueError("Backslashes are not allowed in candidate paths.") + + path = PurePosixPath(file_path) + + if path.is_absolute() or ".." in path.parts or "." in path.parts: + raise ValueError("Candidate file path must stay inside the workspace.") + + return path + + +def download_candidate_files( + repository: str, + commit_sha: str, + selected_files: list[str], + destination: Path, + token: str | None = None, + timeout: int = 15, +) -> list[Path]: + """Download selected files from an exact GitHub commit. + + The destination must not already exist. Downloaded files are stored + without executing them or modifying the source repository. + """ + + _validate_repository(repository) + _validate_commit_sha(commit_sha) + + if not isinstance(selected_files, list) or not selected_files: + raise ValueError("Select at least one candidate file.") + + if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0: + raise ValueError("Timeout must be a positive integer.") + + validated_paths = [_validate_file_path(file_path) for file_path in selected_files] + + if len(set(validated_paths)) != len(validated_paths): + raise ValueError("Duplicate candidate file paths are not allowed.") + + original_destination = Path(destination) + + if original_destination.is_symlink() or original_destination.exists(): + raise FileExistsError("Candidate destination already exists or is a symlink.") + + destination = original_destination.resolve() + + destination.parent.mkdir(parents=True, exist_ok=True) + destination.mkdir() + + auth_token = token or os.getenv("GITHUB_TOKEN") + downloaded_files = [] + + ssl_context = ssl.create_default_context(cafile=certifi.where()) + try: + for relative_path in validated_paths: + encoded_path = quote(relative_path.as_posix(), safe="/") + url = ( + f"https://raw.githubusercontent.com/" + f"{repository}/{commit_sha}/{encoded_path}" + ) + + headers = { + "User-Agent": "FiberLab-Guardian", + "Accept": "application/vnd.github.raw+json", + } + + if auth_token: + headers["Authorization"] = f"Bearer {auth_token}" + + request = Request(url, headers=headers, method="GET") + + try: + with urlopen( + request, + timeout=timeout, + context=ssl_context, + ) as response: + content = response.read(MAX_FILE_SIZE_BYTES + 1) + if len(content) > MAX_FILE_SIZE_BYTES: + raise CandidateDownloadError( + f"Candidate file {relative_path} exceeds the " + f"{MAX_FILE_SIZE_BYTES}-byte download limit." + ) + + except HTTPError as error: + raise CandidateDownloadError( + f"GitHub returned HTTP {error.code} " + f"while downloading {relative_path}." + ) from error + + except (URLError, TimeoutError, OSError) as error: + raise CandidateDownloadError( + f"Unable to download {relative_path} from GitHub." + ) from error + + target = destination.joinpath(*relative_path.parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + + downloaded_files.append(target) + + except Exception: + # Remove an incomplete candidate workspace. + + shutil.rmtree(destination, ignore_errors=True) + raise + + return downloaded_files diff --git a/guardian/error_detector.py b/guardian/error_detector.py new file mode 100644 index 0000000..94b1ac5 --- /dev/null +++ b/guardian/error_detector.py @@ -0,0 +1,92 @@ +"""Classify and explain findings produced by FiberLab Guardian.""" + +from dataclasses import dataclass + +from guardian.inspector import InspectionFinding + + +@dataclass +class Diagnostic: + """Represent an actionable Guardian diagnostic.""" + + status: str + severity: str + file: str + message: str + recommendation: str + + +def classify_finding(finding: InspectionFinding) -> Diagnostic: + """Convert an inspection finding into an actionable diagnostic.""" + if finding.status == "PASS": + return Diagnostic( + "PASS", + "INFO", + finding.file, + finding.message, + "No action required.", + ) + + if finding.status == "PENDING": + return Diagnostic( + "PENDING", + "WAITING", + finding.file, + finding.message, + "Wait for the module owner to submit the implementation.", + ) + + message = finding.message.lower() + + recommendations = ( + ( + "unable to parse module", + "Fix the Python syntax error in this file.", + ), + ( + "missing required function", + "Implement the missing function according to MODULE_CONTRACTS.md.", + ), + ( + "incompatible signature", + "Update the function parameters to match the agreed API contract.", + ), + ( + "broken internal import", + "Check the import path and ensure the target module exists.", + ), + ( + "missing imported symbol", + "Define the missing function or class in the imported module.", + ), + ( + "streamlit import", + "Move Streamlit-dependent code into the application layer.", + ), + ( + "belongs in", + "Move the function to the module specified by the integration contract.", + ), + ) + + recommendation = "Review the finding and compare the implementation with the integration contract." + + for keyword, action in recommendations: + if keyword in message: + recommendation = action + break + + return Diagnostic( + "FAIL", + "HIGH", + finding.file, + finding.message, + recommendation, + ) + + +def analyze_findings( + findings: list[InspectionFinding], +) -> list[Diagnostic]: + """Classify all findings produced by the Code Inspector.""" + return [classify_finding(finding) for finding in findings] diff --git a/guardian/github_client.py b/guardian/github_client.py new file mode 100644 index 0000000..fcc82ed --- /dev/null +++ b/guardian/github_client.py @@ -0,0 +1,183 @@ +"""Read-only GitHub API client for FiberLab Guardian.""" + +import json +import os +import re +import ssl +from dataclasses import dataclass +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +import certifi + + +@dataclass +class PullRequestInfo: + """Metadata describing a GitHub pull request.""" + + number: int + title: str + state: str + base_branch: str + head_branch: str + head_sha: str + head_repo: str | None + + +@dataclass +class ChangedFile: + """File changed by a GitHub pull request.""" + + path: str + status: str + additions: int + deletions: int + changes: int + + +class GitHubAPIError(Exception): + """Raised when a GitHub API request fails.""" + + +class GitHubClient: + """Read-only client for retrieving GitHub pull request data.""" + + API_URL = "https://api.github.com" + API_VERSION = "2022-11-28" + + def __init__(self, token: str | None = None, timeout: int = 15): + self.token = token or os.getenv("GITHUB_TOKEN") + self.timeout = timeout + + @staticmethod + def _validate_repository(repository: str) -> None: + if not isinstance(repository, str) or not re.fullmatch( + r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", + repository, + ): + raise ValueError("Repository must use the owner/repository format.") + + @staticmethod + def _validate_pr_number(pr_number: int) -> None: + if ( + isinstance(pr_number, bool) + or not isinstance(pr_number, int) + or pr_number <= 0 + ): + raise ValueError("Pull request number must be a positive integer.") + + def _request(self, endpoint: str) -> dict | list: + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": self.API_VERSION, + "User-Agent": "FiberLab-Guardian", + } + + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + + request = Request( + f"{self.API_URL}{endpoint}", + headers=headers, + method="GET", + ) + + ssl_context = ssl.create_default_context(cafile=certifi.where()) + + try: + with urlopen( + request, + timeout=self.timeout, + context=ssl_context, + ) as response: + return json.loads(response.read().decode("utf-8")) + + except HTTPError as error: + if error.code == 404: + raise GitHubAPIError("GitHub resource was not found.") from error + + if error.code == 401: + raise GitHubAPIError("GitHub authentication failed.") from error + + if error.code == 403: + raise GitHubAPIError( + "GitHub access was denied or rate-limited." + ) from error + + raise GitHubAPIError(f"GitHub API returned HTTP {error.code}.") from error + + except (URLError, TimeoutError, OSError) as error: + raise GitHubAPIError("Unable to connect to the GitHub API.") from error + + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise GitHubAPIError("GitHub returned an invalid JSON response.") from error + + def get_pull_request( + self, + repository: str, + pr_number: int, + ) -> PullRequestInfo: + """Retrieve pull request metadata.""" + + self._validate_repository(repository) + self._validate_pr_number(pr_number) + + data = self._request(f"/repos/{repository}/pulls/{pr_number}") + + if not isinstance(data, dict): + raise GitHubAPIError("Unexpected pull request response.") + + head = data.get("head") or {} + base = data.get("base") or {} + head_repo = head.get("repo") + + return PullRequestInfo( + number=data["number"], + title=data["title"], + state=data["state"], + base_branch=base["ref"], + head_branch=head["ref"], + head_sha=head["sha"], + head_repo=( + head_repo.get("full_name") if isinstance(head_repo, dict) else None + ), + ) + + def list_changed_files( + self, + repository: str, + pr_number: int, + ) -> list[ChangedFile]: + """Retrieve every file changed by a pull request.""" + + self._validate_repository(repository) + self._validate_pr_number(pr_number) + + files = [] + page = 1 + + while True: + data = self._request( + f"/repos/{repository}/pulls/{pr_number}/files?per_page=100&page={page}" + ) + + if not isinstance(data, list): + raise GitHubAPIError("Unexpected changed-files response.") + + for item in data: + files.append( + ChangedFile( + path=item["filename"], + status=item["status"], + additions=item["additions"], + deletions=item["deletions"], + changes=item["changes"], + ) + ) + + if len(data) < 100: + break + + page += 1 + + return files diff --git a/guardian/github_commenter.py b/guardian/github_commenter.py new file mode 100644 index 0000000..b860fbe --- /dev/null +++ b/guardian/github_commenter.py @@ -0,0 +1,223 @@ +"""Publish FiberLab Guardian reports as GitHub pull request comments.""" + +import json +import os +import re +import ssl +from dataclasses import dataclass +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import Request, urlopen + +import certifi + +from guardian.reporter import GuardianReport + + +@dataclass +class GitHubCommentResult: + """Details of a successfully published GitHub comment.""" + + comment_id: int + comment_url: str + + +class GitHubCommentError(Exception): + """Raised when a Guardian comment cannot be published.""" + + +def _validate_repository(repository: str) -> None: + """Validate the GitHub owner/repository format.""" + if not isinstance(repository, str) or not re.fullmatch( + r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", + repository, + ): + raise ValueError("Repository must use the owner/repository format.") + + +def _validate_pr_number(pr_number: int) -> None: + """Validate a positive pull request number.""" + if isinstance(pr_number, bool) or not isinstance(pr_number, int) or pr_number <= 0: + raise ValueError("Pull request number must be a positive integer.") + + +def _validate_timeout(timeout: int) -> None: + """Validate the HTTP request timeout.""" + if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0: + raise ValueError("Timeout must be a positive integer.") + + +def _validate_token(token: str | None) -> str: + """Retrieve and validate the GitHub authentication token.""" + auth_token = os.getenv("GITHUB_TOKEN") if token is None else token + + if not isinstance(auth_token, str) or not auth_token.strip(): + raise ValueError( + "A valid GitHub token is required. " + "Set the GITHUB_TOKEN environment variable." + ) + + if auth_token != auth_token.strip(): + raise ValueError("GitHub token must not contain surrounding whitespace.") + + return auth_token + + +def _validate_comment_url(comment_url: str) -> bool: + """Check that the returned URL belongs to GitHub.com over HTTPS.""" + if not isinstance(comment_url, str): + return False + + try: + parsed_url = urlsplit(comment_url) + + return ( + parsed_url.scheme == "https" + and parsed_url.hostname == "github.com" + and parsed_url.port in (None, 443) + and parsed_url.username is None + and parsed_url.password is None + and parsed_url.path.startswith("/") + ) + except ValueError: + return False + + +def format_guardian_comment(report: GuardianReport) -> str: + """Format a Guardian report as a readable GitHub Markdown comment.""" + + lines = [ + "## πŸ›‘οΈ FiberLab Guardian Report", + "", + f"**Overall Status: {report.overall_status}**", + "", + "### Inspection Summary", + "", + f"- Passed: **{report.passed}**", + f"- Failed: **{report.failed}**", + f"- Pending: **{report.pending}**", + "", + "### Diagnostic Findings", + ] + + if not report.diagnostics: + lines.extend(["", "No diagnostic findings were generated."]) + + for diagnostic in report.diagnostics: + lines.extend( + [ + "", + f"#### {diagnostic.status}: `{diagnostic.file}`", + f"- **Severity:** {diagnostic.severity}", + f"- **Finding:** {diagnostic.message}", + f"- **Recommendation:** {diagnostic.recommendation}", + ] + ) + + lines.extend( + [ + "", + "---", + ( + "_Generated by FiberLab Guardian. This is an automated " + "inspection report, not an approval or merge decision._" + ), + ] + ) + + comment = "\n".join(lines) + + if len(comment) > 60_000: + raise ValueError( + "Guardian report is too long to publish as one GitHub comment." + ) + + return comment + + +def publish_guardian_comment( + repository: str, + pr_number: int, + report: GuardianReport, + token: str | None = None, + timeout: int = 15, +) -> GitHubCommentResult: + """Publish a Guardian report as a top-level GitHub PR comment.""" + + _validate_repository(repository) + _validate_pr_number(pr_number) + _validate_timeout(timeout) + + auth_token = _validate_token(token) + + comment_body = format_guardian_comment(report) + payload = json.dumps({"body": comment_body}).encode("utf-8") + + request = Request( + f"https://api.github.com/repos/{repository}/issues/{pr_number}/comments", + data=payload, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {auth_token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "FiberLab-Guardian", + "Content-Type": "application/json", + }, + method="POST", + ) + + ssl_context = ssl.create_default_context(cafile=certifi.where()) + + try: + with urlopen( + request, + timeout=timeout, + context=ssl_context, + ) as response: + data = json.loads(response.read().decode("utf-8")) + + except HTTPError as error: + response_body = error.read().decode("utf-8", errors="replace") + + try: + error_data = json.loads(response_body) + + if isinstance(error_data, dict): + github_message = error_data.get( + "message", + "Unknown GitHub error.", + ) + else: + github_message = "GitHub returned an unexpected error response." + + except json.JSONDecodeError: + github_message = "GitHub returned an unreadable error response." + + message = ( + f"GitHub returned HTTP {error.code} while publishing: {github_message}" + ) + + raise GitHubCommentError(message) from error + + except (URLError, TimeoutError, OSError) as error: + raise GitHubCommentError( + "Unable to connect to GitHub while publishing the comment." + ) from error + + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise GitHubCommentError( + "GitHub returned an invalid comment response." + ) from error + + if ( + not isinstance(data, dict) + or not isinstance(data.get("id"), int) + or isinstance(data.get("id"), bool) + or not _validate_comment_url(data.get("html_url")) + ): + raise GitHubCommentError("GitHub returned an unexpected comment response.") + + return GitHubCommentResult( + comment_id=data["id"], + comment_url=data["html_url"], + ) diff --git a/guardian/github_integration.py b/guardian/github_integration.py new file mode 100644 index 0000000..c9a52d2 --- /dev/null +++ b/guardian/github_integration.py @@ -0,0 +1,73 @@ +"""Connect GitHub pull requests to the FiberLab Guardian pipeline.""" + +from dataclasses import dataclass +from pathlib import Path + +from guardian.candidate_downloader import download_candidate_files +from guardian.github_client import ChangedFile, GitHubClient, PullRequestInfo +from guardian.reporter import GuardianReport +from guardian.runner import run_guardian + + +@dataclass +class PullRequestInspectionResult: + """Store the results of inspecting a GitHub pull request.""" + + pull_request: PullRequestInfo + changed_files: list[ChangedFile] + downloaded_files: list[Path] + report: GuardianReport + + +def inspect_pull_request( + repository: str, + pr_number: int, + project_root: Path, + candidate_workspace: Path, + integration_workspace: Path, + token: str | None = None, +) -> PullRequestInspectionResult: + """Download and statically inspect the files changed by a GitHub PR.""" + + client = GitHubClient(token=token) + + pull_request = client.get_pull_request(repository, pr_number) + changed_files = client.list_changed_files(repository, pr_number) + + # Deleted files have no current file content to download. + downloadable_files = [ + changed_file + for changed_file in changed_files + if changed_file.status != "removed" + ] + + if not downloadable_files: + raise ValueError("The pull request contains no downloadable files.") + + selected_paths = [changed_file.path for changed_file in downloadable_files] + + # A PR's head commit may belong to a contributor's fork. + download_repository = pull_request.head_repo or repository + + downloaded_files = download_candidate_files( + repository=download_repository, + commit_sha=pull_request.head_sha, + selected_files=selected_paths, + destination=candidate_workspace, + token=token, + ) + + report = run_guardian( + project_root=project_root, + candidate_root=candidate_workspace, + selected_files=selected_paths, + workspace_root=integration_workspace, + run_quality_checks=False, + ) + + return PullRequestInspectionResult( + pull_request=pull_request, + changed_files=changed_files, + downloaded_files=downloaded_files, + report=report, + ) diff --git a/guardian/inspector.py b/guardian/inspector.py new file mode 100644 index 0000000..30fed8e --- /dev/null +++ b/guardian/inspector.py @@ -0,0 +1,350 @@ +"""Inspect FiberLab modules against their agreed integration contracts.""" + +import ast +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class InspectionFinding: + """Represent one result from a Guardian inspection.""" + + status: str + file: str + message: str + + +MODULE_CONTRACTS = { + "physics/numerical_aperture.py": { + "calculate_numerical_aperture": ("n_core", "n_cladding"), + "calculate_critical_angle": ("n_core", "n_cladding"), + }, + "physics/attenuation.py": { + "calculate_output_power": ( + "input_power_mw", + "attenuation_db_per_km", + "length_km", + ), + "calculate_total_loss": ( + "attenuation_db_per_km", + "length_km", + ), + }, + "visualization/fiber_animation.py": { + "create_fiber_ray_figure": ("ray_data",), + }, + "visualization/signal_graphs.py": { + "create_attenuation_figure": ("distance_km", "power_mw"), + "create_signal_waveform_figure": ( + "input_signal", + "output_signal", + "time_axis", + ), + }, +} + + +def inspect_project(project_root: Path) -> list[InspectionFinding]: + """Inspect physics and visualization modules against their contracts.""" + findings = [] + expected_locations = { + function_name: relative_path + for relative_path, functions in MODULE_CONTRACTS.items() + for function_name in functions + } + + for relative_path, expected_functions in MODULE_CONTRACTS.items(): + file_path = project_root / relative_path + + if not file_path.exists(): + findings.append( + InspectionFinding( + "PENDING", + relative_path, + "Module has not been implemented yet.", + ) + ) + continue + + try: + source = file_path.read_text(encoding="utf-8") + module = ast.parse(source, filename=str(file_path)) + except (SyntaxError, UnicodeDecodeError) as error: + findings.append( + InspectionFinding( + "FAIL", + relative_path, + f"Unable to parse module: {error}", + ) + ) + continue + + definitions = { + node.name: node + for node in module.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + for function_name, expected_parameters in expected_functions.items(): + function = definitions.get(function_name) + + if function is None: + findings.append( + InspectionFinding( + "FAIL", + relative_path, + f"Missing required function: {function_name}.", + ) + ) + continue + + actual_parameters = tuple(argument.arg for argument in function.args.args) + + if ( + actual_parameters != expected_parameters + or function.args.posonlyargs + or function.args.vararg + or function.args.kwarg + or function.args.kwonlyargs + ): + findings.append( + InspectionFinding( + "FAIL", + relative_path, + ( + f"{function_name} has an incompatible signature. " + f"Expected {expected_parameters}; " + f"got {actual_parameters}." + ), + ) + ) + else: + findings.append( + InspectionFinding( + "PASS", + relative_path, + f"{function_name} matches its expected signature.", + ) + ) + + # Check for contract functions implemented in the wrong module. + for package in ("physics", "visualization"): + package_path = project_root / package + + if not package_path.exists(): + continue + + for file_path in package_path.rglob("*.py"): + try: + module = ast.parse( + file_path.read_text(encoding="utf-8"), + filename=str(file_path), + ) + except (SyntaxError, UnicodeDecodeError): + continue + + relative_path = file_path.relative_to(project_root).as_posix() + + for node in module.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + + expected_path = expected_locations.get(node.name) + + if expected_path and relative_path != expected_path: + findings.append( + InspectionFinding( + "FAIL", + relative_path, + ( + f"{node.name} belongs in {expected_path}, " + f"not {relative_path}." + ), + ) + ) + + # Physics and visualization must remain independent of Streamlit. + for package in ("physics", "visualization"): + package_path = project_root / package + + if not package_path.exists(): + continue + + for file_path in package_path.rglob("*.py"): + relative_path = file_path.relative_to(project_root).as_posix() + + try: + module = ast.parse( + file_path.read_text(encoding="utf-8"), + filename=str(file_path), + ) + except (SyntaxError, UnicodeDecodeError): + continue + + for node in ast.walk(module): + imports_streamlit = ( + isinstance(node, ast.Import) + and any(alias.name == "streamlit" for alias in node.names) + ) or (isinstance(node, ast.ImportFrom) and node.module == "streamlit") + + if imports_streamlit: + findings.append( + InspectionFinding( + "FAIL", + relative_path, + "Streamlit import is not allowed in this package.", + ) + ) + findings.extend(inspect_internal_imports(project_root)) + findings.extend(inspect_package_syntax(project_root, findings)) + + return findings + + +def inspect_internal_imports(project_root: Path) -> list[InspectionFinding]: + """Detect broken internal module and symbol imports.""" + findings = [] + project_packages = ("physics", "visualization", "components", "pages", "guardian") + + for package in project_packages: + package_path = project_root / package + + if not package_path.exists(): + continue + + for file_path in package_path.rglob("*.py"): + relative_path = file_path.relative_to(project_root).as_posix() + + try: + module = ast.parse( + file_path.read_text(encoding="utf-8"), + filename=str(file_path), + ) + except (SyntaxError, UnicodeDecodeError): + continue + + for node in ast.walk(module): + if not isinstance(node, ast.ImportFrom) or not node.module: + continue + + imported_module = node.module + root_package = imported_module.split(".")[0] + + if root_package not in project_packages: + continue + + module_path = project_root.joinpath(*imported_module.split(".")) + + target_file = module_path.with_suffix(".py") + target_init = module_path / "__init__.py" + + if target_file.exists(): + pass + elif target_init.exists(): + target_file = target_init + else: + findings.append( + InspectionFinding( + "FAIL", + relative_path, + f"Broken internal import: {imported_module}.", + ) + ) + continue + + try: + target_source = target_file.read_text(encoding="utf-8") + target_ast = ast.parse( + target_source, + filename=str(target_file), + ) + except (SyntaxError, UnicodeDecodeError): + continue + + available_symbols = { + item.name + for item in target_ast.body + if isinstance( + item, + ( + ast.FunctionDef, + ast.AsyncFunctionDef, + ast.ClassDef, + ), + ) + } + + available_symbols.update( + item.targets[0].id + for item in target_ast.body + if isinstance(item, ast.Assign) + and len(item.targets) == 1 + and isinstance(item.targets[0], ast.Name) + ) + + for alias in node.names: + if alias.name == "*": + continue + + # Allow imports of submodules. + submodule_path = module_path / f"{alias.name}.py" + submodule_package = module_path / alias.name / "__init__.py" + + if ( + alias.name in available_symbols + or submodule_path.exists() + or submodule_package.exists() + ): + continue + + findings.append( + InspectionFinding( + "FAIL", + relative_path, + ( + f"Missing imported symbol '{alias.name}' " + f"in module '{imported_module}'." + ), + ) + ) + + return findings + + +def inspect_package_syntax( + project_root: Path, + existing_findings: list[InspectionFinding], +) -> list[InspectionFinding]: + """Detect syntax errors across all application Python packages.""" + findings = [] + + already_reported = { + finding.file + for finding in existing_findings + if finding.status == "FAIL" and "Unable to parse module" in finding.message + } + + for package in ("physics", "visualization", "components", "pages", "guardian"): + package_path = project_root / package + + if not package_path.exists(): + continue + + for file_path in package_path.rglob("*.py"): + relative_path = file_path.relative_to(project_root).as_posix() + + if relative_path in already_reported: + continue + + try: + source = file_path.read_text(encoding="utf-8") + ast.parse(source, filename=str(file_path)) + except (SyntaxError, UnicodeDecodeError) as error: + findings.append( + InspectionFinding( + "FAIL", + relative_path, + f"Unable to parse module: {error}", + ) + ) + return findings diff --git a/guardian/integration_engine.py b/guardian/integration_engine.py new file mode 100644 index 0000000..188a650 --- /dev/null +++ b/guardian/integration_engine.py @@ -0,0 +1,152 @@ +"""Build isolated workspaces for FiberLab Guardian integration checks.""" + +import os +import shutil +from pathlib import Path + +IGNORED_DIRECTORIES = { + ".git", + ".venv", + "__pycache__", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", + "htmlcov", +} + +IGNORED_FILES = { + ".coverage", +} + + +def create_integration_workspace( + source_root: Path, + destination: Path, +) -> Path: + """Copy a project into an isolated workspace, excluding development files.""" + source_root = Path(source_root).resolve() + original_destination = Path(destination) + + # Reject existing destinations and symlinks before resolving. + if original_destination.is_symlink() or original_destination.exists(): + raise FileExistsError( + f"Integration workspace already exists: {original_destination}" + ) + + destination = original_destination.resolve() + + if not source_root.exists(): + raise FileNotFoundError(f"Source directory does not exist: {source_root}") + + if not source_root.is_dir(): + raise NotADirectoryError(f"Source path is not a directory: {source_root}") + + if destination.is_relative_to(source_root): + raise ValueError("Integration workspace must be outside the source project.") + + # Check only files and directories that will actually be copied. + for current_root, directory_names, file_names in os.walk( + source_root, + topdown=True, + followlinks=False, + ): + current_path = Path(current_root) + + # Exclude development directories before inspecting their contents. + directory_names[:] = [ + name for name in directory_names if name not in IGNORED_DIRECTORIES + ] + + # Reject symlink directories that would otherwise be copied. + for name in directory_names: + path = current_path / name + + if path.is_symlink(): + raise ValueError( + f"Symlinks are not allowed in the source project: {path}" + ) + + # Reject symlink files that would otherwise be copied. + for name in file_names: + if name in IGNORED_FILES or name.endswith(".pyc"): + continue + + path = current_path / name + + if path.is_symlink(): + raise ValueError( + f"Symlinks are not allowed in the source project: {path}" + ) + + def ignore_development_files( + directory: str, + names: list[str], + ) -> set[str]: + """Exclude environments, caches, and coverage files.""" + return { + name + for name in names + if name in IGNORED_DIRECTORIES + or name in IGNORED_FILES + or name.endswith(".pyc") + } + + shutil.copytree( + source_root, + destination, + ignore=ignore_development_files, + ) + + return destination + + +def integrate_candidate_modules( + candidate_root: Path, + workspace_root: Path, + selected_files: list[str], +) -> list[Path]: + """Copy selected candidate modules into an isolated integration workspace.""" + candidate_root = Path(candidate_root).resolve() + workspace_root = Path(workspace_root).resolve() + + if not candidate_root.is_dir(): + raise FileNotFoundError(f"Candidate directory does not exist: {candidate_root}") + + if not workspace_root.is_dir(): + raise FileNotFoundError( + f"Integration workspace does not exist: {workspace_root}" + ) + + integrated_files = [] + + for relative_path in selected_files: + relative_path = Path(relative_path) + + if relative_path.is_absolute() or ".." in relative_path.parts: + raise ValueError(f"Unsafe candidate path: {relative_path}") + + # Check original path components before resolving symlinks. + candidate_path = candidate_root / relative_path + current_path = candidate_root + + for part in relative_path.parts: + current_path = current_path / part + + if current_path.is_symlink(): + raise ValueError(f"Symlinks are not allowed: {relative_path}") + + source_file = candidate_path.resolve() + + if not source_file.is_relative_to(candidate_root): + raise ValueError(f"Unsafe candidate path: {relative_path}") + + if not source_file.is_file(): + raise FileNotFoundError(f"Candidate file does not exist: {source_file}") + + destination_file = workspace_root / relative_path + destination_file.parent.mkdir(parents=True, exist_ok=True) + + shutil.copy2(source_file, destination_file) + integrated_files.append(destination_file) + + return integrated_files diff --git a/guardian/reporter.py b/guardian/reporter.py new file mode 100644 index 0000000..b701c97 --- /dev/null +++ b/guardian/reporter.py @@ -0,0 +1,85 @@ +"""Generate consolidated reports from FiberLab Guardian inspections.""" + +from dataclasses import dataclass +from pathlib import Path + +from guardian.error_detector import Diagnostic, analyze_findings +from guardian.inspector import inspect_project + + +@dataclass +class GuardianReport: + """Store the complete results of a Guardian inspection.""" + + diagnostics: list[Diagnostic] + passed: int + failed: int + pending: int + + @property + def overall_status(self) -> str: + """Determine the overall inspection status.""" + if self.failed: + return "FAIL" + + if self.pending: + return "PENDING" + + return "PASS" + + +def generate_report(project_root: Path) -> GuardianReport: + """Run inspection, classify findings, and generate a report.""" + findings = inspect_project(project_root) + diagnostics = analyze_findings(findings) + + return GuardianReport( + diagnostics=diagnostics, + passed=sum(item.status == "PASS" for item in diagnostics), + failed=sum(item.status == "FAIL" for item in diagnostics), + pending=sum(item.status == "PENDING" for item in diagnostics), + ) + + +def format_report(report: GuardianReport) -> str: + """Format a Guardian report into a readable diagnostic summary.""" + + lines = [ + "FIBERLAB GUARDIAN REPORT", + "=" * 40, + "", + f"Overall Status: {report.overall_status}", + "", + "SUMMARY", + "-" * 40, + f"Passed: {report.passed}", + f"Failed: {report.failed}", + f"Pending: {report.pending}", + "", + "DIAGNOSTICS", + "-" * 40, + ] + + if not report.diagnostics: + lines.append("No diagnostics available.") + else: + for index, diagnostic in enumerate(report.diagnostics, start=1): + lines.extend( + [ + "", + f"{index}. [{diagnostic.status}] {diagnostic.file}", + f" Severity: {diagnostic.severity}", + f" Finding: {diagnostic.message}", + f" Recommendation: {diagnostic.recommendation}", + ] + ) + + lines.extend( + [ + "", + "=" * 40, + "End of Guardian Report", + ] + ) + + return "\n".join(lines) diff --git a/guardian/runner.py b/guardian/runner.py new file mode 100644 index 0000000..bac3435 --- /dev/null +++ b/guardian/runner.py @@ -0,0 +1,92 @@ +"""Coordinate the FiberLab Guardian integration workflow.""" + +from pathlib import Path + +from guardian.error_detector import Diagnostic, analyze_findings +from guardian.inspector import inspect_project +from guardian.integration_engine import ( + create_integration_workspace, + integrate_candidate_modules, +) +from guardian.reporter import ( + GuardianReport, +) +from guardian.test_runner import CommandResult, run_command + + +def run_guardian( + project_root: Path, + candidate_root: Path, + selected_files: list[str], + workspace_root: Path, + run_quality_checks: bool = True, + timeout_seconds: int = 120, +) -> GuardianReport: + """Integrate candidate modules, inspect the workspace and run quality checks.""" + + create_integration_workspace(project_root, workspace_root) + + integrate_candidate_modules( + candidate_root, + workspace_root, + selected_files, + ) + + findings = inspect_project(workspace_root) + diagnostics = analyze_findings(findings) + + passed = sum(item.status == "PASS" for item in diagnostics) + failed = sum(item.status == "FAIL" for item in diagnostics) + pending = sum(item.status == "PENDING" for item in diagnostics) + + if failed: + return GuardianReport( + diagnostics=diagnostics, + passed=passed, + failed=failed, + pending=pending, + ) + + if run_quality_checks: + commands = [ + ["ruff", "check", "."], + ["python", "-m", "pytest", "-v"], + ] + + for command in commands: + result = run_command( + command, + workspace_root, + timeout_seconds, + ) + + diagnostic = _command_diagnostic(result) + diagnostics.append(diagnostic) + + if result.status == "PASS": + passed += 1 + else: + failed += 1 + + return GuardianReport( + diagnostics=diagnostics, + passed=passed, + failed=failed, + pending=pending, + ) + + +def _command_diagnostic(result: CommandResult): + """Convert an automated command result into a Guardian diagnostic.""" + + return Diagnostic( + status="PASS" if result.status == "PASS" else "FAIL", + severity="INFO" if result.status == "PASS" else "HIGH", + file="integration workspace", + message=(f"Automated check: {result.status} (exit code: {result.return_code})"), + recommendation=( + "No action required." + if result.status == "PASS" + else result.stderr or result.stdout or "Review the command failure." + ), + ) diff --git a/guardian/test_runner.py b/guardian/test_runner.py new file mode 100644 index 0000000..5cd485d --- /dev/null +++ b/guardian/test_runner.py @@ -0,0 +1,66 @@ +"""Run automated quality checks for FiberLab Guardian.""" + +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class CommandResult: + """Store the result of an executed command.""" + + status: str + return_code: int | None + stdout: str + stderr: str + + +def run_command( + command: list[str], + workspace_root: Path, + timeout_seconds: int = 120, +) -> CommandResult: + """Execute a command inside the isolated integration workspace.""" + try: + completed = subprocess.run( + command, + cwd=workspace_root, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + + status = "PASS" if completed.returncode == 0 else "FAIL" + + return CommandResult( + status=status, + return_code=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + + except subprocess.TimeoutExpired as error: + stdout = error.stdout or "" + stderr = error.stderr or "" + + if isinstance(stdout, bytes): + stdout = stdout.decode(errors="replace") + + if isinstance(stderr, bytes): + stderr = stderr.decode(errors="replace") + + return CommandResult( + status="TIMEOUT", + return_code=None, + stdout=stdout, + stderr=stderr or "Command exceeded its execution timeout.", + ) + + except OSError as error: + return CommandResult( + status="ERROR", + return_code=None, + stdout="", + stderr=str(error), + ) diff --git a/pages/__init__.py b/pages/__init__.py new file mode 100644 index 0000000..9f734c8 --- /dev/null +++ b/pages/__init__.py @@ -0,0 +1 @@ +"""FiberLab page modules.""" diff --git a/pages/attenuation_laboratory.py b/pages/attenuation_laboratory.py new file mode 100644 index 0000000..8f01d53 --- /dev/null +++ b/pages/attenuation_laboratory.py @@ -0,0 +1,17 @@ +"""Attenuation Laboratory page.""" + +from components.page_layout import render_page_header, render_workspace + + +def render() -> None: + """Render the Attenuation Laboratory page.""" + render_page_header( + title="Attenuation Laboratory", + caption="Investigate how optical loss shapes a transmission system.", + description=( + "Study optical power loss, attenuation profiles, " + "and their impact on signal strength over fiber length." + ), + ) + + render_workspace("Interactive attenuation tools are coming soon.") diff --git a/pages/fiber_explorer.py b/pages/fiber_explorer.py new file mode 100644 index 0000000..6add394 --- /dev/null +++ b/pages/fiber_explorer.py @@ -0,0 +1,17 @@ +"""Fiber Explorer page.""" + +from components.page_layout import render_page_header, render_workspace + + +def render() -> None: + """Render the Fiber Explorer page.""" + render_page_header( + title="Fiber Explorer", + caption="Inspect the building blocks of an optical fiber.", + description=( + "Explore fiber geometry, refractive indices, " + "and the conditions that allow light to travel through an optical fiber." + ), + ) + + render_workspace("Interactive fiber exploration tools are coming soon.") diff --git a/pages/guardian_dashboard.py b/pages/guardian_dashboard.py new file mode 100644 index 0000000..fb605ab --- /dev/null +++ b/pages/guardian_dashboard.py @@ -0,0 +1,339 @@ +"""FiberLab Guardian dashboard.""" + +import os +import tempfile +from pathlib import Path + +import streamlit as st + +from components.page_layout import render_page_header +from guardian.candidate_downloader import CandidateDownloadError +from guardian.github_client import GitHubAPIError +from guardian.github_commenter import ( + GitHubCommentError, + format_guardian_comment, + publish_guardian_comment, +) +from guardian.github_integration import inspect_pull_request +from guardian.reporter import format_report, generate_report + + +def render_report_findings(report) -> None: + """Render Guardian diagnostic findings.""" + + if not report.diagnostics: + st.info("No diagnostic findings were generated.") + return + + status_icons = { + "PASS": "βœ…", + "FAIL": "❌", + "PENDING": "⏳", + } + + for diagnostic in report.diagnostics: + icon = status_icons.get(diagnostic.status, "ℹ️") + + with st.expander( + f"{icon} {diagnostic.file} β€” {diagnostic.status}", + expanded=diagnostic.status == "FAIL", + ): + st.write(f"**Severity:** {diagnostic.severity}") + st.write(f"**Finding:** {diagnostic.message}") + st.write(f"**Recommendation:** {diagnostic.recommendation}") + + +def render_report_summary(report) -> None: + """Render the Guardian report status and summary metrics.""" + + if report.overall_status == "PASS": + st.success("Overall Status: PASS") + elif report.overall_status == "FAIL": + st.error("Overall Status: FAIL") + else: + st.warning("Overall Status: PENDING") + + passed_column, failed_column, pending_column = st.columns(3) + + passed_column.metric("Passed", report.passed) + failed_column.metric("Failed", report.failed) + pending_column.metric("Pending", report.pending) + + +def render_local_inspection() -> None: + """Render the local project inspection workflow.""" + + st.subheader("Local Project Inspection") + + st.write( + "Run Guardian against the current FiberLab project to check " + "module contracts, imports, syntax, and code structure." + ) + + st.info( + "Local inspection only. Guardian does not execute downloaded " + "pull-request code or modify project files." + ) + + if st.button( + "Run Guardian Inspection", + type="primary", + use_container_width=True, + key="guardian_local_inspection", + ): + project_root = Path(__file__).resolve().parents[1] + + with st.spinner("Inspecting FiberLab project..."): + try: + report = generate_report(project_root) + + st.session_state["guardian_report"] = report + st.session_state["guardian_report_text"] = format_report(report) + st.session_state["guardian_error"] = None + + except (OSError, ValueError, RuntimeError) as error: + st.session_state["guardian_error"] = str(error) + st.session_state.pop("guardian_report", None) + st.session_state.pop("guardian_report_text", None) + + error_message = st.session_state.get("guardian_error") + + if error_message: + st.error(f"Guardian inspection failed: {error_message}") + + report = st.session_state.get("guardian_report") + + if report is None: + st.caption("Run a local inspection to see its results.") + return + + st.divider() + st.subheader("Local Inspection Results") + + render_report_summary(report) + + st.subheader("Diagnostic Findings") + render_report_findings(report) + + st.subheader("Full Guardian Report") + + report_text = st.session_state["guardian_report_text"] + + st.download_button( + label="Download Inspection Report", + data=report_text, + file_name="fiberlab_guardian_report.txt", + mime="text/plain", + use_container_width=True, + key="guardian_local_download", + ) + + with st.expander("View full text report"): + st.code(report_text, language="text") + + +def render_github_inspection() -> None: + """Render GitHub pull request inspection and reporting.""" + + st.divider() + st.subheader("GitHub Pull Request Inspection") + + st.write( + "Inspect a pull request against the current FiberLab project. " + "Guardian downloads the changed files and checks them in an " + "isolated temporary integration workspace." + ) + + st.warning( + "PR inspection is static only. Downloaded pull-request code " + "is not executed. Publishing a report requires a separate " + "explicit action." + ) + + repository = st.text_input( + "GitHub Repository", + value="CodingBunny06/FiberLab", + help="Use the owner/repository format.", + key="guardian_github_repository", + ).strip() + + pr_number = st.number_input( + "Pull Request Number", + min_value=1, + step=1, + value=1, + key="guardian_github_pr_number", + ) + + if st.button( + "Inspect GitHub Pull Request", + type="primary", + use_container_width=True, + key="guardian_inspect_pr", + ): + project_root = Path(__file__).resolve().parents[1] + + st.session_state.pop("guardian_pr_result", None) + st.session_state.pop("guardian_pr_error", None) + st.session_state.pop("guardian_pr_comment_url", None) + st.session_state.pop("guardian_pr_publish_error", None) + + with st.spinner("Downloading and inspecting pull request..."): + try: + with tempfile.TemporaryDirectory( + prefix="fiberlab-guardian-" + ) as temporary_directory: + temporary_root = Path(temporary_directory) + + candidate_workspace = temporary_root / "candidate" + integration_workspace = temporary_root / "integration" + + result = inspect_pull_request( + repository=repository, + pr_number=int(pr_number), + project_root=project_root, + candidate_workspace=candidate_workspace, + integration_workspace=integration_workspace, + ) + + comment = format_guardian_comment(result.report) + + st.session_state["guardian_pr_result"] = result + st.session_state["guardian_pr_comment"] = comment + st.session_state["guardian_pr_repository"] = repository + st.session_state["guardian_pr_number"] = int(pr_number) + + except ( + CandidateDownloadError, + GitHubAPIError, + GitHubCommentError, + OSError, + ValueError, + RuntimeError, + ) as error: + st.session_state["guardian_pr_error"] = str(error) + + error_message = st.session_state.get("guardian_pr_error") + + if error_message: + st.error(f"GitHub PR inspection failed: {error_message}") + + result = st.session_state.get("guardian_pr_result") + + if result is None: + st.caption("Inspect a pull request to see its results here.") + return + + st.divider() + st.subheader("Pull Request Details") + + pull_request = result.pull_request + + st.write(f"**Title:** {pull_request.title}") + st.write(f"**PR Number:** #{pull_request.number}") + st.write(f"**Source Branch:** `{pull_request.head_branch}`") + st.write(f"**Target Branch:** `{pull_request.base_branch}`") + st.write(f"**Commit:** `{pull_request.head_sha}`") + + st.subheader("Changed Files") + + changed_file_rows = [ + { + "File": changed_file.path, + "Status": changed_file.status, + "Additions": changed_file.additions, + "Deletions": changed_file.deletions, + "Changes": changed_file.changes, + } + for changed_file in result.changed_files + ] + + st.dataframe( + changed_file_rows, + use_container_width=True, + hide_index=True, + ) + + st.divider() + st.subheader("PR Inspection Results") + + render_report_summary(result.report) + + st.subheader("Diagnostic Findings") + render_report_findings(result.report) + + st.divider() + st.subheader("GitHub Comment Preview") + + comment = st.session_state["guardian_pr_comment"] + + with st.expander("Preview Markdown comment", expanded=True): + st.code(comment, language="markdown") + + st.download_button( + label="Download GitHub Comment", + data=comment, + file_name=f"fiberlab_guardian_pr_{pull_request.number}.md", + mime="text/markdown", + use_container_width=True, + key="guardian_pr_comment_download", + ) + + st.divider() + st.subheader("Publish Report to GitHub") + + st.info( + "Publishing creates a new comment on the pull request. " + "Guardian will not publish anything unless you click the button." + ) + + if st.button( + "Publish Guardian Report to PR", + type="primary", + use_container_width=True, + key="guardian_publish_pr", + ): + token = os.getenv("GITHUB_TOKEN") + + with st.spinner("Publishing Guardian report to GitHub..."): + try: + publish_result = publish_guardian_comment( + repository=st.session_state["guardian_pr_repository"], + pr_number=st.session_state["guardian_pr_number"], + report=result.report, + token=token, + ) + + st.session_state["guardian_pr_comment_url"] = publish_result.comment_url + st.session_state["guardian_pr_publish_error"] = None + + except (GitHubCommentError, OSError, ValueError) as error: + st.session_state.pop("guardian_pr_comment_url", None) + st.session_state["guardian_pr_publish_error"] = str(error) + + publish_error = st.session_state.get("guardian_pr_publish_error") + + if publish_error: + st.error(f"Comment publishing failed: {publish_error}") + + comment_url = st.session_state.get("guardian_pr_comment_url") + + if comment_url: + st.success("Guardian report published successfully!") + st.markdown(f"[Open published GitHub comment]({comment_url})") + + +def render() -> None: + """Render the FiberLab Guardian dashboard.""" + + render_page_header( + title="FiberLab Guardian", + caption="Automated Code Inspection & Diagnostics", + description=( + "Inspect FiberLab code, identify structural issues, " + "and review automated diagnostic reports." + ), + ) + + render_local_inspection() + render_github_inspection() diff --git a/pages/home.py b/pages/home.py new file mode 100644 index 0000000..890699f --- /dev/null +++ b/pages/home.py @@ -0,0 +1,75 @@ +"""FiberLab landing page.""" + +import streamlit as st + + +def render() -> None: + """Render the FiberLab landing page.""" + + st.title("FiberLab") + st.subheader("Interactive Optical Fiber Communication Simulator") + + st.markdown( + """ + Explore the science behind optical fiber communication + through interactive experiments, mathematical models, + and scientific visualizations. + """ + ) + + st.divider() + + st.markdown("### Explore the Laboratory") + + capabilities = [ + ( + "πŸ”­ Fiber Explorer", + ( + "Understand optical fiber geometry, refractive indices, " + "numerical aperture, acceptance angle, and total internal reflection." + ), + ), + ( + "πŸ“‰ Attenuation Laboratory", + ( + "Investigate optical power loss over fiber length and " + "study how attenuation affects signal strength." + ), + ), + ( + "〰️ Signal Propagation", + ( + "Explore signal transmission, waveform behaviour, " + "and the fundamentals of pulse broadening." + ), + ), + ] + + columns = st.columns(3) + + for column, (title, description) in zip(columns, capabilities): + with column, st.container(border=True): + st.markdown(f"#### {title}") + st.write(description) + + st.divider() + + st.markdown("### About FiberLab") + + st.write( + """ + FiberLab is an educational engineering platform designed + to make optical fiber concepts easier to understand. + + Our goal is to connect theoretical physics with practical + simulation through calculations, graphs, and interactive + laboratory experiences. + """ + ) + + st.info( + "🚧 Version 1.0 β€” Under Development. " + "Core physics models and interactive simulations are being built." + ) + + st.caption("FiberLab | Optical Fiber Communication Laboratory") diff --git a/pages/signal_propagation.py b/pages/signal_propagation.py new file mode 100644 index 0000000..cf6922b --- /dev/null +++ b/pages/signal_propagation.py @@ -0,0 +1,17 @@ +"""Signal Propagation page.""" + +from components.page_layout import render_page_header, render_workspace + + +def render() -> None: + """Render the Signal Propagation page.""" + render_page_header( + title="Signal Propagation", + caption="Follow the journey of an optical signal through the fiber.", + description=( + "Explore signal transmission, waveform behaviour, " + "and the fundamentals of pulse broadening and dispersion." + ), + ) + + render_workspace("Interactive signal propagation tools are coming soon.") diff --git a/requirements.txt b/requirements.txt index 3eafa03..4fa9448 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ numpy scipy -streamlit +streamlit==1.67.0 plotly matplotlib -pytest \ No newline at end of file +pytest +ruff +certifi diff --git a/tests/test_app_structure.py b/tests/test_app_structure.py new file mode 100644 index 0000000..ac0aafb --- /dev/null +++ b/tests/test_app_structure.py @@ -0,0 +1,112 @@ +import importlib +from pathlib import Path + +import pytest +from streamlit.testing.v1 import AppTest + +import app + + +def test_page_modules_import_cleanly(): + for module_name in ( + "pages.home", + "pages.fiber_explorer", + "pages.attenuation_laboratory", + "pages.signal_propagation", + ): + module = importlib.import_module(module_name) + assert hasattr(module, "render") + + +def test_navigation_definitions_cover_all_pages(): + assert [definition["title"] for definition in app.PAGE_DEFINITIONS] == [ + "FiberLab", + "Fiber Explorer", + "Attenuation Laboratory", + "Signal Propagation", + "Guardian", + ] + assert ( + sum(definition.get("default", False) for definition in app.PAGE_DEFINITIONS) + == 1 + ) + + +def test_main_uses_native_sidebar_navigation(monkeypatch): + calls = {} + + monkeypatch.setattr( + app.st, + "set_page_config", + lambda **kwargs: calls.setdefault("config", kwargs), + ) + monkeypatch.setattr( + app.st, + "Page", + lambda render, **kwargs: {"render": render, **kwargs}, + ) + monkeypatch.setattr( + app.st, + "navigation", + lambda pages, **kwargs: ( + calls.update({"pages": pages, "navigation_kwargs": kwargs}) + or type( + "Navigation", (), {"run": lambda self: calls.setdefault("ran", True)} + )() + ), + ) + + app.main() + + assert calls["config"]["page_title"] == "FiberLab" + assert calls["navigation_kwargs"] == {"position": "sidebar"} + assert [page["title"] for page in calls["pages"]] == [ + "FiberLab", + "Fiber Explorer", + "Attenuation Laboratory", + "Signal Propagation", + "Guardian", + ] + assert calls["ran"] is True + + +def test_landing_page_renders(): + app_test = AppTest.from_file(Path(__file__).parents[1] / "app.py").run() + + assert not app_test.exception + assert [element.value for element in app_test.title] == ["FiberLab"] + assert any( + "Explore the Laboratory" in element.value for element in app_test.markdown + ) + + +@pytest.mark.parametrize( + ("module_name", "title"), + ( + ("pages.fiber_explorer", "Fiber Explorer"), + ("pages.attenuation_laboratory", "Attenuation Laboratory"), + ("pages.signal_propagation", "Signal Propagation"), + ), +) +def test_simulator_pages_render_placeholders(monkeypatch, module_name, title): + module = importlib.import_module(module_name) + rendered = {} + + import streamlit as st + + monkeypatch.setattr(st, "title", lambda value: rendered.setdefault("title", value)) + monkeypatch.setattr( + st, "caption", lambda value: rendered.setdefault("caption", value) + ) + monkeypatch.setattr(st, "write", lambda value: rendered.setdefault("body", value)) + monkeypatch.setattr(st, "divider", lambda: None) + monkeypatch.setattr( + st, "subheader", lambda value: rendered.setdefault("subheader", value) + ) + monkeypatch.setattr(st, "info", lambda value: rendered.setdefault("info", value)) + + module.render() + + assert rendered["title"] == title + assert rendered["subheader"] == "Laboratory workspace" + assert "coming soon" in rendered["info"] diff --git a/tests/test_guardian_candidate_downloader.py b/tests/test_guardian_candidate_downloader.py new file mode 100644 index 0000000..d0ebe15 --- /dev/null +++ b/tests/test_guardian_candidate_downloader.py @@ -0,0 +1,198 @@ +"""Tests for the Guardian GitHub candidate downloader.""" + +import io +from urllib.error import HTTPError, URLError + +import pytest + +from guardian.candidate_downloader import ( + CandidateDownloadError, + download_candidate_files, +) + +REPOSITORY = "CodingBunny06/FiberLab" +COMMIT_SHA = "a" * 40 + + +def test_downloads_candidate_file(monkeypatch, tmp_path): + content = b"def calculate_output_power():\n return 10\n" + + def mock_urlopen(request, timeout, context): + assert COMMIT_SHA in request.full_url + return io.BytesIO(content) + + monkeypatch.setattr( + "guardian.candidate_downloader.urlopen", + mock_urlopen, + ) + + destination = tmp_path / "candidate" + + downloaded = download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py"], + destination, + ) + + expected_file = destination / "physics" / "attenuation.py" + + assert downloaded == [expected_file] + assert expected_file.read_bytes() == content + + +def test_downloads_multiple_candidate_files(monkeypatch, tmp_path): + responses = [b"first file", b"second file"] + + def mock_urlopen(request, timeout, context): + return io.BytesIO(responses.pop(0)) + + monkeypatch.setattr( + "guardian.candidate_downloader.urlopen", + mock_urlopen, + ) + + destination = tmp_path / "candidate" + + downloaded = download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py", "visualization/signal_graphs.py"], + destination, + ) + + assert len(downloaded) == 2 + assert downloaded[0].read_bytes() == b"first file" + assert downloaded[1].read_bytes() == b"second file" + + +def test_rejects_path_traversal(tmp_path): + destination = tmp_path / "candidate" + + with pytest.raises(ValueError): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["../outside.py"], + destination, + ) + + assert not destination.exists() + + +def test_refuses_existing_destination(monkeypatch, tmp_path): + destination = tmp_path / "candidate" + destination.mkdir() + + with pytest.raises(FileExistsError): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py"], + destination, + ) + + +def test_handles_github_http_error(monkeypatch, tmp_path): + def mock_urlopen(request, timeout, context): + raise HTTPError( + request.full_url, + 404, + "Not Found", + None, + None, + ) + + monkeypatch.setattr( + "guardian.candidate_downloader.urlopen", + mock_urlopen, + ) + + with pytest.raises(CandidateDownloadError): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py"], + tmp_path / "candidate", + ) + + +def test_handles_network_error(monkeypatch, tmp_path): + def mock_urlopen(request, timeout, context): + raise URLError("Connection unavailable") + + monkeypatch.setattr( + "guardian.candidate_downloader.urlopen", + mock_urlopen, + ) + + with pytest.raises(CandidateDownloadError): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py"], + tmp_path / "candidate", + ) + + +def test_rejects_invalid_commit_sha(tmp_path): + with pytest.raises(ValueError): + download_candidate_files( + REPOSITORY, + "invalid-sha", + ["physics/attenuation.py"], + tmp_path / "candidate", + ) + + +def test_rejects_absolute_file_path(tmp_path): + with pytest.raises(ValueError): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["/tmp/outside.py"], + tmp_path / "candidate", + ) + + +def test_rejects_symlink_destination(tmp_path): + target = tmp_path / "outside" + destination = tmp_path / "candidate" + target.mkdir() + destination.symlink_to(target, target_is_directory=True) + + with pytest.raises(FileExistsError): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py"], + destination, + ) + + assert list(target.iterdir()) == [] + + +def test_rejects_oversized_candidate_file(monkeypatch, tmp_path): + from guardian.candidate_downloader import MAX_FILE_SIZE_BYTES + + content = b"x" * (MAX_FILE_SIZE_BYTES + 1) + + def mock_urlopen(request, timeout, context): + return io.BytesIO(content) + + monkeypatch.setattr( + "guardian.candidate_downloader.urlopen", + mock_urlopen, + ) + + destination = tmp_path / "candidate" + + with pytest.raises(CandidateDownloadError, match="exceeds"): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py"], + destination, + ) + + assert not destination.exists() diff --git a/tests/test_guardian_end_to_end.py b/tests/test_guardian_end_to_end.py new file mode 100644 index 0000000..402b11b --- /dev/null +++ b/tests/test_guardian_end_to_end.py @@ -0,0 +1,117 @@ +"""End-to-end tests for the FiberLab Guardian pipeline.""" + +from guardian.runner import run_guardian +from guardian.test_runner import CommandResult + + +def test_guardian_completes_full_workflow(tmp_path, monkeypatch): + """Verify workspace creation, integration, inspection and quality checks.""" + + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + + (project / "app.py").write_text("print('original')\n") + (candidate / "app.py").write_text("print('candidate')\n") + + calls = [] + + def mock_run_command(command, workspace_root, timeout_seconds): + calls.append(command) + return CommandResult("PASS", 0, "Success", "") + + monkeypatch.setattr("guardian.runner.run_command", mock_run_command) + + report = run_guardian( + project, + candidate, + ["app.py"], + workspace, + ) + + assert workspace.exists() + assert (workspace / "app.py").read_text() == "print('candidate')\n" + assert (project / "app.py").read_text() == "print('original')\n" + + assert len(calls) == 2 + assert report.failed == 0 + assert report.passed >= 2 + + +def test_guardian_stops_after_structural_failure(tmp_path, monkeypatch): + """Verify invalid candidate syntax prevents quality-check execution.""" + + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + + (project / "app.py").write_text("print('original')\n") + + physics_dir = candidate / "physics" + physics_dir.mkdir() + + (physics_dir / "numerical_aperture.py").write_text("def broken_function(\n") + + def unexpected_command(*args, **kwargs): + raise AssertionError("Quality checks should not execute.") + + monkeypatch.setattr( + "guardian.runner.run_command", + unexpected_command, + ) + + report = run_guardian( + project, + candidate, + ["physics/numerical_aperture.py"], + workspace, + ) + + assert report.failed >= 1 + assert report.overall_status == "FAIL" + + +def test_guardian_reports_quality_check_failure(tmp_path, monkeypatch): + """Verify failed automated checks appear in the final report.""" + + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + + (project / "app.py").write_text("print('original')\n") + (candidate / "app.py").write_text("print('candidate')\n") + + calls = [] + + def mock_run_command(command, workspace_root, timeout_seconds): + calls.append(command) + + if command[0] == "ruff": + return CommandResult("FAIL", 1, "", "Ruff detected an issue.") + + return CommandResult("PASS", 0, "Tests passed", "") + + monkeypatch.setattr("guardian.runner.run_command", mock_run_command) + + report = run_guardian( + project, + candidate, + ["app.py"], + workspace, + ) + + assert len(calls) == 2 + assert report.failed == 1 + assert report.overall_status == "FAIL" + assert any( + "Ruff detected an issue." in item.recommendation for item in report.diagnostics + ) diff --git a/tests/test_guardian_error_detector.py b/tests/test_guardian_error_detector.py new file mode 100644 index 0000000..0934e94 --- /dev/null +++ b/tests/test_guardian_error_detector.py @@ -0,0 +1,68 @@ +"""Tests for the FiberLab Guardian error detector.""" + +from guardian.error_detector import analyze_findings, classify_finding +from guardian.inspector import InspectionFinding + + +def test_pass_finding_is_classified_as_info(): + finding = InspectionFinding("PASS", "physics/test.py", "API is valid.") + + diagnostic = classify_finding(finding) + + assert diagnostic.severity == "INFO" + assert diagnostic.recommendation == "No action required." + + +def test_pending_finding_is_classified_as_waiting(): + finding = InspectionFinding( + "PENDING", + "physics/test.py", + "Module is not implemented.", + ) + + diagnostic = classify_finding(finding) + + assert diagnostic.severity == "WAITING" + assert diagnostic.status == "PENDING" + + +def test_missing_function_gets_recommendation(): + finding = InspectionFinding( + "FAIL", + "physics/attenuation.py", + "Missing required function: calculate_output_power.", + ) + + diagnostic = classify_finding(finding) + + assert diagnostic.severity == "HIGH" + assert "Implement the missing function" in diagnostic.recommendation + + +def test_broken_import_gets_recommendation(): + finding = InspectionFinding( + "FAIL", + "visualization/signal_graphs.py", + "Broken internal import: physics.missing_module.", + ) + + diagnostic = classify_finding(finding) + + assert "import path" in diagnostic.recommendation + + +def test_multiple_findings_are_classified(): + findings = [ + InspectionFinding("PASS", "physics/test.py", "Valid."), + InspectionFinding("PENDING", "visualization/test.py", "Pending."), + InspectionFinding("FAIL", "physics/test.py", "Syntax error."), + ] + + diagnostics = analyze_findings(findings) + + assert len(diagnostics) == 3 + assert [item.status for item in diagnostics] == [ + "PASS", + "PENDING", + "FAIL", + ] diff --git a/tests/test_guardian_github_client.py b/tests/test_guardian_github_client.py new file mode 100644 index 0000000..27bddd0 --- /dev/null +++ b/tests/test_guardian_github_client.py @@ -0,0 +1,191 @@ +import io +import json +from urllib.error import HTTPError, URLError + +import pytest + +from guardian.github_client import ( + ChangedFile, + GitHubAPIError, + GitHubClient, + PullRequestInfo, +) + + +def test_pull_request_info_stores_metadata(): + pull_request = PullRequestInfo( + number=12, + title="Implement attenuation module", + state="open", + base_branch="main", + head_branch="feature/attenuation", + head_sha="abc123", + head_repo="CodingBunny06/FiberLab", + ) + + assert pull_request.number == 12 + assert pull_request.title == "Implement attenuation module" + assert pull_request.state == "open" + assert pull_request.base_branch == "main" + assert pull_request.head_branch == "feature/attenuation" + assert pull_request.head_sha == "abc123" + + +def test_changed_file_stores_change_details(): + changed_file = ChangedFile( + path="physics/attenuation.py", + status="added", + additions=25, + deletions=0, + changes=25, + ) + + assert changed_file.path == "physics/attenuation.py" + assert changed_file.status == "added" + assert changed_file.additions == 25 + assert changed_file.deletions == 0 + assert changed_file.changes == 25 + + +def test_github_client_rejects_invalid_repository(): + client = GitHubClient() + + with pytest.raises(ValueError): + client.get_pull_request("invalid-repository", 12) + + +def test_github_client_rejects_invalid_pr_number(): + client = GitHubClient() + + with pytest.raises(ValueError): + client.get_pull_request( + "CodingBunny06/FiberLab", + 0, + ) + + +def test_github_client_rejects_boolean_pr_number(): + client = GitHubClient() + + with pytest.raises(ValueError): + client.get_pull_request( + "CodingBunny06/FiberLab", + True, + ) + + +def test_github_client_rejects_malformed_repository(): + client = GitHubClient() + + with pytest.raises(ValueError): + client.get_pull_request( + "CodingBunny06/FiberLab/extra", + 12, + ) + + +def test_get_pull_request_retrieves_metadata_and_uses_token(monkeypatch): + """Verify PR metadata retrieval and authentication.""" + + response_data = { + "number": 12, + "title": "Implement attenuation module", + "state": "open", + "base": {"ref": "main"}, + "head": { + "ref": "feature/attenuation", + "sha": "abc123", + "repo": {"full_name": "CodingBunny06/FiberLab"}, + }, + } + + def mock_urlopen(request, timeout, context): + assert request.get_header("Authorization") == "Bearer test-token" + assert timeout == 15 + return io.BytesIO(json.dumps(response_data).encode("utf-8")) + + monkeypatch.setattr("guardian.github_client.urlopen", mock_urlopen) + + client = GitHubClient(token="test-token") + result = client.get_pull_request("CodingBunny06/FiberLab", 12) + + assert result.number == 12 + assert result.title == "Implement attenuation module" + assert result.head_sha == "abc123" + assert result.head_branch == "feature/attenuation" + + +def test_list_changed_files_handles_pagination(monkeypatch): + """Verify changed files are retrieved across multiple API pages.""" + + first_page = [ + { + "filename": f"physics/module_{index}.py", + "status": "added", + "additions": 10, + "deletions": 0, + "changes": 10, + } + for index in range(100) + ] + + second_page = [ + { + "filename": "physics/attenuation.py", + "status": "modified", + "additions": 5, + "deletions": 2, + "changes": 7, + } + ] + + responses = [first_page, second_page] + requested_urls = [] + + def mock_urlopen(request, timeout, context): + requested_urls.append(request.full_url) + return io.BytesIO(json.dumps(responses.pop(0)).encode("utf-8")) + + monkeypatch.setattr("guardian.github_client.urlopen", mock_urlopen) + + client = GitHubClient() + files = client.list_changed_files("CodingBunny06/FiberLab", 12) + + assert len(files) == 101 + assert files[-1].path == "physics/attenuation.py" + assert "page=1" in requested_urls[0] + assert "page=2" in requested_urls[1] + + +def test_github_client_handles_http_errors(monkeypatch): + """Verify GitHub HTTP errors are converted into API errors.""" + + def mock_urlopen(request, timeout, context): + raise HTTPError( + request.full_url, + 404, + "Not Found", + None, + None, + ) + + monkeypatch.setattr("guardian.github_client.urlopen", mock_urlopen) + + client = GitHubClient() + + with pytest.raises(GitHubAPIError, match="not found"): + client.get_pull_request("CodingBunny06/FiberLab", 12) + + +def test_github_client_handles_network_errors(monkeypatch): + """Verify network failures are handled cleanly.""" + + def mock_urlopen(request, timeout, context): + raise URLError("Connection unavailable") + + monkeypatch.setattr("guardian.github_client.urlopen", mock_urlopen) + + client = GitHubClient() + + with pytest.raises(GitHubAPIError, match="Unable to connect"): + client.get_pull_request("CodingBunny06/FiberLab", 12) diff --git a/tests/test_guardian_github_commenter.py b/tests/test_guardian_github_commenter.py new file mode 100644 index 0000000..8a6f3a6 --- /dev/null +++ b/tests/test_guardian_github_commenter.py @@ -0,0 +1,443 @@ +"""Tests for Guardian GitHub PR comment publishing.""" + +import io +import json +from urllib.error import HTTPError, URLError + +import pytest + +from guardian.error_detector import Diagnostic +from guardian.github_commenter import ( + GitHubCommentError, + format_guardian_comment, + publish_guardian_comment, +) +from guardian.reporter import GuardianReport + +REPOSITORY = "CodingBunny06/FiberLab" +PR_NUMBER = 12 +TEST_TOKEN = "test-token" + +VALID_COMMENT_URL = ( + "https://github.com/CodingBunny06/FiberLab/pull/12#issuecomment-12345" +) + + +def sample_report(): + """Create a representative Guardian report.""" + return GuardianReport( + diagnostics=[ + Diagnostic( + status="PASS", + severity="INFO", + file="physics/attenuation.py", + message="Required function found.", + recommendation="No action required.", + ), + Diagnostic( + status="FAIL", + severity="HIGH", + file="physics/numerical_aperture.py", + message="Incorrect function signature.", + recommendation="Check the module contract.", + ), + Diagnostic( + status="PENDING", + severity="WAITING", + file="visualization/signal_graphs.py", + message="Required module is missing.", + recommendation="Wait for the module owner.", + ), + ], + passed=1, + failed=1, + pending=1, + ) + + +def mock_successful_response(): + """Create a mock successful GitHub API response.""" + response_data = { + "id": 12345, + "html_url": VALID_COMMENT_URL, + } + + return io.BytesIO(json.dumps(response_data).encode("utf-8")) + + +def test_formats_guardian_report_as_markdown(): + comment = format_guardian_comment(sample_report()) + + assert "FiberLab Guardian Report" in comment + assert "FAIL" in comment + assert "Passed: **1**" in comment + assert "Failed: **1**" in comment + assert "Pending: **1**" in comment + assert "physics/numerical_aperture.py" in comment + assert "Check the module contract." in comment + assert "not an approval or merge decision" in comment + + +def test_formats_report_without_diagnostics(): + report = GuardianReport( + diagnostics=[], + passed=0, + failed=0, + pending=0, + ) + + comment = format_guardian_comment(report) + + assert "No diagnostic findings were generated." in comment + + +def test_rejects_oversized_report(): + report = GuardianReport( + diagnostics=[ + Diagnostic( + status="FAIL", + severity="HIGH", + file="large_file.py", + message="x" * 61_000, + recommendation="Review the file.", + ) + ], + passed=0, + failed=1, + pending=0, + ) + + with pytest.raises(ValueError, match="too long"): + format_guardian_comment(report) + + +@pytest.mark.parametrize( + "repository", + [ + "invalid-repository", + "", + "owner/", + "/repository", + "owner/repository/extra", + "owner/repo name", + None, + ], +) +def test_rejects_invalid_repository(repository): + with pytest.raises(ValueError): + publish_guardian_comment( + repository, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + +@pytest.mark.parametrize( + "pr_number", + [0, -1, True, False, 1.5, "12"], +) +def test_rejects_invalid_pr_number(pr_number): + with pytest.raises(ValueError): + publish_guardian_comment( + REPOSITORY, + pr_number, + sample_report(), + token=TEST_TOKEN, + ) + + +@pytest.mark.parametrize( + "timeout", + [0, -1, True, False, 1.5, "15"], +) +def test_rejects_invalid_timeout(timeout): + with pytest.raises(ValueError, match="Timeout"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + timeout=timeout, + ) + + +def test_rejects_missing_token(monkeypatch): + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + with pytest.raises(ValueError, match="GitHub token"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + ) + + +@pytest.mark.parametrize("token", ["", " ", " ", None]) +def test_rejects_empty_token(token, monkeypatch): + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + if token is None: + pytest.skip("None means use the environment variable.") + + with pytest.raises(ValueError, match="GitHub token"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=token, + ) + + +def test_rejects_token_with_surrounding_whitespace(): + with pytest.raises(ValueError, match="surrounding whitespace"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=" test-token ", + ) + + +def test_uses_environment_token(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", TEST_TOKEN) + + def mock_urlopen(request, timeout, context): + assert request.get_header("Authorization") == f"Bearer {TEST_TOKEN}" + return mock_successful_response() + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + result = publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + ) + + assert result.comment_id == 12345 + + +def test_publishes_comment_successfully(monkeypatch): + def mock_urlopen(request, timeout, context): + assert request.get_method() == "POST" + assert request.get_header("Authorization") == f"Bearer {TEST_TOKEN}" + assert request.get_header("Content-type") == "application/json" + assert request.get_header("Accept") == "application/vnd.github+json" + assert request.get_header("X-github-api-version") == "2022-11-28" + assert f"/issues/{PR_NUMBER}/comments" in request.full_url + assert timeout == 15 + assert context is not None + + body = json.loads(request.data.decode("utf-8")) + assert "FiberLab Guardian Report" in body["body"] + + return mock_successful_response() + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + result = publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + assert result.comment_id == 12345 + assert result.comment_url == VALID_COMMENT_URL + + +@pytest.mark.parametrize( + "invalid_url", + [ + "http://github.com/owner/repo", + "https://evil.example.com/comment", + "https://github.com.evil.example.com/comment", + "https://evil@github.com/comment", + "https://github.com:444/comment", + "not-a-url", + "", + ], +) +def test_rejects_invalid_comment_url(monkeypatch, invalid_url): + response_data = { + "id": 12345, + "html_url": invalid_url, + } + + def mock_urlopen(request, timeout, context): + return io.BytesIO(json.dumps(response_data).encode("utf-8")) + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises(GitHubCommentError, match="unexpected comment response"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + +@pytest.mark.parametrize( + "response_data", + [ + {}, + {"html_url": VALID_COMMENT_URL}, + {"id": 12345}, + {"id": True, "html_url": VALID_COMMENT_URL}, + {"id": "12345", "html_url": VALID_COMMENT_URL}, + [], + None, + ], +) +def test_rejects_incomplete_or_invalid_response(monkeypatch, response_data): + def mock_urlopen(request, timeout, context): + return io.BytesIO(json.dumps(response_data).encode("utf-8")) + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises(GitHubCommentError, match="unexpected comment response"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + +def test_handles_malformed_json_response(monkeypatch): + def mock_urlopen(request, timeout, context): + return io.BytesIO(b"{invalid json") + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises(GitHubCommentError, match="invalid comment response"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + +def test_handles_invalid_unicode_response(monkeypatch): + def mock_urlopen(request, timeout, context): + return io.BytesIO(b"\xff\xfe") + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises(GitHubCommentError, match="invalid comment response"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + +def test_handles_github_http_error(monkeypatch): + def mock_urlopen(request, timeout, context): + raise HTTPError( + request.full_url, + 403, + "Forbidden", + None, + io.BytesIO(b'{"message": "Resource not accessible"}'), + ) + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises( + GitHubCommentError, + match="HTTP 403.*Resource not accessible", + ): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + +def test_handles_unreadable_github_error_response(monkeypatch): + def mock_urlopen(request, timeout, context): + raise HTTPError( + request.full_url, + 500, + "Internal Server Error", + None, + io.BytesIO(b"not json"), + ) + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises( + GitHubCommentError, + match="HTTP 500.*unreadable error response", + ): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + +def test_handles_network_error(monkeypatch): + def mock_urlopen(request, timeout, context): + raise URLError("Connection unavailable") + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises(GitHubCommentError, match="connect"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + +def test_handles_timeout(monkeypatch): + def mock_urlopen(request, timeout, context): + raise TimeoutError("Request timed out") + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises(GitHubCommentError, match="connect"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) diff --git a/tests/test_guardian_github_integration.py b/tests/test_guardian_github_integration.py new file mode 100644 index 0000000..c5d73df --- /dev/null +++ b/tests/test_guardian_github_integration.py @@ -0,0 +1,148 @@ +"""Tests for GitHub pull request integration.""" + +import pytest + +from guardian.github_client import ChangedFile, PullRequestInfo +from guardian.github_integration import inspect_pull_request +from guardian.reporter import GuardianReport + + +def test_inspects_pull_request_using_existing_guardian_pipeline( + monkeypatch, + tmp_path, +): + """Verify the complete orchestration without network access.""" + + pull_request = PullRequestInfo( + number=12, + title="Implement attenuation module", + state="open", + base_branch="main", + head_branch="feature/attenuation", + head_sha="a" * 40, + head_repo="contributor/FiberLab", + ) + + changed_files = [ + ChangedFile("physics/attenuation.py", "modified", 20, 5, 25), + ChangedFile("physics/old_module.py", "removed", 0, 10, 10), + ] + + class FakeGitHubClient: + def __init__(self, token=None): + assert token == "test-token" + + def get_pull_request(self, repository, pr_number): + assert repository == "CodingBunny06/FiberLab" + assert pr_number == 12 + return pull_request + + def list_changed_files(self, repository, pr_number): + return changed_files + + downloaded_path = tmp_path / "candidate" / "physics" / "attenuation.py" + expected_report = GuardianReport( + diagnostics=[], + passed=1, + failed=0, + pending=2, + ) + + download_calls = {} + runner_calls = {} + + def fake_download(**kwargs): + download_calls.update(kwargs) + return [downloaded_path] + + def fake_runner(**kwargs): + runner_calls.update(kwargs) + return expected_report + + monkeypatch.setattr( + "guardian.github_integration.GitHubClient", + FakeGitHubClient, + ) + monkeypatch.setattr( + "guardian.github_integration.download_candidate_files", + fake_download, + ) + monkeypatch.setattr( + "guardian.github_integration.run_guardian", + fake_runner, + ) + + result = inspect_pull_request( + repository="CodingBunny06/FiberLab", + pr_number=12, + project_root=tmp_path / "project", + candidate_workspace=tmp_path / "candidate", + integration_workspace=tmp_path / "integration", + token="test-token", + ) + + assert result.pull_request.number == 12 + assert len(result.changed_files) == 2 + assert result.downloaded_files == [downloaded_path] + assert result.report is expected_report + + assert download_calls["repository"] == "contributor/FiberLab" + assert download_calls["commit_sha"] == "a" * 40 + assert download_calls["selected_files"] == ["physics/attenuation.py"] + + assert runner_calls["selected_files"] == ["physics/attenuation.py"] + assert runner_calls["run_quality_checks"] is False + + +def test_rejects_pull_request_without_downloadable_files( + monkeypatch, + tmp_path, +): + """Verify deleted-only PRs do not start a download.""" + + class FakeGitHubClient: + def __init__(self, token=None): + pass + + def get_pull_request(self, repository, pr_number): + return PullRequestInfo( + number=13, + title="Remove old module", + state="open", + base_branch="main", + head_branch="feature/removal", + head_sha="b" * 40, + head_repo="contributor/FiberLab", + ) + + def list_changed_files(self, repository, pr_number): + return [ + ChangedFile( + "physics/old_module.py", + "removed", + 0, + 10, + 10, + ) + ] + + def unexpected_download(**kwargs): + pytest.fail("Downloader should not run for deleted-only PRs.") + + monkeypatch.setattr( + "guardian.github_integration.GitHubClient", + FakeGitHubClient, + ) + monkeypatch.setattr( + "guardian.github_integration.download_candidate_files", + unexpected_download, + ) + + with pytest.raises(ValueError, match="no downloadable files"): + inspect_pull_request( + repository="CodingBunny06/FiberLab", + pr_number=13, + project_root=tmp_path / "project", + candidate_workspace=tmp_path / "candidate", + integration_workspace=tmp_path / "integration", + ) diff --git a/tests/test_guardian_inspector.py b/tests/test_guardian_inspector.py new file mode 100644 index 0000000..a6e7b54 --- /dev/null +++ b/tests/test_guardian_inspector.py @@ -0,0 +1,256 @@ +"""Tests for the FiberLab Guardian code inspector.""" + +from pathlib import Path + +from guardian.inspector import inspect_project + + +def create_module(root: Path, relative_path: str, source: str) -> None: + """Create a Python module inside a temporary project.""" + file_path = root / relative_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(source, encoding="utf-8") + + +def test_missing_modules_are_reported_as_pending(tmp_path): + findings = inspect_project(tmp_path) + + assert findings + assert all(finding.status == "PENDING" for finding in findings) + + +def test_valid_physics_module_passes_signature_check(tmp_path): + create_module( + tmp_path, + "physics/numerical_aperture.py", + """ +def calculate_numerical_aperture(n_core, n_cladding): + return 0.0 + +def calculate_critical_angle(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "PASS" and "calculate_numerical_aperture" in finding.message + for finding in findings + ) + + +def test_incorrect_signature_is_detected(tmp_path): + create_module( + tmp_path, + "physics/numerical_aperture.py", + """ +def calculate_numerical_aperture(n_core, cladding_index): + return 0.0 + +def calculate_critical_angle(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" and "incompatible signature" in finding.message + for finding in findings + ) + + +def test_syntax_error_is_detected(tmp_path): + create_module( + tmp_path, + "physics/numerical_aperture.py", + "def calculate_numerical_aperture(:", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" and "Unable to parse module" in finding.message + for finding in findings + ) + + +def test_streamlit_import_is_detected(tmp_path): + create_module( + tmp_path, + "physics/numerical_aperture.py", + """ +import streamlit + +def calculate_numerical_aperture(n_core, n_cladding): + return 0.0 + +def calculate_critical_angle(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" and "Streamlit import" in finding.message + for finding in findings + ) + + +def test_missing_function_is_detected(tmp_path): + create_module( + tmp_path, + "physics/numerical_aperture.py", + """ +def calculate_numerical_aperture(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" and "calculate_critical_angle" in finding.message + for finding in findings + ) + + +def test_function_in_wrong_module_is_detected(tmp_path): + create_module( + tmp_path, + "physics/attenuation.py", + """ +def calculate_numerical_aperture(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and "belongs in physics/numerical_aperture.py" in finding.message + for finding in findings + ) + + +def test_missing_internal_module_import_is_detected(tmp_path): + create_module( + tmp_path, + "physics/numerical_aperture.py", + """ +from physics.missing_module import calculate_something + +def calculate_numerical_aperture(n_core, n_cladding): + return 0.0 + +def calculate_critical_angle(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" and "physics.missing_module" in finding.message + for finding in findings + ) + + +def test_missing_imported_function_is_detected(tmp_path): + create_module( + tmp_path, + "physics/attenuation.py", + """ +def calculate_output_power(input_power_mw, attenuation_db_per_km, length_km): + return 1.0 +""", + ) + + create_module( + tmp_path, + "visualization/signal_graphs.py", + """ +from physics.attenuation import calculate_loss +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" and "calculate_loss" in finding.message + for finding in findings + ) + + +def test_syntax_error_in_non_contract_module_is_detected(tmp_path): + create_module( + tmp_path, + "visualization/helpers.py", + "def create_figure(:", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and finding.file == "visualization/helpers.py" + and "Unable to parse module" in finding.message + for finding in findings + ) + + +def test_streamlit_import_in_nested_module_is_detected(tmp_path): + create_module( + tmp_path, + "visualization/helpers/renderer.py", + "import streamlit", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and finding.file == "visualization/helpers/renderer.py" + and "Streamlit import" in finding.message + for finding in findings + ) + + +def test_contract_function_in_nested_module_is_detected(tmp_path): + create_module( + tmp_path, + "physics/helpers/numerical.py", + """ +def calculate_numerical_aperture(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and finding.file == "physics/helpers/numerical.py" + and "belongs in physics/numerical_aperture.py" in finding.message + for finding in findings + ) + + +def test_syntax_error_in_guardian_module_is_detected(tmp_path): + create_module( + tmp_path, + "guardian/helpers.py", + "def inspect_candidate(:", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and finding.file == "guardian/helpers.py" + and "Unable to parse module" in finding.message + for finding in findings + ) diff --git a/tests/test_guardian_integration_engine.py b/tests/test_guardian_integration_engine.py new file mode 100644 index 0000000..282dc7a --- /dev/null +++ b/tests/test_guardian_integration_engine.py @@ -0,0 +1,136 @@ +"""Tests for the FiberLab Guardian integration engine.""" + +import pytest + +from guardian.integration_engine import ( + create_integration_workspace, + integrate_candidate_modules, +) + + +def test_workspace_copies_project_files(tmp_path): + source = tmp_path / "source" + destination = tmp_path / "workspace" + + source.mkdir() + (source / "app.py").write_text("print('FiberLab')", encoding="utf-8") + + result = create_integration_workspace(source, destination) + + assert result == destination + assert (destination / "app.py").read_text(encoding="utf-8") == ("print('FiberLab')") + + +def test_workspace_excludes_development_directories(tmp_path): + source = tmp_path / "source" + destination = tmp_path / "workspace" + + source.mkdir() + (source / "app.py").write_text("print('FiberLab')", encoding="utf-8") + + for directory in (".git", ".venv", "__pycache__", ".pytest_cache", ".ruff_cache"): + ignored = source / directory + ignored.mkdir() + (ignored / "internal.txt").write_text("exclude me", encoding="utf-8") + + create_integration_workspace(source, destination) + + for directory in (".git", ".venv", "__pycache__", ".pytest_cache", ".ruff_cache"): + assert not (destination / directory).exists() + + +def test_workspace_refuses_existing_destination(tmp_path): + source = tmp_path / "source" + destination = tmp_path / "workspace" + + source.mkdir() + destination.mkdir() + + with pytest.raises(FileExistsError): + create_integration_workspace(source, destination) + + +def test_workspace_rejects_missing_source(tmp_path): + source = tmp_path / "missing" + destination = tmp_path / "workspace" + + with pytest.raises(FileNotFoundError): + create_integration_workspace(source, destination) + + +def test_integrates_selected_candidate_module(tmp_path): + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + (candidate / "physics").mkdir(parents=True) + (workspace / "physics").mkdir(parents=True) + + source_file = candidate / "physics" / "attenuation.py" + source_file.write_text("def calculate_loss():\n return 5\n", encoding="utf-8") + + result = integrate_candidate_modules( + candidate, + workspace, + ["physics/attenuation.py"], + ) + + destination_file = workspace / "physics" / "attenuation.py" + + assert destination_file.exists() + assert destination_file.read_text(encoding="utf-8") == source_file.read_text( + encoding="utf-8" + ) + assert result == [destination_file] + + +def test_integrates_only_selected_files(tmp_path): + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + candidate.mkdir() + workspace.mkdir() + + (candidate / "selected.py").write_text("selected = True", encoding="utf-8") + (candidate / "unselected.py").write_text("unselected = True", encoding="utf-8") + + integrate_candidate_modules(candidate, workspace, ["selected.py"]) + + assert (workspace / "selected.py").exists() + assert not (workspace / "unselected.py").exists() + + +def test_integrates_candidate_into_existing_workspace_file(tmp_path): + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + candidate.mkdir() + workspace.mkdir() + + (candidate / "app.py").write_text("candidate version", encoding="utf-8") + (workspace / "app.py").write_text("placeholder version", encoding="utf-8") + + integrate_candidate_modules(candidate, workspace, ["app.py"]) + + assert (workspace / "app.py").read_text(encoding="utf-8") == "candidate version" + + +def test_rejects_missing_candidate_file(tmp_path): + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + candidate.mkdir() + workspace.mkdir() + + with pytest.raises(FileNotFoundError): + integrate_candidate_modules(candidate, workspace, ["physics/missing.py"]) + + +def test_rejects_unsafe_candidate_path(tmp_path): + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + candidate.mkdir() + workspace.mkdir() + + with pytest.raises(ValueError): + integrate_candidate_modules(candidate, workspace, ["../outside.py"]) diff --git a/tests/test_guardian_pipeline.py b/tests/test_guardian_pipeline.py new file mode 100644 index 0000000..bdf7f9e --- /dev/null +++ b/tests/test_guardian_pipeline.py @@ -0,0 +1,52 @@ +"""End-to-end tests for the FiberLab Guardian pipeline.""" + +from guardian.reporter import generate_report + + +def create_module(root, relative_path, source): + """Create a simulated teammate module.""" + file_path = root / relative_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(source, encoding="utf-8") + + +def test_guardian_detects_multiple_integration_problems(tmp_path): + # Simulate a teammate submitting an incorrect physics module. + create_module( + tmp_path, + "physics/numerical_aperture.py", + """ +def calculate_numerical_aperture(n_core, cladding_index): + return 0.0 + +def calculate_critical_angle(n_core, n_cladding): + return 0.0 +""", + ) + + # Simulate an invalid import and forbidden dependency. + create_module( + tmp_path, + "visualization/signal_graphs.py", + """ +import streamlit +from physics.numerical_aperture import calculate_missing_function +""", + ) + + report = generate_report(tmp_path) + + failed_messages = [ + diagnostic.message.lower() + for diagnostic in report.diagnostics + if diagnostic.status == "FAIL" + ] + + assert report.overall_status == "FAIL" + + assert any("incompatible signature" in message for message in failed_messages) + assert any("missing imported symbol" in message for message in failed_messages) + assert any("streamlit import" in message for message in failed_messages) + + assert report.failed >= 3 + assert report.pending > 0 diff --git a/tests/test_guardian_report_quality.py b/tests/test_guardian_report_quality.py new file mode 100644 index 0000000..36d9242 --- /dev/null +++ b/tests/test_guardian_report_quality.py @@ -0,0 +1,62 @@ +"""Tests for Guardian report quality and diagnostics.""" + +from guardian.error_detector import Diagnostic +from guardian.reporter import GuardianReport, format_report + + +def test_report_prioritizes_failure_over_pending(): + report = GuardianReport( + diagnostics=[], + passed=2, + failed=1, + pending=3, + ) + + assert report.overall_status == "FAIL" + + +def test_report_returns_pending_when_no_failures(): + report = GuardianReport( + diagnostics=[], + passed=2, + failed=0, + pending=3, + ) + + assert report.overall_status == "PENDING" + + +def test_report_returns_pass_when_no_failures_or_pending(): + report = GuardianReport( + diagnostics=[], + passed=5, + failed=0, + pending=0, + ) + + assert report.overall_status == "PASS" + + +def test_formatted_report_contains_diagnostic_details(): + diagnostic = Diagnostic( + status="FAIL", + severity="HIGH", + file="physics/numerical_aperture.py", + message="Function signature does not match the contract.", + recommendation="Update the function signature.", + ) + + report = GuardianReport( + diagnostics=[diagnostic], + passed=2, + failed=1, + pending=0, + ) + + output = format_report(report) + + assert "FAIL" in output + assert "physics/numerical_aperture.py" in output + assert "HIGH" in output + assert "Function signature does not match the contract." in output + assert "Update the function signature." in output diff --git a/tests/test_guardian_reporter.py b/tests/test_guardian_reporter.py new file mode 100644 index 0000000..37726d0 --- /dev/null +++ b/tests/test_guardian_reporter.py @@ -0,0 +1,35 @@ +"""Tests for the FiberLab Guardian reporter.""" + +from guardian.reporter import format_report, generate_report + + +def test_report_detects_pending_modules(tmp_path): + report = generate_report(tmp_path) + + assert report.overall_status == "PENDING" + assert report.pending > 0 + + +def test_report_counts_inspection_failures(tmp_path): + report = generate_report(tmp_path) + + assert report.failed == 0 + + +def test_report_contains_all_summary_sections(tmp_path): + report = generate_report(tmp_path) + output = format_report(report) + + assert "FIBERLAB GUARDIAN REPORT" in output + assert "Overall Status:" in output + assert "Passed:" in output + assert "Failed:" in output + assert "Pending:" in output + + +def test_report_contains_diagnostic_details(tmp_path): + report = generate_report(tmp_path) + output = format_report(report) + + assert "DIAGNOSTICS" in output + assert "Recommendation:" in output diff --git a/tests/test_guardian_runner.py b/tests/test_guardian_runner.py new file mode 100644 index 0000000..006cf0a --- /dev/null +++ b/tests/test_guardian_runner.py @@ -0,0 +1,106 @@ +"""Tests for the FiberLab Guardian execution pipeline.""" + +from guardian.runner import run_guardian + + +def test_guardian_creates_workspace_and_generates_report(tmp_path): + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + + (project / "app.py").write_text("print('FiberLab')", encoding="utf-8") + (candidate / "app.py").write_text( + "print('Candidate FiberLab')", + encoding="utf-8", + ) + + report = run_guardian( + project_root=project, + candidate_root=candidate, + selected_files=["app.py"], + workspace_root=workspace, + run_quality_checks=False, + ) + + assert workspace.exists() + assert (workspace / "app.py").read_text(encoding="utf-8") == ( + "print('Candidate FiberLab')" + ) + assert report is not None + assert report.overall_status in ("PASS", "PENDING", "FAIL") + + +def test_guardian_does_not_modify_original_project(tmp_path): + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + + original_content = "print('Original FiberLab')" + (project / "app.py").write_text(original_content, encoding="utf-8") + (candidate / "app.py").write_text( + "print('Candidate version')", + encoding="utf-8", + ) + + run_guardian( + project_root=project, + candidate_root=candidate, + selected_files=["app.py"], + workspace_root=workspace, + run_quality_checks=False, + ) + + assert (project / "app.py").read_text(encoding="utf-8") == original_content + + +def test_guardian_rejects_missing_candidate_file(tmp_path): + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + + (project / "app.py").write_text("print('FiberLab')", encoding="utf-8") + + try: + run_guardian( + project_root=project, + candidate_root=candidate, + selected_files=["physics/missing.py"], + workspace_root=workspace, + run_quality_checks=False, + ) + except FileNotFoundError: + pass + else: + raise AssertionError("Expected FileNotFoundError") + + +def test_guardian_rejects_existing_workspace(tmp_path): + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + workspace.mkdir() + + try: + run_guardian( + project_root=project, + candidate_root=candidate, + selected_files=[], + workspace_root=workspace, + run_quality_checks=False, + ) + except FileExistsError: + pass + else: + raise AssertionError("Expected FileExistsError") diff --git a/tests/test_guardian_self_check.py b/tests/test_guardian_self_check.py new file mode 100644 index 0000000..492d9f1 --- /dev/null +++ b/tests/test_guardian_self_check.py @@ -0,0 +1,20 @@ +import ast + + +def test_guardian_detects_incorrect_api_signature(): + # Simulate a teammate implementing the wrong signature. + incorrect_source = """ +def calculate_numerical_aperture(n_core, cladding_index): + return 0.0 +""" + + expected_parameters = ("n_core", "n_cladding") + + module = ast.parse(incorrect_source) + + function = next(node for node in module.body if isinstance(node, ast.FunctionDef)) + + actual_parameters = tuple(argument.arg for argument in function.args.args) + + assert actual_parameters != expected_parameters + assert actual_parameters == ("n_core", "cladding_index") diff --git a/tests/test_guardian_test_runner.py b/tests/test_guardian_test_runner.py new file mode 100644 index 0000000..1dd35fe --- /dev/null +++ b/tests/test_guardian_test_runner.py @@ -0,0 +1,79 @@ +"""Tests for the FiberLab Guardian automated test runner.""" + +import subprocess + +from guardian.test_runner import run_command + + +def test_successful_command_returns_pass(monkeypatch, tmp_path): + def fake_run(*args, **kwargs): + return subprocess.CompletedProcess( + args[0], 0, stdout="All checks passed", stderr="" + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + + result = run_command(["ruff", "check", "."], tmp_path) + + assert result.status == "PASS" + assert result.return_code == 0 + assert result.stdout == "All checks passed" + assert result.stderr == "" + + +def test_failed_command_returns_fail(monkeypatch, tmp_path): + def fake_run(*args, **kwargs): + return subprocess.CompletedProcess( + args[0], 1, stdout="", stderr="Lint errors found" + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + + result = run_command(["ruff", "check", "."], tmp_path) + + assert result.status == "FAIL" + assert result.return_code == 1 + assert result.stderr == "Lint errors found" + + +def test_timed_out_command_returns_timeout(monkeypatch, tmp_path): + def fake_run(*args, **kwargs): + raise subprocess.TimeoutExpired(args[0], kwargs["timeout"]) + + monkeypatch.setattr(subprocess, "run", fake_run) + + result = run_command( + ["python", "-m", "pytest"], + tmp_path, + timeout_seconds=5, + ) + + assert result.status == "TIMEOUT" + assert result.return_code is None + + +def test_execution_error_is_captured(monkeypatch, tmp_path): + def fake_run(*args, **kwargs): + raise OSError("Command could not start") + + monkeypatch.setattr(subprocess, "run", fake_run) + + result = run_command(["missing-command"], tmp_path) + + assert result.status == "ERROR" + assert result.return_code is None + assert "could not start" in result.stderr.lower() + + +def test_command_runs_in_requested_workspace(monkeypatch, tmp_path): + captured = {} + + def fake_run(*args, **kwargs): + captured["cwd"] = kwargs["cwd"] + return subprocess.CompletedProcess(args[0], 0, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + + run_command(["ruff", "check", "."], tmp_path) + + assert captured["cwd"] == tmp_path diff --git a/tests/test_module_contracts.py b/tests/test_module_contracts.py new file mode 100644 index 0000000..f1e3d05 --- /dev/null +++ b/tests/test_module_contracts.py @@ -0,0 +1,173 @@ +"""Tests for FiberLab module integration contracts.""" + +import ast +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +# Expected public APIs and their parameter names, in order. +MODULE_CONTRACTS = { + "physics/numerical_aperture.py": { + "calculate_numerical_aperture": ( + "n_core", + "n_cladding", + ), + "calculate_critical_angle": ( + "n_core", + "n_cladding", + ), + }, + "physics/attenuation.py": { + "calculate_output_power": ( + "input_power_mw", + "attenuation_db_per_km", + "length_km", + ), + "calculate_total_loss": ( + "attenuation_db_per_km", + "length_km", + ), + }, + "visualization/fiber_animation.py": { + "create_fiber_ray_figure": ("ray_data",), + }, + "visualization/signal_graphs.py": { + "create_attenuation_figure": ( + "distance_km", + "power_mw", + ), + "create_signal_waveform_figure": ( + "input_signal", + "output_signal", + "time_axis", + ), + }, +} + + +def test_physics_package_exists(): + assert (PROJECT_ROOT / "physics" / "__init__.py").exists() + + +def test_visualization_package_exists(): + assert (PROJECT_ROOT / "visualization" / "__init__.py").exists() + + +def test_integration_contract_document_exists(): + assert (PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md").exists() + + +def test_physics_package_does_not_import_streamlit(): + physics_files = list((PROJECT_ROOT / "physics").glob("*.py")) + + for file_path in physics_files: + source = file_path.read_text(encoding="utf-8") + assert "import streamlit" not in source + assert "from streamlit" not in source + + +def test_physics_api_contract_exists(): + contract = PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" + content = contract.read_text(encoding="utf-8") + + assert "calculate_numerical_aperture" in content + assert "calculate_critical_angle" in content + assert "calculate_output_power" in content + assert "calculate_total_loss" in content + + +def test_visualization_api_contract_exists(): + contract = PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" + content = contract.read_text(encoding="utf-8") + + assert "create_fiber_ray_figure" in content + assert "create_attenuation_figure" in content + assert "create_signal_waveform_figure" in content + + +def test_application_integration_contract_exists(): + contract = PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" + content = contract.read_text(encoding="utf-8") + + assert "Application Integration API" in content + assert "Error Handling" in content + + +def test_visualization_package_does_not_import_streamlit(): + visualization_files = list((PROJECT_ROOT / "visualization").glob("*.py")) + + for file_path in visualization_files: + source = file_path.read_text(encoding="utf-8") + assert "import streamlit" not in source + assert "from streamlit" not in source + + +def test_implemented_module_api_signatures(): + """Validate implemented APIs without importing or executing modules.""" + + expected_locations = {} + + for relative_path, functions in MODULE_CONTRACTS.items(): + expected_path = PROJECT_ROOT / relative_path + + for function_name in functions: + expected_locations[function_name] = expected_path + + # Not implemented yet: allow the teammate to add it later. + if not expected_path.exists(): + continue + + source = expected_path.read_text(encoding="utf-8") + module = ast.parse(source, filename=str(expected_path)) + + definitions = { + node.name: node + for node in module.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + for function_name, expected_parameters in functions.items(): + assert function_name in definitions, ( + f"{function_name} is missing from {relative_path}" + ) + + function = definitions[function_name] + actual_parameters = tuple(argument.arg for argument in function.args.args) + + assert actual_parameters == expected_parameters, ( + f"{function_name} in {relative_path} has incorrect " + f"parameters. Expected {expected_parameters}, " + f"got {actual_parameters}." + ) + + assert not function.args.posonlyargs, ( + f"{function_name} must not use positional-only parameters." + ) + assert function.args.vararg is None, f"{function_name} must not use *args." + assert function.args.kwarg is None, ( + f"{function_name} must not use **kwargs." + ) + assert not function.args.kwonlyargs, ( + f"{function_name} must not use keyword-only parameters." + ) + + # Detect contract functions implemented in the wrong module. + for package in ("physics", "visualization"): + package_path = PROJECT_ROOT / package + + for file_path in package_path.glob("*.py"): + source = file_path.read_text(encoding="utf-8") + module = ast.parse(source, filename=str(file_path)) + + for node in module.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + + expected_path = expected_locations.get(node.name) + + if expected_path is not None: + assert file_path == expected_path, ( + f"{node.name} belongs in " + f"{expected_path.relative_to(PROJECT_ROOT)}, " + f"not {file_path.relative_to(PROJECT_ROOT)}." + ) diff --git a/tests/test_result_display.py b/tests/test_result_display.py new file mode 100644 index 0000000..2d88508 --- /dev/null +++ b/tests/test_result_display.py @@ -0,0 +1,42 @@ +"""Tests for FiberLab result display components.""" + +from unittest.mock import MagicMock + +from components import result_display + + +def test_render_result_metrics_displays_empty_message(monkeypatch): + info_mock = MagicMock() + monkeypatch.setattr(result_display.st, "info", info_mock) + + result_display.render_result_metrics({}) + + info_mock.assert_called_once_with("No simulation results available yet.") + + +def test_render_result_metrics_displays_values(monkeypatch): + columns = [MagicMock(), MagicMock()] + columns_mock = MagicMock(return_value=columns) + metric_mock = MagicMock() + + monkeypatch.setattr(result_display.st, "columns", columns_mock) + monkeypatch.setattr(result_display.st, "metric", metric_mock) + + results = { + "Output Power": "12.5 mW", + "Total Loss": "3.2 dB", + } + + result_display.render_result_metrics(results) + + columns_mock.assert_called_once_with(2) + + assert metric_mock.call_count == 2 + assert metric_mock.call_args_list[0].kwargs == { + "label": "Output Power", + "value": "12.5 mW", + } + assert metric_mock.call_args_list[1].kwargs == { + "label": "Total Loss", + "value": "3.2 dB", + } diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..d6145cd --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,58 @@ +"""Tests for FiberLab input validation.""" + +import pytest + +from components.validation import ( + validate_non_negative, + validate_positive, + validate_refractive_indices, +) + + +def test_validate_positive_accepts_valid_value(): + assert validate_positive(5.0, "Length") is None + + +def test_validate_positive_rejects_zero(): + with pytest.raises(ValueError): + validate_positive(0, "Length") + + +def test_validate_positive_rejects_negative(): + with pytest.raises(ValueError): + validate_positive(-2.0, "Length") + + +def test_validate_positive_rejects_infinity(): + with pytest.raises(ValueError): + validate_positive(float("inf"), "Length") + + +def test_validate_non_negative_accepts_zero(): + assert validate_non_negative(0, "Attenuation") is None + + +def test_validate_non_negative_rejects_negative(): + with pytest.raises(ValueError): + validate_non_negative(-1.0, "Attenuation") + + +def test_validate_refractive_indices_accepts_valid_values(): + assert validate_refractive_indices(1.5, 1.4) is None + + +def test_validate_refractive_indices_rejects_invalid_order(): + with pytest.raises(ValueError): + validate_refractive_indices(1.4, 1.5) + + +@pytest.mark.parametrize("value", ["1.5", None, True, object()]) +def test_validate_positive_rejects_non_numeric_values(value): + with pytest.raises(ValueError): + validate_positive(value, "Length") + + +@pytest.mark.parametrize("value", ["0", None, False, object()]) +def test_validate_non_negative_rejects_non_numeric_values(value): + with pytest.raises(ValueError): + validate_non_negative(value, "Attenuation")