diff --git a/backend/.gitignore b/backend/.gitignore index 8ef3470..7b45fd8 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -38,3 +38,10 @@ logs/ # ──── OS ───────────────────────────────────── .DS_Store Thumbs.db + +# --- Python --- +__pycache__/ +*.pyc +*.pyo +*.pyd + diff --git a/backend/api/v1/__pycache__/__init__.cpython-313.pyc b/backend/api/v1/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 3059b5c..0000000 Binary files a/backend/api/v1/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/api/v1/endpoints/__pycache__/__init__.cpython-313.pyc b/backend/api/v1/endpoints/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 7df6f83..0000000 Binary files a/backend/api/v1/endpoints/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/api/v1/endpoints/__pycache__/agents.cpython-313.pyc b/backend/api/v1/endpoints/__pycache__/agents.cpython-313.pyc deleted file mode 100644 index f6c61fa..0000000 Binary files a/backend/api/v1/endpoints/__pycache__/agents.cpython-313.pyc and /dev/null differ diff --git a/backend/api/v1/endpoints/__pycache__/auth.cpython-313.pyc b/backend/api/v1/endpoints/__pycache__/auth.cpython-313.pyc deleted file mode 100644 index fffa288..0000000 Binary files a/backend/api/v1/endpoints/__pycache__/auth.cpython-313.pyc and /dev/null differ diff --git a/backend/api/v1/endpoints/__pycache__/catalog.cpython-313.pyc b/backend/api/v1/endpoints/__pycache__/catalog.cpython-313.pyc deleted file mode 100644 index fa4b7b6..0000000 Binary files a/backend/api/v1/endpoints/__pycache__/catalog.cpython-313.pyc and /dev/null differ diff --git a/backend/api/v1/endpoints/__pycache__/collisions.cpython-313.pyc b/backend/api/v1/endpoints/__pycache__/collisions.cpython-313.pyc deleted file mode 100644 index e7a3898..0000000 Binary files a/backend/api/v1/endpoints/__pycache__/collisions.cpython-313.pyc and /dev/null differ diff --git a/backend/api/v1/endpoints/__pycache__/dashboard.cpython-313.pyc b/backend/api/v1/endpoints/__pycache__/dashboard.cpython-313.pyc deleted file mode 100644 index f6217d6..0000000 Binary files a/backend/api/v1/endpoints/__pycache__/dashboard.cpython-313.pyc and /dev/null differ diff --git a/backend/api/v1/endpoints/__pycache__/satellites.cpython-313.pyc b/backend/api/v1/endpoints/__pycache__/satellites.cpython-313.pyc deleted file mode 100644 index 4c95a7e..0000000 Binary files a/backend/api/v1/endpoints/__pycache__/satellites.cpython-313.pyc and /dev/null differ diff --git a/backend/api/v1/endpoints/__pycache__/weather.cpython-313.pyc b/backend/api/v1/endpoints/__pycache__/weather.cpython-313.pyc deleted file mode 100644 index 10f3c89..0000000 Binary files a/backend/api/v1/endpoints/__pycache__/weather.cpython-313.pyc and /dev/null differ diff --git a/backend/app/__pycache__/__init__.cpython-313.pyc b/backend/app/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index a5bee04..0000000 Binary files a/backend/app/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc deleted file mode 100644 index 4673fac..0000000 Binary files a/backend/app/__pycache__/main.cpython-313.pyc and /dev/null differ diff --git a/backend/app/core/__pycache__/__init__.cpython-313.pyc b/backend/app/core/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index a6fd6d4..0000000 Binary files a/backend/app/core/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/app/core/__pycache__/config.cpython-313.pyc b/backend/app/core/__pycache__/config.cpython-313.pyc deleted file mode 100644 index d44c082..0000000 Binary files a/backend/app/core/__pycache__/config.cpython-313.pyc and /dev/null differ diff --git a/backend/app/core/__pycache__/scheduler.cpython-313.pyc b/backend/app/core/__pycache__/scheduler.cpython-313.pyc deleted file mode 100644 index 2b5cbec..0000000 Binary files a/backend/app/core/__pycache__/scheduler.cpython-313.pyc and /dev/null differ diff --git a/backend/app/core/__pycache__/security.cpython-313.pyc b/backend/app/core/__pycache__/security.cpython-313.pyc deleted file mode 100644 index 89dc62a..0000000 Binary files a/backend/app/core/__pycache__/security.cpython-313.pyc and /dev/null differ diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..dfb18f1 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "backend", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/backend/scripts/seed_db.py b/backend/scripts/seed_db.py deleted file mode 100644 index 0997340..0000000 --- a/backend/scripts/seed_db.py +++ /dev/null @@ -1,454 +0,0 @@ -#!/usr/bin/env python -""" -Kepler Database Seeding Script -=============================== -Populates the MongoDB database with realistic sample satellite, organization, -telemetry, and conjunction records for local development and testing. -""" - -import os -import sys -import argparse -import random -import datetime -import math - -# Add backend directory to system path to resolve imports correctly -current_dir = os.path.dirname(os.path.abspath(__file__)) -backend_dir = os.path.dirname(current_dir) -if backend_dir not in sys.path: - sys.path.insert(0, backend_dir) - -from app.core.config import settings -from database.session import SessionLocal, get_mongo_client, uri_db_name -from models.db_models import ( - Satellite, - Debris, - SpaceObject, - Organization, - Telemetry, - CollisionPrediction, - Role, - User -) - -# Realistic satellite names by country -SATELLITE_NAMES = { - "US": ["GPS", "IRIDIUM", "STARLINK", "LANDSAT", "NOAA", "AURA", "TERRA", "GOES", "AEOLUS"], - "IN": ["CARTOSAT", "RESOURCESAT", "INSAT", "RISAT", "GSAT", "EOS", "OCEANSAT", "MOM"], - "CN": ["YAOGAN", "BEIDOU", "SHIYAN", "FENGYUN", "GAOFEN", "TIANHUI", "QUEQIAO"], - "RU": ["COSMOS", "GLONASS", "SOYUZ", "METEOR", "RESURS", "ELEKTRO", "LUCH"], - "ESA": ["SENTINEL", "METEOSAT", "ENVISAT", "CRYOSAT", "PROBA", "SOLAR-ORBITER"], - "JP": ["ALOS", "HIMAWARI", "GCOM", "ASNARO", "QZSS", "IBUKI", "ARASE"], - "CA": ["RADARSAT", "CASSIOPE", "SCISAT", "M3MSAT"], -} - -DEBRIS_NAMES = [ - "FENGYUN 1C DEBRIS", - "COSMOS 2251 DEBRIS", - "IRIDIUM 33 DEBRIS", - "DELTA 2 ROCKET BODY DEBRIS", - "TITAN 3C DEBRIS", - "CZ-4B DEBRIS", - "PEASASAT DEBRIS", - "SL-8 ROCKET BODY DEBRIS", - "ASAT TEST DEBRIS", - "SPUTNIK DEBRIS", -] - - -def calculate_semimajor_axis(mean_motion_revday: float) -> float: - """Convert mean motion (rev/day) to semi-major axis (km).""" - if mean_motion_revday <= 0: - return 6778.0 - n_rads = mean_motion_revday * 2 * math.pi / 86400.0 - return (398600.4418 / (n_rads ** 2)) ** (1.0 / 3.0) - - -def generate_tle(norad_id: str, incl: float, ecc: float, mean_motion: float) -> tuple[str, str]: - """Generate mock TLE lines with valid checksums for a given satellite profile.""" - nid = str(norad_id).zfill(5) - - # Generate international designator (COSPAR ID) - year = str(random.randint(15, 26)) - launch_num = str(random.randint(1, 99)).zfill(3) - piece = random.choice(["A", "B", "C", "D"]) - cospar = f"{year}{launch_num}{piece}" - - # Generate epoch day - epoch_day = f"{random.randint(1, 365):03d}.{random.randint(10000000, 99999999)}" - - def calculate_checksum(line: str) -> int: - c = 0 - for char in line[:68]: - if char.isdigit(): - c += int(char) - elif char == "-": - c += 1 - return c % 10 - - # Line 1 format - line1 = f"1 {nid}U {cospar:<8} {year}{epoch_day:>12} .00002182 00000-0 10000-3 0 999" - line1 = line1[:68].ljust(68) - line1 = f"{line1}{calculate_checksum(line1)}" - - # Line 2 format - raan = random.uniform(0, 360) - arg_p = random.uniform(0, 360) - mean_anomaly = random.uniform(0, 360) - ecc_str = f"{int(ecc * 10000000):07d}"[:7] - rev_num = random.randint(1000, 99999) - - line2 = f"2 {nid} {incl:8.4f} {raan:8.4f} {ecc_str} {arg_p:8.4f} {mean_anomaly:8.4f} {mean_motion:11.8f}{rev_num:5d}" - line2 = line2[:68].ljust(68) - line2 = f"{line2}{calculate_checksum(line2)}" - - return line1, line2 - - -def seed_database(count: int, clear: bool): - """Seed the database with organizations, satellites, debris, telemetry, and conjunctions.""" - db = None - try: - print("🔌 Connecting to MongoDB...") - db = SessionLocal() - client = get_mongo_client() - db_name = uri_db_name(settings.MONGODB_URI) - mongo_db = client[db_name] - print(f"✅ Successfully connected to MongoDB database: '{db_name}'") - - if clear: - print("🧹 Clearing existing database collections...") - collections_to_clear = [ - "satellites", - "debris", - "orbitalElements", - "telemetry", - "conjunctions", - "organizations" - ] - for col_name in collections_to_clear: - mongo_db[col_name].delete_many({}) - # Reset counters - mongo_db["counters"].delete_one({"_id": col_name}) - print(f" - Cleared collection '{col_name}' and its auto-increment counters") - - # 1. Seed Organizations - print("🏢 Seeding default organizations...") - orgs_to_create = [ - {"name": "NASA", "description": "National Aeronautics and Space Administration"}, - {"name": "ISRO", "description": "Indian Space Research Organisation"}, - {"name": "ESA", "description": "European Space Agency"}, - {"name": "SpaceX", "description": "Space Exploration Technologies Corp."}, - {"name": "OneWeb", "description": "Eutelsat OneWeb Satellite Network"}, - ] - - seeded_orgs = [] - for org_data in orgs_to_create: - # Check if already exists - existing = db.query(Organization).filter(Organization.name == org_data["name"]).first() - if not existing: - org = Organization( - name=org_data["name"], - description=org_data["description"], - created_at=datetime.datetime.utcnow().isoformat() - ) - db.add(org) - db.commit() - db.refresh(org) - seeded_orgs.append(org) - print(f" + Created organization: {org.name} (ID: {org.id})") - else: - seeded_orgs.append(existing) - print(f" • Organization {existing.name} already exists (ID: {existing.id})") - - # Helper list of generated norad IDs to ensure uniqueness - used_norad_ids = set() - - # Pre-load existing NORAD IDs from the database if not clearing to avoid unique index crashes - if not clear: - print("🔍 Pre-loading existing NORAD IDs from database to avoid duplicates...") - try: - for sat_doc in mongo_db["satellites"].find({}, {"noradId": 1}): - if sat_doc.get("noradId"): - used_norad_ids.add(sat_doc["noradId"]) - for debris_doc in mongo_db["debris"].find({}, {"noradId": 1}): - if debris_doc.get("noradId"): - used_norad_ids.add(debris_doc["noradId"]) - print(f" - Loaded {len(used_norad_ids)} existing NORAD IDs.") - except Exception as e: - print(f" ⚠️ Warning: Could not pre-load existing NORAD IDs: {e}") - - # 2. Seed Satellites - print(f"🛰️ Generating {count} satellite records...") - satellites_list = [] - for i in range(count): - # Choose country and name - country = random.choice(list(SATELLITE_NAMES.keys())) - prefix = random.choice(SATELLITE_NAMES[country]) - suffix = f"-{random.randint(100, 999)}" if prefix in ["STARLINK", "SENTINEL", "YAOGAN"] else f" {random.randint(1, 20)}" - name = f"{prefix}{suffix}" - - # Unique NORAD ID - norad_id = str(random.randint(10000, 49999)) - while norad_id in used_norad_ids: - norad_id = str(random.randint(10000, 49999)) - used_norad_ids.add(norad_id) - - # Orbital elements - inclination = random.uniform(50.0, 105.0) # Common LEO inclinations - eccentricity = random.uniform(0.0001, 0.01) # Near circular LEO orbits - mean_motion = random.uniform(14.0, 16.2) # 14 to 16.2 orbits per day - semimajor = calculate_semimajor_axis(mean_motion) - period = 1440.0 / mean_motion - epoch = datetime.datetime.utcnow() - datetime.timedelta(days=random.uniform(0, 5)) - launch_date = (datetime.date.today() - datetime.timedelta(days=random.randint(100, 5000))).isoformat() - - tle1, tle2 = generate_tle(norad_id, inclination, eccentricity, mean_motion) - - # Link to a random organization - org = random.choice(seeded_orgs) - - # Create SpaceObject first to replicate ORM relationship correctly - space_obj = SpaceObject( - noradId=norad_id, - objectName=name, - objectType="PAYLOAD", - cospar_id=f"{random.randint(2010, 2026)}-{random.randint(1, 100):03d}{random.choice(['A', 'B', 'C'])}", - epoch=epoch.isoformat(), - inclination=inclination, - eccentricity=eccentricity, - semimajor_axis=semimajor, - period=period, - mean_motion=mean_motion, - tle_line1=tle1, - tle_line2=tle2, - updated_at=datetime.datetime.utcnow().isoformat() - ) - db.add(space_obj) - db.commit() - db.refresh(space_obj) - - # Now create Satellite linked to SpaceObject and Organization - # Make the first satellite deterministically ACTIVE so telemetry tests pass without flakiness - status_val = "ACTIVE" if i == 0 else random.choice(["ACTIVE", "ACTIVE", "ACTIVE", "DEGRADED", "INACTIVE"]) - sat = Satellite( - noradId=norad_id, - objectName=name, - objectType="PAYLOAD", - countryCode=country, - launchDate=launch_date, - epoch=epoch.isoformat(), - inclination=inclination, - eccentricity=eccentricity, - meanMotion=mean_motion, - source="seed_script", - createdAt=datetime.datetime.utcnow().isoformat(), - updatedAt=datetime.datetime.utcnow().isoformat(), - space_object_id=space_obj.id, - organization_id=org.id, - status=status_val, - fuel_percentage=random.uniform(15.0, 100.0), - dry_mass=random.uniform(150.0, 1800.0), - propellant_mass=random.uniform(20.0, 800.0), - operational_mode=random.choice(["NORMAL", "NORMAL", "NORMAL", "SAFE", "STANDBY"]), - semimajor_axis=semimajor, - period=period, - tle_line1=tle1, - tle_line2=tle2 - ) - db.add(sat) - db.commit() - db.refresh(sat) - satellites_list.append(sat) - - print(f" + Seeded {len(satellites_list)} satellite and space object records.") - - # 3. Seed Telemetry - print("📈 Seeding telemetry log history for satellites...") - telemetry_count = 0 - for sat in satellites_list: - # Generate 5 telemetry points over the last 50 minutes for each active satellite - if sat.status in ["ACTIVE", "DEGRADED"]: - base_altitude = sat.semimajor_axis - 6378.1 # Semi-major axis minus Earth radius - base_velocity = 7.5 # Typical LEO velocity in km/s - - for j in range(5): - ts = datetime.datetime.utcnow() - datetime.timedelta(minutes=10 * j) - tel = Telemetry( - satellite_id=str(sat.id), - timestamp=ts, - altitude_km=base_altitude + random.uniform(-2.0, 2.0), - velocity_kms=base_velocity + random.uniform(-0.1, 0.1), - temperature_c=random.uniform(-5.0, 35.0), - battery_charge=max(0.0, min(100.0, sat.fuel_percentage + random.uniform(-5.0, 5.0))), - neural_load=random.uniform(10.0, 90.0) - ) - db.add(tel) - telemetry_count += 1 - db.commit() - print(f" + Seeded {telemetry_count} telemetry data points.") - - # 4. Seed Debris - debris_count = max(5, count // 2) - print(f"☄️ Generating {debris_count} space debris records...") - debris_list = [] - for i in range(debris_count): - # Choose debris name - base_name = random.choice(DEBRIS_NAMES) - name = f"{base_name} [#{random.randint(1000, 9999)}]" - - # Unique NORAD ID - norad_id = str(random.randint(50000, 89999)) - while norad_id in used_norad_ids: - norad_id = str(random.randint(50000, 89999)) - used_norad_ids.add(norad_id) - - inclination = random.uniform(50.0, 110.0) - eccentricity = random.uniform(0.0005, 0.05) - mean_motion = random.uniform(13.5, 16.5) - semimajor = calculate_semimajor_axis(mean_motion) - period = 1440.0 / mean_motion - epoch = datetime.datetime.utcnow() - datetime.timedelta(days=random.uniform(0, 10)) - - tle1, tle2 = generate_tle(norad_id, inclination, eccentricity, mean_motion) - - # Create SpaceObject for Debris - space_obj = SpaceObject( - noradId=norad_id, - objectName=name, - objectType="DEBRIS", - cospar_id=None, - epoch=epoch.isoformat(), - inclination=inclination, - eccentricity=eccentricity, - semimajor_axis=semimajor, - period=period, - mean_motion=mean_motion, - tle_line1=tle1, - tle_line2=tle2, - updated_at=datetime.datetime.utcnow().isoformat() - ) - db.add(space_obj) - db.commit() - db.refresh(space_obj) - - # Create Debris record linked to SpaceObject - deb = Debris( - noradId=norad_id, - objectName=name, - epoch=epoch.isoformat(), - inclination=inclination, - eccentricity=eccentricity, - meanMotion=mean_motion, - source="seed_script", - createdAt=datetime.datetime.utcnow().isoformat(), - space_object_id=space_obj.id, - size_category=random.choice(["SMALL", "MEDIUM", "LARGE"]), - radar_cross_section=random.uniform(0.01, 15.0), - average_mass=random.uniform(0.5, 200.0), - semimajor_axis=semimajor, - period=period, - tle_line1=tle1, - tle_line2=tle2 - ) - db.add(deb) - db.commit() - db.refresh(deb) - debris_list.append(deb) - - print(f" + Seeded {len(debris_list)} space debris records.") - - # 5. Seed Conjunctions (Collisions) - print("💥 Seeding sample orbital conjunctions (collisions)...") - conjunctions_count = 0 - # Create up to 5 conjunction events between seeded satellites and debris - num_conjunctions = min(5, len(satellites_list), len(debris_list)) - - for i in range(num_conjunctions): - sat = satellites_list[i] - deb = debris_list[i] - - miss_distance = random.uniform(10.0, 800.0) - prob = random.uniform(0.001, 0.45) - # Determine risk level based on miss distance & probability - if miss_distance < 50.0 or prob > 0.1: - risk_level = "CRITICAL" - elif miss_distance < 150.0 or prob > 0.01: - risk_level = "HIGH" - elif miss_distance < 500.0 or prob > 0.001: - risk_level = "MEDIUM" - else: - risk_level = "LOW" - - tca = datetime.datetime.utcnow() + datetime.timedelta(hours=random.uniform(4, 72)) - - conj = CollisionPrediction( - primaryObject=sat.noradId, - secondaryObject=deb.noradId, - missDistance=miss_distance, - riskScore=prob, - conjunctionTime=tca, - createdAt=datetime.datetime.utcnow(), - object_a_id=sat.space_object_id, - object_b_id=deb.space_object_id, - probability=prob, - tca=tca, - miss_distance_m=miss_distance, - relative_velocity_kms=random.uniform(8.0, 15.0), - risk_level=risk_level, - status="PENDING", - created_at=datetime.datetime.utcnow() - ) - db.add(conj) - conjunctions_count += 1 - - db.commit() - print(f" + Seeded {conjunctions_count} collision conjunction alerts.") - - print("\n🎉 Database Seeding Completed Successfully!") - print(f" - Seeded {len(seeded_orgs)} Organizations") - print(f" - Seeded {len(satellites_list)} Satellites") - print(f" - Seeded {telemetry_count} Telemetry Records") - print(f" - Seeded {len(debris_list)} Debris Records") - print(f" - Seeded {conjunctions_count} Conjunction Predictions") - - except Exception as e: - print(f"❌ Error during database seeding: {e}") - raise e - finally: - if db: - db.close() - - -def main(): - parser = argparse.ArgumentParser( - description="Seed Kepler MongoDB database with sample satellite, organization, and collision data." - ) - parser.add_argument( - "--count", "-c", - type=int, - default=50, - help="Number of satellites to generate (default: 50)" - ) - parser.add_argument( - "--clear", "-x", - action="store_true", - help="Clear existing satellite, debris, telemetry, and conjunction records before seeding" - ) - args = parser.parse_args() - - # Validate count - if args.count <= 0: - print("❌ Error: Count must be a positive integer.") - sys.exit(1) - - try: - seed_database(count=args.count, clear=args.clear) - except Exception as e: - print(f"❌ Seeding process failed: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/backend/tests/__pycache__/test_backend.cpython-312-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_backend.cpython-312-pytest-9.1.1.pyc deleted file mode 100644 index c343777..0000000 Binary files a/backend/tests/__pycache__/test_backend.cpython-312-pytest-9.1.1.pyc and /dev/null differ diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py deleted file mode 100644 index aeaf5f8..0000000 --- a/backend/tests/test_seed.py +++ /dev/null @@ -1,163 +0,0 @@ -import pytest -from unittest.mock import MagicMock, patch -import datetime - -from scripts.seed_db import seed_database, calculate_semimajor_axis, generate_tle - - -class FakeCollection: - def __init__(self): - self.docs = [] - - def delete_many(self, filter_dict): - self.docs = [] - - def insert_one(self, doc): - self.docs.append(doc) - - def find_one_and_update(self, filter, update, **kwargs): - # Mock counter generator - return {"seq": len(self.docs) + 1} - - def delete_one(self, filter_dict): - pass - - def find(self, filter_dict=None, projection=None): - return self.docs - - -class FakeDb: - def __init__(self): - self.collections = { - "satellites": FakeCollection(), - "debris": FakeCollection(), - "orbitalElements": FakeCollection(), - "telemetry": FakeCollection(), - "conjunctions": FakeCollection(), - "organizations": FakeCollection(), - "counters": FakeCollection(), - } - self.db = self.collections - self.client = MagicMock() - self.client.admin.command.return_value = {"ok": 1} - - def __getitem__(self, name): - return self.collections[name] - - -class FakeSession: - def __init__(self, fake_db): - self.db = fake_db - self.pending_adds = [] - - def query(self, model_class): - # Simple mock query object - query_mock = MagicMock() - query_mock.filter.return_value.first.return_value = None - query_mock.first.return_value = None - return query_mock - - def add(self, obj): - obj._session = self - self.pending_adds.append(obj) - - def commit(self): - for obj in self.pending_adds: - col_name = obj.__tablename__ - doc = obj._to_dict() - if obj.id is None: - # generate mock ID - new_id = len(self.db.collections[col_name].docs) + 1 - obj.id = new_id - doc["id"] = new_id - doc["_id"] = new_id - self.db.collections[col_name].insert_one(doc) - self.pending_adds = [] - - def refresh(self, obj): - if obj.id is None: - obj.id = 1 - - def close(self): - pass - - -def test_calculate_semimajor_axis(): - # Mean motion 0 or negative - assert calculate_semimajor_axis(0) == 6778.0 - assert calculate_semimajor_axis(-1) == 6778.0 - - # Typical LEO mean motion - axis = calculate_semimajor_axis(15.5) - assert 6500.0 < axis < 7500.0 - - -def test_generate_tle(): - line1, line2 = generate_tle("25544", 51.64, 0.001, 15.5) - - # Check lines format - assert line1.startswith("1 25544U") - assert line2.startswith("2 25544") - - # Check length (must be 69 characters including checksum) - assert len(line1) == 69 - assert len(line2) == 69 - - # Check standard checksum digit - assert line1[-1].isdigit() - assert line2[-1].isdigit() - - -@patch("scripts.seed_db.get_mongo_client") -@patch("scripts.seed_db.SessionLocal") -def test_seed_database_execution(mock_session_local, mock_mongo_client): - fake_db = FakeDb() - mock_mongo_client.return_value = fake_db.client - - fake_session = FakeSession(fake_db) - mock_session_local.return_value = fake_session - - # Run seed script for 10 satellites - # This will clear collections and populate them - seed_database(count=10, clear=True) - - # Verify collections size - # 5 organizations created - assert len(fake_db["organizations"].docs) == 5 - # 10 satellites and 10 space objects (satellites) created - assert len(fake_db["satellites"].docs) == 10 - # count // 2 debris = 5 debris created. - # Total space objects in orbitalElements = 10 satellites + 5 debris = 15 - assert len(fake_db["debris"].docs) == 5 - assert len(fake_db["orbitalElements"].docs) == 15 - - # Telemetry should be created for each active/degraded satellite - eligible = sum( - doc["status"] in {"ACTIVE", "DEGRADED"} - for doc in fake_db["satellites"].docs - ) - assert len(fake_db["telemetry"].docs) == eligible * 5 - # Telemetry count should be 5 * active/degraded satellites - - # Conjunctions seeded: min(5, len(satellites), len(debris)) = min(5, 10, 5) = 5 - assert len(fake_db["conjunctions"].docs) == 5 - - -@patch("scripts.seed_db.get_mongo_client") -@patch("scripts.seed_db.SessionLocal") -def test_seed_database_no_clear(mock_session_local, mock_mongo_client): - fake_db = FakeDb() - mock_mongo_client.return_value = fake_db.client - - # Pre-populate some existing IDs in the collection - fake_db["satellites"].docs = [{"noradId": "12345"}, {"noradId": "67890"}] - fake_db["debris"].docs = [{"noradId": "11111"}] - - fake_session = FakeSession(fake_db) - mock_session_local.return_value = fake_session - - # Run seed script without clear - seed_database(count=5, clear=False) - - # Assert it completed without crash and new unique IDs are generated - assert len(fake_db["satellites"].docs) > 2 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e5547a..ac23157 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "framer-motion": "^12.40.0", "gsap": "^3.15.0", "lucide-react": "^1.17.0", + "motion": "^12.42.2", "react": "^19.2.6", "react-dom": "^19.2.6", "react-hook-form": "^7.77.0", @@ -39,6 +40,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.13.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -47,11 +49,64 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", - "vite": "^8.0.12" + "vite": "^8.0.12", + "vitest": "^4.1.10" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -532,6 +587,19 @@ "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cesium/engine": { "version": "26.0.0", "resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-26.0.0.tgz", @@ -583,6 +651,146 @@ "node": ">=22.0.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", @@ -947,6 +1155,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -1949,6 +2175,55 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -1976,6 +2251,25 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -2073,6 +2367,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -2411,6 +2712,129 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xyflow/react": { "version": "12.11.0", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz", @@ -2602,12 +3026,47 @@ "node": ">=8" } }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -2671,6 +3130,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bitmap-sdf": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", @@ -2871,6 +3340,16 @@ "node": ">=22.0.0" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -3146,6 +3625,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3352,6 +3845,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", @@ -3394,6 +3901,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -3479,6 +3993,17 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -3507,6 +4032,14 @@ "node": ">=0.3.1" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dompurify": { "version": "3.4.8", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", @@ -3622,6 +4155,19 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -3658,6 +4204,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3971,6 +4524,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -4515,6 +5078,30 @@ "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -4541,6 +5128,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -4778,6 +5378,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -4896,6 +5503,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsep": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", @@ -5348,6 +6006,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5366,6 +6035,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -5675,6 +6351,20 @@ "node": ">= 10" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -5897,6 +6587,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5941,6 +6644,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/phenomenon": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/phenomenon/-/phenomenon-1.6.0.tgz", @@ -6110,6 +6820,30 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -6618,6 +7352,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6870,6 +7617,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6923,6 +7677,13 @@ "deprecated": "Please use @jridgewell/sourcemap-codec instead", "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -6932,6 +7693,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -7050,6 +7818,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/systeminformation": { "version": "5.31.17", "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.17.tgz", @@ -7111,6 +7886,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -7127,6 +7919,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7162,6 +7984,32 @@ "topoquantize": "bin/topoquantize" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -7535,6 +8383,144 @@ "vite": ">=2.7.1" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7550,6 +8536,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7582,6 +8585,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index bc48f63..a34caa0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -41,6 +42,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.13.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -49,8 +51,10 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", - "vite": "^8.0.12" + "vite": "^8.0.12", + "vitest": "^4.1.10" } } diff --git a/frontend/src/components/AnimatedCounter.tsx b/frontend/src/components/AnimatedCounter.tsx new file mode 100644 index 0000000..4a55428 --- /dev/null +++ b/frontend/src/components/AnimatedCounter.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { useAnimatedCounter } from '../hooks/useAnimatedCounter'; +import { formatNumber, formatForScreenReader, inferDecimals } from '../utils/numberFormatter'; +import type { FormatOptions } from '../utils/numberFormatter'; + +export interface AnimatedCounterProps extends FormatOptions { + /** The target numeric value to display. */ + value: number; + /** Animation duration in ms (800–1500 recommended). Default: 1200. */ + duration?: number; + /** Show a skeleton placeholder instead of the number/animation. */ + loading?: boolean; + /** Extra class name for the wrapping . */ + className?: string; + /** Briefly highlight the counter when the value increases. Default: false. */ + highlightOnIncrease?: boolean; +} + +/** + * AnimatedCounter + * + * Lightweight, reusable counter that animates from 0 (first load) or the + * previous value (on updates) to a new target. Formatting and animation + * logic are delegated to numberFormatter.ts and useAnimatedCounter.ts — + * this component only handles rendering and accessibility. + */ +export function AnimatedCounter({ + value, + duration = 1200, + loading = false, + className = '', + highlightOnIncrease = false, + ...formatOptions +}: AnimatedCounterProps) { + const { displayValue, isAnimating } = useAnimatedCounter(value, { + duration, + disableAnimation: loading, + }); + + const [highlight, setHighlight] = React.useState(false); + const prevValueRef = React.useRef(value); + + React.useEffect(() => { + if (highlightOnIncrease && value > prevValueRef.current) { + setHighlight(true); + prevValueRef.current = value; + const t = setTimeout(() => setHighlight(false), 600); + return () => clearTimeout(t); + } + setHighlight(false); + prevValueRef.current = value; + }, [value, highlightOnIncrease]); + + if (loading) { + return ( +