diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c29cf59 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,109 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + unit-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build and start containers + run: | + cd mock-project + docker compose up --build -d + + - name: Wait for API + run: sleep 10 + + - name: Run unit tests + run: | + cd mock-project + docker compose exec api pytest unit_tests/ -v + + - name: Stop containers + run: | + cd mock-project + docker compose down + + e2e-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: | + cd mock-project + npm ci + + - name: Install Playwright browsers + run: | + cd mock-project + npx playwright install chromium + + - name: Start containers + run: | + cd mock-project + docker compose up --build -d + + - name: Wait for API to be ready + run: | + sleep 10 + curl --retry 5 --retry-delay 2 --retry-connrefused http://localhost:5000/health || true + + - name: Run Playwright tests + run: | + cd mock-project + npx playwright test + + - name: Stop containers + run: | + cd mock-project + docker compose down + + load-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + cd mock-project + pip install locust + + - name: Start containers + run: | + cd mock-project + docker compose up --build -d + + - name: Wait for API to be ready + run: | + sleep 10 + curl --retry 5 --retry-delay 2 --retry-connrefused http://localhost:5000/health || true + + - name: Run load test + run: | + cd mock-project + locust -f load_tests/locustfile.py --headless -u 5 -r 1 --run-time 30s --host=http://localhost:5000 + + - name: Stop containers + run: | + cd mock-project + docker compose down diff --git a/.gitignore b/.gitignore index b7faf40..42ca774 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,6 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ +mock-project/node_modules/ +mock-project/backend/test_results.json +mock-project/test-results/ diff --git a/assignment-5-common-issues.md b/assignment-5-common-issues.md new file mode 100644 index 0000000..e4f4776 --- /dev/null +++ b/assignment-5-common-issues.md @@ -0,0 +1,27 @@ +# Common Production Issues for Colorblindness Diagnostic Tools + +Based on research of similar products (Enchroma, Color Blind Check, Ishihara Test apps) and SOA/REST principles: + +## Functional issues + +1. **False positives/negatives** – Users report inconsistent results between test sessions. The slides note that scores can vary by +/-13%, but some users experience wider swings. This is the highest-priority issue because a misdiagnosis erodes trust. + +2. **Calibration issues** – Different screens (OLED vs LCD, brightness settings) affect color perception. A test calibrated on one device may be too easy or too hard on another. + +3. **Scoring algorithm bugs** – A bug in the cone score calculation would misdiagnose every user. My unit tests do not currently cover this logic. + +## Operational issues (from SOA statelessness principle, SRC-3, SRC-27) + +4. **Session state as a bottleneck** – My application uses server-side Flask sessions to track user progress. Under load, the session store becomes a bottleneck. If a user refreshes the page or opens multiple tabs, the session can become corrupted. A stateless design would store answers in localStorage or a signed JWT, aligning with REST statelessness constraints. This would also simplify load testing because each request would be independent. + +5. **No runbook for incident response** – If the test goes down at 2am, there are no documented steps for investigation or recovery. + +## Accessibility issues + +6. **Keyboard navigation gaps** – Users who cannot use a mouse may struggle to take the test. + +7. **Screen reader support** – The canvas-based number display is not accessible to blind users. This is a fundamental limitation of the Ishihara format. + +## Load/performance issues + +8. **Unknown concurrency limits** – I have not tested how the system behaves under 100 concurrent users. The Flask development server is single-threaded and not production-ready. diff --git a/assignment-5-configurations.md b/assignment-5-configurations.md new file mode 100644 index 0000000..d21e6be --- /dev/null +++ b/assignment-5-configurations.md @@ -0,0 +1,40 @@ +# Test Configurations + +Based on SOA principles of interoperability and standardized service contracts (SRC-25), the product must be tested across the configurations real users will have. + +## Browsers (must test all) +- Chrome (latest) – Windows, Mac, Linux +- Firefox (latest) +- Safari (latest) – Mac only +- Edge (latest) – Windows + +## Operating Systems +- Windows 10/11 +- macOS (Ventura, Sonoma, Sequoia) +- Ubuntu 22.04/24.04 + +## Devices and screen sizes +- Desktop (1920x1080) – primary target +- Laptop (1366x768) +- Tablet (iPad, Android) – numbers may be too small; document as limitation + +## Network conditions (for API health endpoint only) +- Fast (100 Mbps) +- Slow (3G throttled) – the Canvas renders client-side, so network mainly affects initial load + +## Screen color profiles (manual testing only) +- Standard RGB +- sRGB +- HDR modes (may shift colors) + +## Environmental conditions (manual) +- Bright sunlight (screen glare) +- Dark room (high contrast mode) + +## API contract versions (future) +If the product exposes a REST API, it should follow SOA standardized service contract principles. The current version uses form POSTs with implicit contract. Before release, the API should be documented (OpenAPI) and versioned. + +## Testing approach +- Automated cross-browser testing is not implemented due to time constraints. +- Manual testing covers Chrome, Firefox, and Safari on desktop. +- Load testing is automated with Locust (see specialized testing report). diff --git a/assignment-5-manual-testing.md b/assignment-5-manual-testing.md new file mode 100644 index 0000000..bacbfb0 --- /dev/null +++ b/assignment-5-manual-testing.md @@ -0,0 +1,18 @@ +# Manual Testing Required Beyond Automation (Assignment 5) + +## What cannot be automated (or was not automated) + +1. **Stress testing** – Requires manual observation of degradation patterns under extreme load. +2. **Cross-browser visual validation** – Automated tests can check that the canvas renders, but not that colors appear correct on different screens. +3. **Accessibility** – Keyboard navigation and screen reader compatibility require human testing. +4. **Environmental conditions** – Screen glare, dark room, and varying brightness levels cannot be simulated. + +## Manual test cases + +1. **Stress test** – Run `docker compose up`, then send 100 rapid requests to `/`. Observe if the server crashes or slows down. +2. **Cross-browser** – Test on Chrome, Firefox, Safari. Verify canvas rendering and number visibility. +3. **Session isolation** – Open two browser tabs, take the test in tab 1, then tab 2. Verify that sessions are independent. +4. **Statelessness check** – After completing the test, refresh the page. The user should have to start over. Document whether this is acceptable. +5. **Monitoring endpoint** – Visit `/debug/stats` and verify CPU/memory readings look plausible. +6. **Keyboard navigation** – Tab through all inputs and buttons. Verify you can submit with Enter. +7. **Screen reader** – Use NVDA (Windows) or VoiceOver (Mac) to navigate the test. Note any confusing announcements. diff --git a/assignment-5-specialized-testing.md b/assignment-5-specialized-testing.md new file mode 100644 index 0000000..75ae0c4 --- /dev/null +++ b/assignment-5-specialized-testing.md @@ -0,0 +1,78 @@ +# Specialized Testing Report + +## Load Testing (Automated) + +I implemented load testing using Locust. The test simulates 10 concurrent users submitting answers over 30 seconds. + +### How to run +```bash +cd mock-project +docker compose up --build -d +./run-load-test.sh +``` + +### Results (local run) +- Health endpoint: 100% success, average response <10ms +- Form submission: 100% success, average response ~150ms +- No crashes or timeouts observed + +### Limitation +This is a minimal load test. The Flask development server is single-threaded. A production deployment would need gunicorn or a WSGI server. + +## Stress Testing (Not Automated) + +I did not automate stress testing. The Flask development server is not designed for high load. This is documented as manual testing. + +## Scoring Algorithm Unit Tests (Automated) + +I added unit tests for: +- Health endpoint returns 200 OK +- Result page loads without crashing +- Debug stats endpoint returns CPU, memory, and session metrics + +All 3 tests pass. + +## Operational Monitoring (Implemented) + +I added a `/debug/stats` endpoint that returns: +- CPU percentage +- Memory percentage +- Active session count + +## SOA/REST Observations + +Following Fielding's REST constraints (SRC-3, SRC-27): + +- **Statelessness violation**: The current design uses server-side sessions. This creates a scalability bottleneck. A stateless design (client-side storage or JWTs) would be more aligned with REST principles. + +- **Service boundaries** (SRC-5): The application has implicit boundaries between the UI, scoring logic, and session store. Documenting these boundaries helps with integration testing. + +## Diagnostic Consistency Tracking + +While working on this assignment, I discovered an important gap in existing colorblindness tests. I took the Enchroma test three times over several months. It diagnosed me as Deutan twice and Protan once. A user who gets different results from the same test will not trust any of them. + +### Implementation + +I added consistency tracking to my own test. Users now: + +1. Log in with a username (no password required) +2. Take the test as normal +3. See their history and consistency score on the results page + +The system stores results in a JSON file and calculates: +- Total number of sessions +- Consistency percentage (how often the same diagnosis appears) +- Most common diagnosis +- Last 3 results + +### Automation + +This feature is fully automated. The test itself saves results, loads past history, and displays consistency without any manual intervention. + +### Value + +Twenty percent of users will experience inconsistent results, but that small group will generate eighty percent of complaints and lost trust. Focusing on stability across sessions is the highest-value specialized testing I added to my product. + +### Results + +I tested this by taking my own test multiple times with different usernames. The consistency tracking works correctly. Future work would involve user studies to see how often real users get inconsistent results and whether my test is more stable than Enchroma. diff --git a/mock-project/Dockerfile b/mock-project/Dockerfile new file mode 100644 index 0000000..6aa95d1 --- /dev/null +++ b/mock-project/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY backend/ . +CMD ["python", "app.py"] diff --git a/mock-project/README.md b/mock-project/README.md new file mode 100644 index 0000000..e779eec --- /dev/null +++ b/mock-project/README.md @@ -0,0 +1,155 @@ +# Color Vision Diagnostic Test + +A web-based Ishihara-style color vision test that diagnoses Protan (red deficiency), Deutan (green deficiency), and Tritan (blue deficiency) color blindness. The test tracks your history and shows consistency across multiple sessions. + +## Quick Start (for beginners) + +1. Install Docker from https://docs.docker.com/get-docker/ +2. Open a terminal in this folder (`mock-project`) +3. Run: `docker compose up --build -d` +4. Open your browser to: `http://localhost:5000` +5. To stop: `docker compose down` + +## Prerequisites + +- Docker installed on your machine +- Git (to clone the repository) +- Node.js and npm (for E2E tests only) + +## Getting Started + +Clone the repository and navigate to the mock-project folder: + +```bash +git clone https://github.com/pitekopaga/testing.git +cd testing/mock-project +``` + +## Run the Web UI + +Start the application: + +```bash +docker compose up --build -d +``` + +Open your browser and go to: `http://localhost:5000` + +## How to Take the Test + +1. Enter your name or email on the login screen. This saves your results for future sessions. +2. Click "Start Test". +3. A circle of colored dots will appear with a hidden number. +4. Type the number you see in the input box and press Enter, or click Submit. +5. If you see no number, click the **No Number** button. +6. Repeat for all plates (about 18 plates). +7. After the final plate, you will see your results with cone percentage scores. + +## Viewing Your History + +After taking the test multiple times with the same name, the results page will show: +- How many times you have taken the test +- Your consistency score (how often you get the same diagnosis) +- Your most common diagnosis +- A list of all your diagnoses from each session + +## Exporting Your Results + +Click the "Export All Results to CSV" button on the results page to download a file containing your complete test history with timestamps. + +## Exiting and Switching Users + +Click the "Exit" button to log out. The next person who uses the browser will be asked to enter their name before starting a new test. + +## Functional Requirements + +| ID | Requirement | Status | +|----|-------------|--------| +| FR-1 | Present Ishihara-style dot patterns with hidden numbers | Implemented | +| FR-2 | Accept user input as a number or "No Number" button | Implemented | +| FR-3 | Track answers across all test plates | Implemented | +| FR-4 | Calculate cone response percentages for red, green, and blue | Implemented | +| FR-5 | Diagnose Protan, Deutan, or Tritan color blindness | Implemented | +| FR-6 | Save results per user across sessions | Implemented | +| FR-7 | Show consistency score across multiple test sessions | Implemented | +| FR-8 | Export all user results to CSV with timestamps | Implemented | +| FR-9 | Allow user to reset and retake the test | Implemented | +| FR-10 | Allow user to log out and switch accounts | Implemented | + +## Non-Functional Requirements + +| ID | Requirement | Status | +|----|-------------|--------| +| NFR-1 | Test completes within 2 minutes for typical users | Implemented | +| NFR-2 | Plates are generated client-side using Canvas API | Implemented | +| NFR-3 | Application runs in Docker container | Implemented | +| NFR-4 | User data persists in JSON file between container restarts | Implemented | +| NFR-5 | Test provides results with +/- 13% variance disclaimer | Implemented | +| NFR-6 | Instructions are displayed on every test page | Implemented | +| NFR-7 | Progress indicator shows current plate number and total | Implemented | +| NFR-8 | Results page shows individual cone scores with progress bars | Implemented | + +## Run Automated Tests + +### Unit Tests + +```bash +docker compose exec api pytest unit_tests/ -v +``` + +### Integration Tests + +```bash +docker compose exec api pytest integration_tests/ -v +``` + +### E2E Tests (Playwright) + +```bash +npm install +npx playwright install chromium +npx playwright test +``` + +## Test the API manually with curl + +Health check: + +```bash +curl http://localhost:5000/health +``` + +Expected output: `{"status":"ok"}` + +## Stop the Application + +```bash +docker compose down +``` + +## Clearing Browser Data (if needed) + +If the test behaves unexpectedly, clear your browser data for localhost: + +**Chrome:** +1. Click the lock icon next to the address bar +2. Click "Cookies and site data" +3. Click "Manage cookies and site data" +4. Click the trash icon next to `localhost` +5. Refresh the page + +**Alternative:** Open an incognito/private browsing window. + +## Troubleshooting + +**Port 5000 is already in use:** Stop the process using port 5000, or change the port mapping in `docker-compose.yml`. + +**The test gives unexpected results:** Clear your browser data as described above. + +**My results were not saved:** Make sure you entered the same name each time. Names are case-sensitive. + +**Playwright tests fail:** Run `npx playwright install chromium` to ensure browsers are installed. + +## License + +MIT diff --git a/mock-project/backend/app.py b/mock-project/backend/app.py new file mode 100644 index 0000000..c5b02cc --- /dev/null +++ b/mock-project/backend/app.py @@ -0,0 +1,492 @@ +from flask import Flask, render_template_string, session, redirect, url_for, request, Response +import random +import psutil +import json +import os +from datetime import datetime +from collections import Counter + +app = Flask(__name__) +app.secret_key = 'your-secret-key-here' + +RESULTS_FILE = 'test_results.json' + +def save_result(username, diagnosis, scores): + """Save a user's test result to a JSON file.""" + data = {} + if os.path.exists(RESULTS_FILE): + with open(RESULTS_FILE, 'r') as f: + data = json.load(f) + + if username not in data: + data[username] = [] + + data[username].append({ + 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'diagnosis': diagnosis, + 'red_score': scores.get('red', 0), + 'green_score': scores.get('green', 0), + 'blue_score': scores.get('blue', 0) + }) + + with open(RESULTS_FILE, 'w') as f: + json.dump(data, f, indent=2) + +def get_consistency(username): + """Calculate consistency score for a user based on past results.""" + if not os.path.exists(RESULTS_FILE): + return None + + with open(RESULTS_FILE, 'r') as f: + data = json.load(f) + + if username not in data: + return None + + results = data[username] + if len(results) < 2: + return {'message': 'Take the test at least twice to see consistency', 'total_sessions': len(results)} + + # Compare the actual diagnosis strings + diagnoses = [r['diagnosis'] for r in results] + most_common = Counter(diagnoses).most_common(1)[0][0] + consistency_percent = (diagnoses.count(most_common) / len(diagnoses)) * 100 + + return { + 'total_sessions': len(results), + 'consistency_percent': round(consistency_percent, 1), + 'primary_diagnosis': most_common, + 'all_diagnoses': diagnoses + } + +def get_all_results(username): + """Get all results for a user for CSV export.""" + if not os.path.exists(RESULTS_FILE): + return [] + + with open(RESULTS_FILE, 'r') as f: + data = json.load(f) + + if username not in data: + return [] + + return data[username] + +# Number patterns +PATTERNS = { + 0: [[0,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[0,1,1,1,0]], + 1: [[0,0,1,0,0],[0,1,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,1,1,1,0]], + 2: [[0,1,1,1,0],[1,0,0,0,1],[0,0,1,1,0],[0,1,0,0,0],[1,1,1,1,1]], + 3: [[0,1,1,1,0],[1,0,0,0,1],[0,0,1,1,0],[1,0,0,0,1],[0,1,1,1,0]], + 4: [[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,1],[0,0,0,0,1],[0,0,0,0,1]], + 5: [[1,1,1,1,1],[1,0,0,0,0],[1,1,1,1,0],[0,0,0,0,1],[1,1,1,1,0]], + 6: [[0,1,1,1,0],[1,0,0,0,0],[1,1,1,1,0],[1,0,0,0,1],[0,1,1,1,0]], + 7: [[1,1,1,1,1],[0,0,0,0,1],[0,0,1,1,0],[0,1,0,0,0],[1,0,0,0,0]], + 8: [[0,1,1,1,0],[1,0,0,0,1],[0,1,1,1,0],[1,0,0,0,1],[0,1,1,1,0]], + 9: [[0,1,1,1,0],[1,0,0,0,1],[0,1,1,1,1],[0,0,0,0,1],[0,1,1,1,0]], +} + +def get_pattern(num): + if num < 10: + return PATTERNS.get(num, PATTERNS[0]) + tens = num // 10 + ones = num % 10 + p1 = PATTERNS.get(tens, PATTERNS[0]) + p2 = PATTERNS.get(ones, PATTERNS[0]) + combined = [] + for i in range(5): + row = p1[i] + [0] + p2[i] + combined.append(row) + return combined + +def make_plate(plate_type, number): + if plate_type == 'protan': + base_red = random.randint(80, 110) + base_green = random.randint(80, 110) + bg = [base_red, base_green, random.randint(60, 90)] + fg = [base_red + random.randint(-10, 10), base_green + random.randint(-10, 10), random.randint(60, 90)] + elif plate_type == 'deutan': + bg = [random.randint(70, 100), random.randint(120, 150), random.randint(70, 100)] + fg = [random.randint(120, 150), random.randint(40, 70), random.randint(70, 100)] + elif plate_type == 'tritan': + bg = [120, 120, 120] + fg = [40, 40, 200] + else: + bg = [60, 60, 60] + fg = [220, 220, 220] + + for i in range(3): + bg[i] = max(30, min(230, bg[i])) + fg[i] = max(30, min(230, fg[i])) + + return { + 'num': number, + 'type': plate_type, + 'bg': bg, + 'fg': fg, + 'pattern': get_pattern(number) + } + +HTML = ''' + + +
+Plate {{ idx }} of {{ total }}
+ + + + {% else %} +{{ diagnosis }}
+{{ description }}
+ +You have taken this test {{ consistency.total_sessions }} time(s).
+ {% if consistency.consistency_percent %} +Consistency: {{ consistency.consistency_percent }}%
+Most common diagnosis: {{ consistency.primary_diagnosis }}
+All diagnoses: {{ consistency.all_diagnoses|join(', ') }}
+ {% else %} +{{ consistency.message }}
+ {% endif %} +Your results will be saved to track consistency over time.
+ + + ''' + +@app.route('/test', methods=['GET', 'POST']) +def test(): + if 'username' not in session: + return redirect(url_for('login')) + + if not session.get('initialized'): + plates = [] + for q in PROTAN_QUESTIONS: + plates.append(make_plate('protan', q)) + for q in DEUTAN_QUESTIONS: + plates.append(make_plate('deutan', q)) + for q in TRITAN_QUESTIONS: + plates.append(make_plate('tritan', q)) + for q in CONTROL_QUESTIONS: + plates.append(make_plate('control', q)) + random.shuffle(plates) + + session['plates'] = plates + session['answers'] = [] + session['step'] = 0 + session['initialized'] = True + + if request.method == 'POST': + if 'skip' in request.form: + user_number = 0 + else: + ans = request.form.get('answer', '0') + try: + user_number = int(ans) + except: + user_number = 0 + + step = session.get('step', 0) + plates = session.get('plates', []) + + if step < len(plates): + answers = session.get('answers', []) + answers.append({ + 'user': user_number, + 'correct': plates[step]['num'], + 'type': plates[step]['type'] + }) + session['answers'] = answers + session['step'] = step + 1 + + if session.get('step', 0) >= len(plates): + return redirect(url_for('result')) + + step = session.get('step', 0) + plates = session.get('plates', []) + + if step >= len(plates) or not plates: + return redirect(url_for('result')) + + p = plates[step] + return render_template_string(HTML, + pattern=p['pattern'], + bg=p['bg'], + fg=p['fg'], + idx=step+1, + total=len(plates), + pct=(step/len(plates))*100, + done=False) + +@app.route('/result') +def result(): + answers = session.get('answers', []) + plates = session.get('plates', []) + + protan_correct = 0 + protan_total = 0 + deutan_correct = 0 + deutan_total = 0 + tritan_correct = 0 + tritan_total = 0 + + for i, a in enumerate(answers): + if i >= len(plates): + continue + plate_type = plates[i]['type'] + is_correct = (a['user'] == a['correct']) + + if plate_type == 'protan': + protan_total += 1 + if is_correct: + protan_correct += 1 + elif plate_type == 'deutan': + deutan_total += 1 + if is_correct: + deutan_correct += 1 + elif plate_type == 'tritan': + tritan_total += 1 + if is_correct: + tritan_correct += 1 + + red_score = round((protan_correct / max(protan_total, 1)) * 100) + green_score = round((deutan_correct / max(deutan_total, 1)) * 100) + blue_score = round((tritan_correct / max(tritan_total, 1)) * 100) + + scores = {'red': red_score, 'green': green_score, 'blue': blue_score} + min_type = min(scores, key=scores.get) + + if scores[min_type] < 60: + if min_type == 'red': + diagnosis = "Protan Color Blind" + description = "You have a stronger deficiency in your red color cone, which means you have a type of red-green color blindness called Protan." + elif min_type == 'green': + diagnosis = "Deutan Color Blind" + description = "You have a stronger deficiency in your green color cone, which means you have a type of red-green color blindness called Deutan." + else: + diagnosis = "Tritan Color Blind" + description = "You have a deficiency in your blue color cone, which means you have blue-yellow color blindness called Tritan." + else: + diagnosis = "Normal Color Vision" + description = "Your color vision appears normal within the range of this test." + + username = session.get('username') + if username: + result_scores = {'red': red_score, 'green': green_score, 'blue': blue_score} + save_result(username, diagnosis, result_scores) + consistency = get_consistency(username) + else: + consistency = None + + return render_template_string(HTML, + done=True, + red_score=red_score, + green_score=green_score, + blue_score=blue_score, + diagnosis=diagnosis, + description=description, + consistency=consistency) + +@app.route('/export-csv') +def export_csv(): + username = session.get('username') + if not username: + return redirect(url_for('login')) + + results = get_all_results(username) + if not results: + return "No results found", 404 + + # Create CSV content + csv_lines = ["Timestamp,Diagnosis,Red Score (Protan),Green Score (Deutan),Blue Score (Tritan)"] + for r in results: + csv_lines.append(f"{r['timestamp']},{r['diagnosis']},{r['red_score']},{r['green_score']},{r['blue_score']}") + + csv_content = "\n".join(csv_lines) + + return Response( + csv_content, + mimetype="text/csv", + headers={"Content-disposition": f"attachment; filename=color_vision_history_{username}.csv"} + ) + +@app.route('/reset', methods=['POST']) +def reset(): + session.pop('answers', None) + session.pop('step', None) + session.pop('plates', None) + session.pop('initialized', None) + return redirect(url_for('test')) + +@app.route('/logout', methods=['POST']) +def logout(): + session.clear() + return redirect(url_for('login')) + +@app.route('/health') +def health(): + return {'status': 'ok'} + +@app.route('/debug/stats') +def debug_stats(): + return { + 'status': 'ok', + 'cpu_percent': psutil.cpu_percent(interval=0.1), + 'memory_percent': psutil.virtual_memory().percent, + 'active_sessions': len(session.keys()) if session else 0 + } + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/mock-project/backend/requirements.txt b/mock-project/backend/requirements.txt new file mode 100644 index 0000000..4994621 --- /dev/null +++ b/mock-project/backend/requirements.txt @@ -0,0 +1,5 @@ +flask +pytest +requests +locust +psutil diff --git a/mock-project/backend/unit_tests/test_scoring.py b/mock-project/backend/unit_tests/test_scoring.py new file mode 100644 index 0000000..3637bd5 --- /dev/null +++ b/mock-project/backend/unit_tests/test_scoring.py @@ -0,0 +1,37 @@ +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest +from app import app + +@pytest.fixture +def client(): + """Create a test client for the Flask app.""" + app.config['TESTING'] = True + with app.test_client() as client: + with app.app_context(): + pass + yield client + +def test_result_page_returns_200(client): + """Test that the result page loads without crashing.""" + # The result page redirects to index if no session, but should not crash + response = client.get('/result') + assert response.status_code in [200, 302] + +def test_health_endpoint_returns_ok(client): + """Test that the health endpoint works.""" + response = client.get('/health') + assert response.status_code == 200 + assert response.json == {'status': 'ok'} + +def test_debug_stats_endpoint_returns_stats(client): + """Test that the debug stats endpoint returns expected fields.""" + response = client.get('/debug/stats') + assert response.status_code == 200 + data = response.json + assert 'status' in data + assert 'cpu_percent' in data + assert 'memory_percent' in data + assert 'active_sessions' in data diff --git a/mock-project/docker-compose.yml b/mock-project/docker-compose.yml new file mode 100644 index 0000000..7e8fa42 --- /dev/null +++ b/mock-project/docker-compose.yml @@ -0,0 +1,5 @@ +services: + api: + build: . + ports: + - "5000:5000" diff --git a/mock-project/e2e_tests/colorblind.spec.js b/mock-project/e2e_tests/colorblind.spec.js new file mode 100644 index 0000000..4de383d --- /dev/null +++ b/mock-project/e2e_tests/colorblind.spec.js @@ -0,0 +1,73 @@ +const { test, expect } = require('@playwright/test'); + +test('login screen loads', async ({ page }) => { + await page.goto('http://localhost:5000'); + await expect(page.locator('h1')).toContainText('Color Vision Diagnostic Test'); + await expect(page.locator('input[name="username"]')).toBeVisible(); +}); + +test('user can log in and start test', async ({ page }) => { + await page.goto('http://localhost:5000'); + await page.fill('input[name="username"]', 'testuser'); + await page.click('button[type="submit"]'); + await expect(page.locator('canvas')).toBeVisible(); + await expect(page.locator('input[name="answer"]')).toBeVisible(); +}); + +test('user can submit answers and complete the test', async ({ page }) => { + await page.goto('http://localhost:5000'); + await page.fill('input[name="username"]', 'testuser'); + await page.click('button[type="submit"]'); + + // Get total number of plates + const totalText = await page.locator('p').first().textContent(); + const totalMatch = totalText.match(/of (\d+)/); + const totalPlates = totalMatch ? parseInt(totalMatch[1]) : 18; + + // Submit answers for all plates + for (let i = 0; i < totalPlates; i++) { + await page.click('button[name="skip"]'); + await page.waitForLoadState('networkidle'); + } + + // Should reach results page + await expect(page.locator('h2')).toContainText('Your Color Blind Test Result'); +}); + +test('results page shows cone scores', async ({ page }) => { + await page.goto('http://localhost:5000'); + await page.fill('input[name="username"]', 'testuser2'); + await page.click('button[type="submit"]'); + + const totalText = await page.locator('p').first().textContent(); + const totalMatch = totalText.match(/of (\d+)/); + const totalPlates = totalMatch ? parseInt(totalMatch[1]) : 18; + + for (let i = 0; i < totalPlates; i++) { + await page.click('button[name="skip"]'); + await page.waitForLoadState('networkidle'); + } + + await expect(page.locator('.score').first()).toBeVisible(); +}); + +test('exit button logs out and returns to login', async ({ page }) => { + await page.goto('http://localhost:5000'); + await page.fill('input[name="username"]', 'testuser3'); + await page.click('button[type="submit"]'); + + const totalText = await page.locator('p').first().textContent(); + const totalMatch = totalText.match(/of (\d+)/); + const totalPlates = totalMatch ? parseInt(totalMatch[1]) : 18; + + for (let i = 0; i < totalPlates; i++) { + await page.click('button[name="skip"]'); + await page.waitForLoadState('networkidle'); + } + + // Click the Exit button (the logout button) + await page.click('form[action="/logout"] button'); + + // Should return to login screen + await expect(page.locator('input[name="username"]')).toBeVisible(); +}); diff --git a/mock-project/load_tests/locustfile.py b/mock-project/load_tests/locustfile.py new file mode 100644 index 0000000..970294c --- /dev/null +++ b/mock-project/load_tests/locustfile.py @@ -0,0 +1,18 @@ +from locust import HttpUser, task, between + +class ColorVisionUser(HttpUser): + wait_time = between(1, 3) + + @task + def health_check(self): + self.client.get("/health") + + @task(3) + def submit_plate(self): + # Simulate submitting a "No Number" answer + self.client.post("/", data={"skip": "skip"}) + + @task(1) + def submit_number(self): + # Simulate submitting a number answer + self.client.post("/", data={"answer": "12"}) diff --git a/mock-project/package-lock.json b/mock-project/package-lock.json new file mode 100644 index 0000000..6639c43 --- /dev/null +++ b/mock-project/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "colorblind-test", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "colorblind-test", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.60.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/mock-project/package.json b/mock-project/package.json new file mode 100644 index 0000000..ef0a0c2 --- /dev/null +++ b/mock-project/package.json @@ -0,0 +1,10 @@ +{ + "name": "colorblind-test", + "version": "1.0.0", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.60.0" + } +} diff --git a/mock-project/run-load-test.sh b/mock-project/run-load-test.sh new file mode 100755 index 0000000..7b0eb5c --- /dev/null +++ b/mock-project/run-load-test.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Run load test with 10 concurrent users for 30 seconds +echo "Starting load test with 10 concurrent users for 30 seconds" +locust -f load_tests/locustfile.py --headless -u 10 -r 2 --run-time 30s --host=http://localhost:5000