From 85a856b7ac9fbbd5d6842ddd2a98b06fad370548 Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Sun, 12 Apr 2026 03:07:25 -0300 Subject: [PATCH] updated modelgen, decoupling tester --- cfg/amar/.env | 4 +- cfg/amar/ctrl/start.sh | 27 +- cfg/amar/ctrl/stop.sh | 10 +- cfg/amar/soleprint/.env | 8 +- cfg/amar/soleprint/docker-compose.nginx.yml | 6 +- cfg/amar/soleprint/docker-compose.yml | 14 +- cfg/sample/soleprint/docker-compose.yml | 1 + ctrl/deploy.sh | 46 + ctrl/spr.py | 330 ++++ registry.json | 10 + soleprint/common/ui/package.json | 25 + soleprint/common/ui/pnpm-lock.yaml | 1692 +++++++++++++++++ .../common/ui/src/components/LayoutGrid.vue | 32 + soleprint/common/ui/src/components/Panel.vue | 87 + .../ui/src/components/ParameterEditor.vue | 145 ++ .../common/ui/src/components/ResizeHandle.vue | 70 + .../common/ui/src/components/SplitPane.vue | 157 ++ .../ui/src/composables/useDataSource.ts | 23 + .../ui/src/composables/useEditorExecution.ts | 57 + .../common/ui/src/composables/useRegistry.ts | 77 + .../common/ui/src/datasources/DataSource.ts | 40 + .../ui/src/datasources/SSEDataSource.ts | 93 + .../ui/src/datasources/StaticDataSource.ts | 45 + .../__tests__/StaticDataSource.test.ts | 103 + soleprint/common/ui/src/index.ts | 38 + .../common/ui/src/plugins/BBoxDrawPlugin.ts | 88 + .../common/ui/src/plugins/CrosshairPlugin.ts | 60 + .../ui/src/plugins/InteractionPlugin.ts | 36 + .../common/ui/src/renderers/FrameRenderer.vue | 178 ++ .../common/ui/src/renderers/GraphRenderer.vue | 317 +++ .../common/ui/src/renderers/LogRenderer.vue | 143 ++ .../common/ui/src/renderers/TableRenderer.vue | 122 ++ .../ui/src/renderers/TimeSeriesRenderer.vue | 198 ++ soleprint/common/ui/src/tokens.css | 59 + soleprint/common/ui/tsconfig.json | 18 + soleprint/common/ui/vitest.config.ts | 7 + soleprint/run.py | 6 +- soleprint/station/tools/hub/ports | 13 - soleprint/station/tools/hub/update-ports.sh | 88 - .../tools/modelgen/generator/__init__.py | 10 +- .../tools/modelgen/generator/pydantic.py | 18 +- .../tools/modelgen/generator/sqlmodel.py | 181 ++ .../generator/{graphene.py => strawberry.py} | 118 +- .../tools/modelgen/generator/typescript.py | 7 +- soleprint/station/tools/modelgen/helpers.py | 11 + .../station/tools/modelgen/loader/schema.py | 20 + .../station/tools/modelgen/pyproject.toml | 16 + soleprint/station/tools/modelgen/types.py | 50 +- soleprint/station/tools/sbwrapper/config.json | 30 +- soleprint/station/tools/tester/base.py | 77 +- soleprint/station/tools/tester/config.py | 2 +- soleprint/station/tools/tester/endpoints.py | 37 - .../station/tools/tester/environments.json | 29 +- soleprint/station/tools/tester/helpers.py | 39 +- soleprint/station/tools/tester/tests/base.py | 166 +- .../station/tools/tester/tests/conftest.py | 29 - .../station/tools/tester/tests/endpoints.py | 38 - .../station/tools/tester/tests/helpers.py | 44 - 58 files changed, 4770 insertions(+), 625 deletions(-) create mode 100755 ctrl/deploy.sh create mode 100755 ctrl/spr.py create mode 100644 registry.json create mode 100644 soleprint/common/ui/package.json create mode 100644 soleprint/common/ui/pnpm-lock.yaml create mode 100644 soleprint/common/ui/src/components/LayoutGrid.vue create mode 100644 soleprint/common/ui/src/components/Panel.vue create mode 100644 soleprint/common/ui/src/components/ParameterEditor.vue create mode 100644 soleprint/common/ui/src/components/ResizeHandle.vue create mode 100644 soleprint/common/ui/src/components/SplitPane.vue create mode 100644 soleprint/common/ui/src/composables/useDataSource.ts create mode 100644 soleprint/common/ui/src/composables/useEditorExecution.ts create mode 100644 soleprint/common/ui/src/composables/useRegistry.ts create mode 100644 soleprint/common/ui/src/datasources/DataSource.ts create mode 100644 soleprint/common/ui/src/datasources/SSEDataSource.ts create mode 100644 soleprint/common/ui/src/datasources/StaticDataSource.ts create mode 100644 soleprint/common/ui/src/datasources/__tests__/StaticDataSource.test.ts create mode 100644 soleprint/common/ui/src/index.ts create mode 100644 soleprint/common/ui/src/plugins/BBoxDrawPlugin.ts create mode 100644 soleprint/common/ui/src/plugins/CrosshairPlugin.ts create mode 100644 soleprint/common/ui/src/plugins/InteractionPlugin.ts create mode 100644 soleprint/common/ui/src/renderers/FrameRenderer.vue create mode 100644 soleprint/common/ui/src/renderers/GraphRenderer.vue create mode 100644 soleprint/common/ui/src/renderers/LogRenderer.vue create mode 100644 soleprint/common/ui/src/renderers/TableRenderer.vue create mode 100644 soleprint/common/ui/src/renderers/TimeSeriesRenderer.vue create mode 100644 soleprint/common/ui/src/tokens.css create mode 100644 soleprint/common/ui/tsconfig.json create mode 100644 soleprint/common/ui/vitest.config.ts delete mode 100644 soleprint/station/tools/hub/ports delete mode 100755 soleprint/station/tools/hub/update-ports.sh create mode 100644 soleprint/station/tools/modelgen/generator/sqlmodel.py rename soleprint/station/tools/modelgen/generator/{graphene.py => strawberry.py} (62%) create mode 100644 soleprint/station/tools/modelgen/pyproject.toml delete mode 100644 soleprint/station/tools/tester/endpoints.py delete mode 100644 soleprint/station/tools/tester/tests/conftest.py delete mode 100644 soleprint/station/tools/tester/tests/endpoints.py delete mode 100644 soleprint/station/tools/tester/tests/helpers.py diff --git a/cfg/amar/.env b/cfg/amar/.env index 91997da..bd4632a 100644 --- a/cfg/amar/.env +++ b/cfg/amar/.env @@ -28,8 +28,8 @@ DB_DUMP=dev.sql # ============================================================================= # PORTS # ============================================================================= -BACKEND_PORT=8000 -FRONTEND_PORT=3000 +BACKEND_PORT=8010 +FRONTEND_PORT=3010 # ============================================================================= # BACKEND SERVER (Uvicorn) diff --git a/cfg/amar/ctrl/start.sh b/cfg/amar/ctrl/start.sh index 4e91a99..2249981 100755 --- a/cfg/amar/ctrl/start.sh +++ b/cfg/amar/ctrl/start.sh @@ -9,29 +9,42 @@ # ./start.sh --build # Rebuild images set -e -cd "$(dirname "$0")/.." + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CFG_DIR="$(dirname "$SCRIPT_DIR")" +GEN_DIR="$CFG_DIR/../../gen/amar" +SPR_DIR="$GEN_DIR/soleprint" BUILD="" DETACH="" TARGET="all" +NGINX="-f $CFG_DIR/soleprint/docker-compose.nginx.yml" for arg in "$@"; do case $arg in -d|--detached) DETACH="-d" ;; --build) BUILD="--build" ;; + --no-nginx) NGINX="" ;; amar) TARGET="amar" ;; soleprint) TARGET="soleprint" ;; esac done -if [ "$TARGET" = "all" ] || [ "$TARGET" = "amar" ]; then +if [ "$TARGET" = "all" ]; then + echo "Starting amar + soleprint..." + (cd "$CFG_DIR" && docker compose up $BUILD -d) + (cd "$SPR_DIR" && docker compose -f docker-compose.yml $NGINX up $BUILD -d) + if [ -z "$DETACH" ]; then + (cd "$CFG_DIR" && docker compose logs -f) & + (cd "$SPR_DIR" && docker compose logs -f) & + wait + fi +elif [ "$TARGET" = "amar" ]; then echo "Starting amar..." - docker compose up $DETACH $BUILD -fi - -if [ "$TARGET" = "all" ] || [ "$TARGET" = "soleprint" ]; then + (cd "$CFG_DIR" && docker compose up $DETACH $BUILD) +elif [ "$TARGET" = "soleprint" ]; then echo "Starting soleprint..." - (cd soleprint && docker compose up $DETACH $BUILD) + (cd "$SPR_DIR" && docker compose -f docker-compose.yml $NGINX up $DETACH $BUILD) fi if [ -n "$DETACH" ]; then diff --git a/cfg/amar/ctrl/stop.sh b/cfg/amar/ctrl/stop.sh index 857c78d..818fdb3 100755 --- a/cfg/amar/ctrl/stop.sh +++ b/cfg/amar/ctrl/stop.sh @@ -7,19 +7,23 @@ # ./stop.sh soleprint # Stop only soleprint set -e -cd "$(dirname "$0")/.." + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CFG_DIR="$(dirname "$SCRIPT_DIR")" +GEN_DIR="$CFG_DIR/../../gen/amar" +SPR_DIR="$GEN_DIR/soleprint" TARGET="all" [ -n "$1" ] && TARGET="$1" if [ "$TARGET" = "all" ] || [ "$TARGET" = "soleprint" ]; then echo "Stopping soleprint..." - (cd soleprint && docker compose down) + (cd "$SPR_DIR" && docker compose -f docker-compose.yml -f "$CFG_DIR/soleprint/docker-compose.nginx.yml" down 2>/dev/null || true) fi if [ "$TARGET" = "all" ] || [ "$TARGET" = "amar" ]; then echo "Stopping amar..." - docker compose down + (cd "$CFG_DIR" && docker compose down) fi echo "Done." diff --git a/cfg/amar/soleprint/.env b/cfg/amar/soleprint/.env index 1e23f82..ea3000d 100644 --- a/cfg/amar/soleprint/.env +++ b/cfg/amar/soleprint/.env @@ -13,14 +13,18 @@ DEPLOYMENT_NAME=amar_soleprint NETWORK_NAME=soleprint_network # ============================================================================= -# PATHS +# PATHS (bare-metal only, not used by docker) # ============================================================================= -SOLEPRINT_BARE_PATH=/home/mariano/wdir/spr/gen +SOLEPRINT_BARE_PATH=/home/mariano/wdir/spr/gen/amar/soleprint # ============================================================================= # PORTS # ============================================================================= SOLEPRINT_PORT=12000 +NGINX_PORT=8030 +ROOM_NAME=amar +MANAGED_DOMAIN=amar.local.ar +SOLEPRINT_DOMAIN=spr.local.ar # ============================================================================= # DATABASE (amar's DB for station tools) diff --git a/cfg/amar/soleprint/docker-compose.nginx.yml b/cfg/amar/soleprint/docker-compose.nginx.yml index e9b3fe8..bd05c46 100644 --- a/cfg/amar/soleprint/docker-compose.nginx.yml +++ b/cfg/amar/soleprint/docker-compose.nginx.yml @@ -20,12 +20,10 @@ services: image: nginx:alpine container_name: ${DEPLOYMENT_NAME}_nginx ports: - - "80:80" + - "${NGINX_PORT:-8030}:80" volumes: # Mount template that will be processed with envsubst - - ../ctrl/server/nginx/docker-local.conf:/etc/nginx/templates/default.conf.template:ro - # Mount wrapper files for serving - - ../wrapper:/app/wrapper:ro + - ./nginx/local.conf:/etc/nginx/conf.d/default.conf:ro env_file: - .env environment: diff --git a/cfg/amar/soleprint/docker-compose.yml b/cfg/amar/soleprint/docker-compose.yml index 645d49b..6b7fb33 100644 --- a/cfg/amar/soleprint/docker-compose.yml +++ b/cfg/amar/soleprint/docker-compose.yml @@ -1,34 +1,34 @@ -# Soleprint Services - Docker Compose +# Soleprint Services - Amar Room # # Runs soleprint hub as a single service # Artery, atlas, station are accessed via path-based routing # # Usage: -# cd mainroom/soleprint && docker compose up -d +# cd gen/amar/soleprint && docker compose up + +name: ${DEPLOYMENT_NAME} services: soleprint: build: - context: ${SOLEPRINT_BARE_PATH} + context: . dockerfile: Dockerfile container_name: ${DEPLOYMENT_NAME}_soleprint + user: "${UID:-1000}:${GID:-1000}" volumes: - - ${SOLEPRINT_BARE_PATH}:/app + - .:/app ports: - "${SOLEPRINT_PORT}:8000" env_file: - .env environment: - # For single-port mode, all subsystems are internal routes - ARTERY_EXTERNAL_URL=/artery - ATLAS_EXTERNAL_URL=/atlas - STATION_EXTERNAL_URL=/station networks: - default - # Use run.py for single-port bare-metal mode command: uvicorn run:app --host 0.0.0.0 --port 8000 --reload networks: default: - external: true name: ${NETWORK_NAME} diff --git a/cfg/sample/soleprint/docker-compose.yml b/cfg/sample/soleprint/docker-compose.yml index 254e035..81550fc 100644 --- a/cfg/sample/soleprint/docker-compose.yml +++ b/cfg/sample/soleprint/docker-compose.yml @@ -33,4 +33,5 @@ services: networks: default: + external: true name: ${NETWORK_NAME} diff --git a/ctrl/deploy.sh b/ctrl/deploy.sh new file mode 100755 index 0000000..d6dfb4b --- /dev/null +++ b/ctrl/deploy.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Deploy soleprint standalone to server +# +# Usage: +# ./ctrl/deploy.sh # Sync and restart +# ./ctrl/deploy.sh --build # Rebuild locally first, then sync and restart +# ./ctrl/deploy.sh --sync-only # Sync without restarting + +set -e +cd "$(dirname "$0")/.." + +SERVER="mcrn.ar" +REMOTE_DIR="~/soleprint/gen/standalone" +BUILD=false +SYNC_ONLY=false + +for arg in "$@"; do + case $arg in + --build) BUILD=true ;; + --sync-only) SYNC_ONLY=true ;; + esac +done + +if [ "$BUILD" = true ]; then + echo "Building standalone..." + python build.py +fi + +echo "Syncing gen/standalone/ to $SERVER:$REMOTE_DIR..." +rsync -avz --delete \ + --exclude='__pycache__' \ + --exclude='.venv' \ + --exclude='*.pyc' \ + --exclude='.env' \ + --filter=':- .gitignore' \ + gen/standalone/ "$SERVER:$REMOTE_DIR/" + +if [ "$SYNC_ONLY" = true ]; then + echo "Sync complete (restart skipped)" + exit 0 +fi + +echo "Restarting soleprint on server..." +ssh "$SERVER" "cd $REMOTE_DIR && docker compose up -d --build" + +echo "Deploy complete" diff --git a/ctrl/spr.py b/ctrl/spr.py new file mode 100755 index 0000000..a91a4f8 --- /dev/null +++ b/ctrl/spr.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +""" +spr - Soleprint component manager + +Manages distributable components from the spr repo into target folders. +Runs from spr/. Consuming projects have no awareness of spr — they just +commit whatever lands in the target folder. + +Usage: + python ctrl/spr.py list + python ctrl/spr.py sync soleprint-ui ~/wdir/unt/ui/framework + python ctrl/spr.py watch soleprint-ui ~/wdir/unt/ui/framework # ctrl+c to stop + python ctrl/spr.py publish soleprint-ui ~/wdir/mpr/ui/framework + python ctrl/spr.py diff soleprint-ui ~/wdir/mpr/ui/framework +""" + +import argparse +import json +import logging +import shutil +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +log = logging.getLogger("spr") + +SPR_HOME = Path(__file__).resolve().parent.parent +REGISTRY_PATH = SPR_HOME / "registry.json" + +EXCLUDE = { + "node_modules", + "__pycache__", + ".venv", + "dist", + ".git", + "*.egg-info", + ".spr", + "pnpm-lock.yaml", +} + + +def load_registry(): + if not REGISTRY_PATH.exists(): + log.error("registry not found: %s", REGISTRY_PATH) + sys.exit(1) + return json.loads(REGISTRY_PATH.read_text()) + + +def resolve_component(registry, name): + if name not in registry: + log.error("unknown component: %s", name) + log.info("available: %s", ", ".join(registry.keys())) + sys.exit(1) + + entry = registry[name] + source = SPR_HOME / entry["path"] + if not source.is_dir(): + log.error("source not found: %s", source) + sys.exit(1) + + return entry["type"], source + + +def get_version(comp_type, source): + try: + if comp_type == "npm": + pkg = json.loads((source / "package.json").read_text()) + return pkg.get("version", "?") + elif comp_type == "pip": + for line in (source / "pyproject.toml").read_text().splitlines(): + if line.strip().startswith("version"): + return line.split('"')[1] + except FileNotFoundError: + pass + return "?" + + +def get_sha(): + try: + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=SPR_HOME, + capture_output=True, + text=True, + ) + return result.stdout.strip() if result.returncode == 0 else "unknown" + except FileNotFoundError: + return "unknown" + + +def should_exclude(path, base): + """Check if a path should be excluded from sync.""" + rel = path.relative_to(base) + for part in rel.parts: + if part in EXCLUDE: + return True + for pattern in EXCLUDE: + if "*" in pattern and part.endswith(pattern.replace("*", "")): + return True + return False + + +def copy_tree(src, dst): + """Copy src to dst, excluding build artifacts. Returns count of files copied.""" + count = 0 + dst.mkdir(parents=True, exist_ok=True) + + for item in src.iterdir(): + if should_exclude(item, src.parent): + continue + + target = dst / item.name + if item.is_dir(): + count += copy_tree(item, target) + else: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, target) + count += 1 + + return count + + +def sync_tree(src, dst): + """Bidirectional sync — newest file wins. Returns (fwd, rev) counts.""" + fwd = _sync_one_way(src, dst) + rev = _sync_one_way(dst, src) + return fwd, rev + + +def _sync_one_way(src, dst): + """Copy files from src to dst only if src is newer.""" + count = 0 + if not src.is_dir(): + return count + + dst.mkdir(parents=True, exist_ok=True) + + for item in src.iterdir(): + if should_exclude(item, src.parent): + continue + + target = dst / item.name + if item.is_dir(): + count += _sync_one_way(item, target) + else: + if not target.exists() or item.stat().st_mtime > target.stat().st_mtime: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, target) + count += 1 + + # Remove files in dst that don't exist in src + if dst.is_dir(): + for item in dst.iterdir(): + if should_exclude(item, dst.parent): + continue + counterpart = src / item.name + if not counterpart.exists(): + if item.is_dir(): + shutil.rmtree(item) + else: + item.unlink() + count += 1 + + return count + + +def write_stamp(dest, name, comp_type, source, mode): + version = get_version(comp_type, source) + sha = get_sha() + stamp = dest / ".spr" + stamp.write_text( + f"name={name}\n" + f"version={version}\n" + f"type={comp_type}\n" + f"sha={sha}\n" + f"mode={mode}\n" + f"updated={datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}\n" + f"source={source}\n" + ) + + +# ── commands ───────────────────────────────────────────────── + + +def cmd_list(args): + registry = load_registry() + log.info("Components (%s):\n", REGISTRY_PATH) + for name, entry in registry.items(): + comp_type = entry["type"] + source = SPR_HOME / entry["path"] + version = get_version(comp_type, source) + log.info(" %s %s v%-10s %s", f"{name:<25}", f"{comp_type:<5}", version, entry["path"]) + + +def cmd_publish(args): + registry = load_registry() + comp_type, source = resolve_component(registry, args.component) + dest = Path(args.dest).resolve() + + if dest.exists(): + shutil.rmtree(dest) + + count = copy_tree(source, dest) + write_stamp(dest, args.component, comp_type, source, "published") + + version = get_version(comp_type, source) + sha = get_sha() + log.info("%s v%s (%s) -> %s (%d files)", args.component, version, sha, dest, count) + + +def cmd_sync(args): + registry = load_registry() + comp_type, source = resolve_component(registry, args.component) + dest = Path(args.dest).resolve() + dest.mkdir(parents=True, exist_ok=True) + + fwd, rev = sync_tree(source, dest) + write_stamp(dest, args.component, comp_type, source, "synced") + + log.info("%s synced (%d fwd, %d rev)", args.component, fwd, rev) + + +def cmd_watch(args): + registry = load_registry() + comp_type, source = resolve_component(registry, args.component) + dest = Path(args.dest).resolve() + dest.mkdir(parents=True, exist_ok=True) + interval = args.interval + + # Initial sync + fwd, rev = sync_tree(source, dest) + write_stamp(dest, args.component, comp_type, source, "watching") + log.info("%s initial sync (%d fwd, %d rev)", args.component, fwd, rev) + log.info(" source: %s", source) + log.info(" dest: %s", dest) + log.info(" watching every %ds — ctrl+c to stop", interval) + + try: + while True: + time.sleep(interval) + fwd, rev = sync_tree(source, dest) + if fwd or rev: + log.info("synced (%d fwd, %d rev)", fwd, rev) + except KeyboardInterrupt: + # Final sync + fwd, rev = sync_tree(source, dest) + if fwd or rev: + log.info("final sync (%d fwd, %d rev)", fwd, rev) + log.info("stopped") + + +def cmd_diff(args): + registry = load_registry() + _, source = resolve_component(registry, args.component) + dest = Path(args.dest).resolve() + + if not dest.exists(): + log.error("%s not found at %s", args.component, dest) + sys.exit(1) + + result = subprocess.run( + [ + "diff", + "-rq", + "--exclude=node_modules", + "--exclude=__pycache__", + "--exclude=.venv", + "--exclude=dist", + "--exclude=*.egg-info", + "--exclude=.git", + "--exclude=.spr", + "--exclude=pnpm-lock.yaml", + str(source), + str(dest), + ], + capture_output=True, + text=True, + ) + if result.stdout: + log.info("\n%s", result.stdout.rstrip()) + else: + log.info("%s is in sync", args.component) + + +# ── main ───────────────────────────────────────────────────── + + +def main(): + logging.basicConfig( + level=logging.DEBUG if "--debug" in sys.argv else logging.INFO, + format="%(levelname)s %(message)s" if "--debug" in sys.argv else ":: %(message)s", + ) + sys.argv = [a for a in sys.argv if a != "--debug"] + + parser = argparse.ArgumentParser( + prog="spr", + description="Soleprint component manager", + ) + sub = parser.add_subparsers(dest="command") + + sub.add_parser("list", help="show available components") + + for cmd in ("publish", "sync", "diff"): + p = sub.add_parser(cmd) + p.add_argument("component", help="component name") + p.add_argument("dest", help="target folder path") + + p = sub.add_parser("watch", help="continuous two-way sync (foreground, ctrl+c to stop)") + p.add_argument("component", help="component name") + p.add_argument("dest", help="target folder path") + p.add_argument("-i", "--interval", type=int, default=2, help="poll interval in seconds (default: 2)") + + args = parser.parse_args() + if not args.command: + parser.print_help() + sys.exit(0) + + { + "list": cmd_list, + "publish": cmd_publish, + "sync": cmd_sync, + "watch": cmd_watch, + "diff": cmd_diff, + }[args.command](args) + + +if __name__ == "__main__": + main() diff --git a/registry.json b/registry.json new file mode 100644 index 0000000..7282cee --- /dev/null +++ b/registry.json @@ -0,0 +1,10 @@ +{ + "soleprint-ui": { + "type": "npm", + "path": "soleprint/common/ui" + }, + "soleprint-modelgen": { + "type": "pip", + "path": "soleprint/station/tools/modelgen" + } +} diff --git a/soleprint/common/ui/package.json b/soleprint/common/ui/package.json new file mode 100644 index 0000000..242e0e4 --- /dev/null +++ b/soleprint/common/ui/package.json @@ -0,0 +1,25 @@ +{ + "name": "soleprint-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@vue-flow/core": "^1.48.2", + "pinia": "^2.2", + "uplot": "^1.6", + "vue": "^3.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5", + "typescript": "^5.6", + "vite": "^6", + "vitest": "^2", + "vue-tsc": "^2" + } +} diff --git a/soleprint/common/ui/pnpm-lock.yaml b/soleprint/common/ui/pnpm-lock.yaml new file mode 100644 index 0000000..1992019 --- /dev/null +++ b/soleprint/common/ui/pnpm-lock.yaml @@ -0,0 +1,1692 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@vue-flow/core': + specifier: ^1.48.2 + version: 1.48.2(vue@3.5.30(typescript@5.9.3)) + pinia: + specifier: ^2.2 + version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + uplot: + specifier: ^1.6 + version: 1.6.32 + vue: + specifier: ^3.5 + version: 3.5.30(typescript@5.9.3) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^5 + version: 5.2.4(vite@6.4.1)(vue@3.5.30(typescript@5.9.3)) + typescript: + specifier: ^5.6 + version: 5.9.3 + vite: + specifier: ^6 + version: 6.4.1 + vitest: + specifier: ^2 + version: 2.1.9 + vue-tsc: + specifier: ^2 + version: 2.2.12(typescript@5.9.3) + +packages: + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue-flow/core@1.48.2': + resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==} + peerDependencies: + vue: ^3.3.0 + + '@vue/compiler-core@3.5.30': + resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==} + + '@vue/compiler-dom@3.5.30': + resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==} + + '@vue/compiler-sfc@3.5.30': + resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==} + + '@vue/compiler-ssr@3.5.30': + resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.30': + resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==} + + '@vue/runtime-core@3.5.30': + resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==} + + '@vue/runtime-dom@3.5.30': + resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==} + + '@vue/server-renderer@3.5.30': + resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==} + peerDependencies: + vue: 3.5.30 + + '@vue/shared@3.5.30': + resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==} + + '@vueuse/core@10.11.1': + resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} + + '@vueuse/metadata@10.11.1': + resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==} + + '@vueuse/shared@10.11.1': + resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uplot@1.6.32: + resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.30: + resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.60.0': + optional: true + + '@rollup/rollup-android-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-x64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.0': + optional: true + + '@types/estree@1.0.8': {} + + '@types/web-bluetooth@0.0.20': {} + + '@vitejs/plugin-vue@5.2.4(vite@6.4.1)(vue@3.5.30(typescript@5.9.3))': + dependencies: + vite: 6.4.1 + vue: 3.5.30(typescript@5.9.3) + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21)': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21 + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue-flow/core@1.48.2(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@vueuse/core': 10.11.1(vue@3.5.30(typescript@5.9.3)) + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + vue: 3.5.30(typescript@5.9.3) + transitivePeerDependencies: + - '@vue/composition-api' + + '@vue/compiler-core@3.5.30': + dependencies: + '@babel/parser': 7.29.2 + '@vue/shared': 3.5.30 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.30': + dependencies: + '@vue/compiler-core': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-sfc@3.5.30': + dependencies: + '@babel/parser': 7.29.2 + '@vue/compiler-core': 3.5.30 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.8 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.30': + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.30 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.30': + dependencies: + '@vue/shared': 3.5.30 + + '@vue/runtime-core@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/runtime-dom@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/runtime-core': 3.5.30 + '@vue/shared': 3.5.30 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + vue: 3.5.30(typescript@5.9.3) + + '@vue/shared@3.5.30': {} + + '@vueuse/core@10.11.1(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 10.11.1 + '@vueuse/shared': 10.11.1(vue@3.5.30(typescript@5.9.3)) + vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/metadata@10.11.1': {} + + '@vueuse/shared@10.11.1(vue@3.5.30(typescript@5.9.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + alien-signals@1.0.13: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + csstype@3.2.3: {} + + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + entities@7.0.1: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.3: + optional: true + + he@1.2.0: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.2 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.11: {} + + path-browserify@1.0.1: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pinia@2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.30(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@vue/composition-api' + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + typescript@5.9.3: {} + + uplot@1.6.32: {} + + vite-node@2.1.9: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.60.0 + optionalDependencies: + fsevents: 2.3.3 + + vite@6.4.1: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + + vitest@2.1.9: + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21 + vite-node: 2.1.9 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vscode-uri@3.1.0: {} + + vue-demi@0.14.10(vue@3.5.30(typescript@5.9.3)): + dependencies: + vue: 3.5.30(typescript@5.9.3) + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue@3.5.30(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-sfc': 3.5.30 + '@vue/runtime-dom': 3.5.30 + '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3)) + '@vue/shared': 3.5.30 + optionalDependencies: + typescript: 5.9.3 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/soleprint/common/ui/src/components/LayoutGrid.vue b/soleprint/common/ui/src/components/LayoutGrid.vue new file mode 100644 index 0000000..3d2acb0 --- /dev/null +++ b/soleprint/common/ui/src/components/LayoutGrid.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/soleprint/common/ui/src/components/Panel.vue b/soleprint/common/ui/src/components/Panel.vue new file mode 100644 index 0000000..0ed1d95 --- /dev/null +++ b/soleprint/common/ui/src/components/Panel.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/soleprint/common/ui/src/components/ParameterEditor.vue b/soleprint/common/ui/src/components/ParameterEditor.vue new file mode 100644 index 0000000..0ae625e --- /dev/null +++ b/soleprint/common/ui/src/components/ParameterEditor.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/soleprint/common/ui/src/components/ResizeHandle.vue b/soleprint/common/ui/src/components/ResizeHandle.vue new file mode 100644 index 0000000..a373413 --- /dev/null +++ b/soleprint/common/ui/src/components/ResizeHandle.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/soleprint/common/ui/src/components/SplitPane.vue b/soleprint/common/ui/src/components/SplitPane.vue new file mode 100644 index 0000000..7a1b0f4 --- /dev/null +++ b/soleprint/common/ui/src/components/SplitPane.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/soleprint/common/ui/src/composables/useDataSource.ts b/soleprint/common/ui/src/composables/useDataSource.ts new file mode 100644 index 0000000..cfad0c6 --- /dev/null +++ b/soleprint/common/ui/src/composables/useDataSource.ts @@ -0,0 +1,23 @@ +import { onMounted, onUnmounted, type Ref } from 'vue' +import { DataSource, type DataSourceStatus } from '../datasources/DataSource' + +/** + * Composable that connects a component to a DataSource. + * + * Connects on mount, disconnects on unmount. + * Returns reactive refs for data, status, and error. + */ +export function useDataSource(source: DataSource): { + data: Ref + status: Ref + error: Ref +} { + onMounted(() => source.connect()) + onUnmounted(() => source.disconnect()) + + return { + data: source.data as Ref, + status: source.status, + error: source.error as Ref, + } +} diff --git a/soleprint/common/ui/src/composables/useEditorExecution.ts b/soleprint/common/ui/src/composables/useEditorExecution.ts new file mode 100644 index 0000000..e968408 --- /dev/null +++ b/soleprint/common/ui/src/composables/useEditorExecution.ts @@ -0,0 +1,57 @@ +import { ref } from 'vue' + +export interface EditorExecutionOptions { + /** Debounce delay in ms for auto-apply. Default: 150 */ + debounceMs?: number +} + +/** + * Generic editor execution pattern — debounced apply with auto-apply toggle, + * loading/error/timing state tracking. + * + * The caller provides the actual execution function. This composable handles + * the orchestration: debounce, auto-apply, loading state, timing. + */ +export function useEditorExecution( + executeFn: () => Promise, + options: EditorExecutionOptions = {}, +) { + const debounceMs = options.debounceMs ?? 150 + + const loading = ref(false) + const error = ref(null) + const autoApply = ref(true) + const execTimeMs = ref(null) + + let debounceTimer: ReturnType | null = null + + async function apply() { + loading.value = true + error.value = null + execTimeMs.value = null + const t0 = performance.now() + try { + await executeFn() + execTimeMs.value = Math.round(performance.now() - t0) + } catch (e) { + error.value = String(e) + } finally { + loading.value = false + } + } + + function onParameterChange() { + if (!autoApply.value) return + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => apply(), debounceMs) + } + + return { + loading, + error, + autoApply, + execTimeMs, + apply, + onParameterChange, + } +} diff --git a/soleprint/common/ui/src/composables/useRegistry.ts b/soleprint/common/ui/src/composables/useRegistry.ts new file mode 100644 index 0000000..66b92e1 --- /dev/null +++ b/soleprint/common/ui/src/composables/useRegistry.ts @@ -0,0 +1,77 @@ +import { ref, type Ref } from 'vue' + +/** + * Generic registry composable — fetches typed data from a URL, caches it, + * exposes it reactively. + * + * Use for any data that is loaded once at app init and rarely changes: + * stage definitions, config schemas, available models, etc. + * + * The registry is shared across all consumers (singleton per URL). + */ + +const cache = new Map; loading: Ref; error: Ref; promise: Promise | null }>() + +export function useRegistry(url: string): { + data: Ref + loading: Ref + error: Ref + refresh: () => Promise +} { + if (!cache.has(url)) { + const data = ref([]) as Ref + const loading = ref(false) + const error = ref(null) + + const entry = { data, loading, error, promise: null as Promise | null } + cache.set(url, entry) + + async function doFetch() { + loading.value = true + error.value = null + try { + const resp = await fetch(url) + if (!resp.ok) { + error.value = `Failed to fetch registry: ${resp.status}` + return + } + data.value = await resp.json() + } catch (e) { + error.value = String(e) + } finally { + loading.value = false + } + } + + entry.promise = doFetch() + } + + const entry = cache.get(url)! + + async function refresh() { + const data = entry.data + const loading = entry.loading + const error = entry.error + loading.value = true + error.value = null + try { + const resp = await fetch(url) + if (!resp.ok) { + error.value = `Failed to fetch registry: ${resp.status}` + return + } + data.value = await resp.json() + } catch (e) { + error.value = String(e) + } finally { + loading.value = false + } + } + + return { + data: entry.data as Ref, + loading: entry.loading, + error: entry.error, + refresh, + } +} diff --git a/soleprint/common/ui/src/datasources/DataSource.ts b/soleprint/common/ui/src/datasources/DataSource.ts new file mode 100644 index 0000000..974d305 --- /dev/null +++ b/soleprint/common/ui/src/datasources/DataSource.ts @@ -0,0 +1,40 @@ +import { type Ref, ref } from 'vue' + +export type DataSourceStatus = 'idle' | 'connecting' | 'live' | 'error' + +/** + * Base class for all data sources. + * + * A DataSource connects to some event stream, exposes reactive state, + * and lets consumers subscribe to typed events. Panels read from these + * reactively — they never touch the transport layer directly. + */ +export abstract class DataSource { + readonly id: string + readonly data: Ref = ref(null) as Ref + readonly status: Ref = ref('idle') + readonly error: Ref = ref(null) as Ref + + private listeners = new Map void>>() + + constructor(id: string) { + this.id = id + } + + abstract connect(): void + abstract disconnect(): void + + /** Subscribe to a specific event type */ + on

(eventType: string, handler: (payload: P) => void): () => void { + if (!this.listeners.has(eventType)) { + this.listeners.set(eventType, new Set()) + } + this.listeners.get(eventType)!.add(handler) + return () => this.listeners.get(eventType)?.delete(handler) + } + + /** Emit an event to subscribers (called by subclasses) */ + protected emit(eventType: string, payload: unknown): void { + this.listeners.get(eventType)?.forEach((fn) => fn(payload)) + } +} diff --git a/soleprint/common/ui/src/datasources/SSEDataSource.ts b/soleprint/common/ui/src/datasources/SSEDataSource.ts new file mode 100644 index 0000000..ed52527 --- /dev/null +++ b/soleprint/common/ui/src/datasources/SSEDataSource.ts @@ -0,0 +1,93 @@ +import { DataSource } from './DataSource' + +export interface SSEDataSourceOptions { + /** Unique identifier for this source */ + id: string + /** SSE endpoint URL (e.g. '/api/detect/stream/job-123') */ + url: string + /** Event types to listen for. Each is dispatched to subscribers via on(). */ + eventTypes: string[] + /** Max reconnection attempts before giving up. Default: 10 */ + maxRetries?: number +} + +/** + * DataSource backed by native EventSource (Server-Sent Events). + * + * Connects to a single SSE endpoint and demultiplexes events by type. + * Multiple panels can subscribe to different event types from the same source. + */ +export class SSEDataSource extends DataSource { + private es: EventSource | null = null + private url: string + private eventTypes: string[] + private maxRetries: number + private retryCount = 0 + + constructor(opts: SSEDataSourceOptions) { + super(opts.id) + this.url = opts.url + this.eventTypes = opts.eventTypes + this.maxRetries = opts.maxRetries ?? 10 + } + + connect(): void { + if (this.es) return + this.status.value = 'connecting' + this.error.value = null + + this.es = new EventSource(this.url) + + this.es.onopen = () => { + this.status.value = 'live' + this.retryCount = 0 + } + + this.es.onerror = () => { + if (this.es?.readyState === EventSource.CLOSED) { + this.retryCount++ + if (this.retryCount >= this.maxRetries) { + this.status.value = 'error' + this.error.value = `Connection lost after ${this.maxRetries} retries` + this.disconnect() + } else { + this.status.value = 'connecting' + } + } + } + + // Register a listener for each event type + for (const eventType of this.eventTypes) { + this.es.addEventListener(eventType, (e: MessageEvent) => { + try { + const parsed = JSON.parse(e.data) + this.data.value = parsed + this.emit(eventType, parsed) + } catch { + // ignore malformed events + } + }) + } + + // Terminal event — pipeline finished (success, failure, or cancel) + this.es.addEventListener('done', () => { + this.status.value = 'idle' + }) + } + + disconnect(): void { + if (this.es) { + this.es.close() + this.es = null + } + } + + /** Update the URL (e.g. when job ID changes) and reconnect */ + setUrl(url: string): void { + this.url = url + if (this.status.value === 'live' || this.status.value === 'connecting') { + this.disconnect() + this.connect() + } + } +} diff --git a/soleprint/common/ui/src/datasources/StaticDataSource.ts b/soleprint/common/ui/src/datasources/StaticDataSource.ts new file mode 100644 index 0000000..a09dfa6 --- /dev/null +++ b/soleprint/common/ui/src/datasources/StaticDataSource.ts @@ -0,0 +1,45 @@ +import { DataSource } from './DataSource' + +export interface StaticEvent { + type: string + data: unknown + /** Delay in ms before emitting this event (relative to previous). Default: 0 */ + delay?: number +} + +/** + * DataSource that replays a fixture array of events. + * + * Used for development and testing without a running backend. + * Events are emitted in sequence with optional delays. + */ +export class StaticDataSource extends DataSource { + private events: StaticEvent[] + private timeouts: ReturnType[] = [] + + constructor(id: string, events: StaticEvent[]) { + super(id) + this.events = events + } + + connect(): void { + this.status.value = 'live' + this.error.value = null + + let cumDelay = 0 + for (const event of this.events) { + cumDelay += event.delay ?? 0 + const timeout = setTimeout(() => { + this.data.value = event.data + this.emit(event.type, event.data) + }, cumDelay) + this.timeouts.push(timeout) + } + } + + disconnect(): void { + for (const t of this.timeouts) clearTimeout(t) + this.timeouts = [] + this.status.value = 'idle' + } +} diff --git a/soleprint/common/ui/src/datasources/__tests__/StaticDataSource.test.ts b/soleprint/common/ui/src/datasources/__tests__/StaticDataSource.test.ts new file mode 100644 index 0000000..9c4cb30 --- /dev/null +++ b/soleprint/common/ui/src/datasources/__tests__/StaticDataSource.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { StaticDataSource } from '../StaticDataSource' + +describe('StaticDataSource', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('emits events in order', async () => { + const source = new StaticDataSource('test', [ + { type: 'log', data: { msg: 'first' } }, + { type: 'log', data: { msg: 'second' } }, + { type: 'stats', data: { count: 42 } }, + ]) + + const received: { type: string; data: unknown }[] = [] + source.on('log', (d) => received.push({ type: 'log', data: d })) + source.on('stats', (d) => received.push({ type: 'stats', data: d })) + + source.connect() + + // Events with delay=0 fire on next microtask via setTimeout(0) + await new Promise((r) => setTimeout(r, 10)) + + expect(source.status.value).toBe('live') + expect(received).toHaveLength(3) + expect(received[0]).toEqual({ type: 'log', data: { msg: 'first' } }) + expect(received[1]).toEqual({ type: 'log', data: { msg: 'second' } }) + expect(received[2]).toEqual({ type: 'stats', data: { count: 42 } }) + + source.disconnect() + expect(source.status.value).toBe('idle') + }) + + it('respects delays between events', async () => { + const source = new StaticDataSource('test-delay', [ + { type: 'a', data: 1 }, + { type: 'b', data: 2, delay: 50 }, + ]) + + const received: unknown[] = [] + source.on('a', (d) => received.push(d)) + source.on('b', (d) => received.push(d)) + + source.connect() + + await new Promise((r) => setTimeout(r, 10)) + expect(received).toHaveLength(1) // only 'a' so far + + await new Promise((r) => setTimeout(r, 60)) + expect(received).toHaveLength(2) // 'b' arrived after delay + + source.disconnect() + }) + + it('updates data ref with latest event payload', async () => { + const source = new StaticDataSource('test-data', [ + { type: 'x', data: { v: 1 } }, + { type: 'x', data: { v: 2 } }, + ]) + + source.connect() + await new Promise((r) => setTimeout(r, 10)) + + expect(source.data.value).toEqual({ v: 2 }) + + source.disconnect() + }) + + it('cleans up on disconnect', async () => { + const source = new StaticDataSource('test-cleanup', [ + { type: 'a', data: 1 }, + { type: 'b', data: 2, delay: 100 }, + ]) + + const received: unknown[] = [] + source.on('b', (d) => received.push(d)) + + source.connect() + await new Promise((r) => setTimeout(r, 10)) + source.disconnect() + + // 'b' should never fire since we disconnected before its delay + await new Promise((r) => setTimeout(r, 150)) + expect(received).toHaveLength(0) + }) + + it('unsubscribe removes listener', async () => { + const source = new StaticDataSource('test-unsub', [ + { type: 'x', data: 1 }, + ]) + + const received: unknown[] = [] + const unsub = source.on('x', (d) => received.push(d)) + unsub() + + source.connect() + await new Promise((r) => setTimeout(r, 10)) + + expect(received).toHaveLength(0) + source.disconnect() + }) +}) diff --git a/soleprint/common/ui/src/index.ts b/soleprint/common/ui/src/index.ts new file mode 100644 index 0000000..2ec6787 --- /dev/null +++ b/soleprint/common/ui/src/index.ts @@ -0,0 +1,38 @@ +// Framework public API +export { DataSource, type DataSourceStatus } from './datasources/DataSource' +export { SSEDataSource } from './datasources/SSEDataSource' +export { StaticDataSource } from './datasources/StaticDataSource' +export { useDataSource } from './composables/useDataSource' +export { useRegistry } from './composables/useRegistry' +export { useEditorExecution } from './composables/useEditorExecution' +export type { EditorExecutionOptions } from './composables/useEditorExecution' + +// Components +export { default as Panel } from './components/Panel.vue' +export { default as LayoutGrid } from './components/LayoutGrid.vue' +export { default as ResizeHandle } from './components/ResizeHandle.vue' +export { default as SplitPane } from './components/SplitPane.vue' +export { default as ParameterEditor } from './components/ParameterEditor.vue' +export type { ConfigField } from './components/ParameterEditor.vue' + +// Renderers +export { default as LogRenderer } from './renderers/LogRenderer.vue' +export { default as TimeSeriesRenderer } from './renderers/TimeSeriesRenderer.vue' +export { default as GraphRenderer } from './renderers/GraphRenderer.vue' +export { default as FrameRenderer } from './renderers/FrameRenderer.vue' +export { default as TableRenderer } from './renderers/TableRenderer.vue' + +// Renderer types +export type { FrameBBox, FrameOverlay } from './renderers/FrameRenderer.vue' +export type { LogEntry } from './renderers/LogRenderer.vue' +export type { GraphNode, GraphMode } from './renderers/GraphRenderer.vue' +export type { TableColumn } from './renderers/TableRenderer.vue' +export type { TimeSeriesSeries } from './renderers/TimeSeriesRenderer.vue' + +// Interaction plugins +export type { InteractionPlugin, PluginContext } from './plugins/InteractionPlugin' +export { BBoxDrawPlugin } from './plugins/BBoxDrawPlugin' +export type { BBoxResult, BBoxCallback } from './plugins/BBoxDrawPlugin' +export { CrosshairPlugin } from './plugins/CrosshairPlugin' +export type { CrosshairCallback } from './plugins/CrosshairPlugin' + diff --git a/soleprint/common/ui/src/plugins/BBoxDrawPlugin.ts b/soleprint/common/ui/src/plugins/BBoxDrawPlugin.ts new file mode 100644 index 0000000..064ef6d --- /dev/null +++ b/soleprint/common/ui/src/plugins/BBoxDrawPlugin.ts @@ -0,0 +1,88 @@ +/** + * BBoxDrawPlugin — draw bounding boxes on the frame viewer. + * + * User drags on the canvas to draw a rectangle. + * On pointer up, emits the bbox coordinates via the callback. + * The frame viewer panel feeds this into the selection store. + */ + +import type { InteractionPlugin, PluginContext } from './InteractionPlugin' + +export interface BBoxResult { + x: number + y: number + w: number + h: number +} + +export type BBoxCallback = (bbox: BBoxResult) => void + +export class BBoxDrawPlugin implements InteractionPlugin { + name = 'bbox-draw' + + private ctx: CanvasRenderingContext2D | null = null + private drawing = false + private startX = 0 + private startY = 0 + private currentBox: BBoxResult | null = null + private callback: BBoxCallback + + constructor(callback: BBoxCallback) { + this.callback = callback + } + + onMount(context: PluginContext): void { + this.ctx = context.ctx + } + + onUnmount(): void { + this.ctx = null + this.drawing = false + this.currentBox = null + } + + onPointerDown(e: PointerEvent): void { + this.drawing = true + this.startX = e.offsetX + this.startY = e.offsetY + this.currentBox = null + } + + onPointerMove(e: PointerEvent): void { + if (!this.drawing) return + + const x = Math.min(this.startX, e.offsetX) + const y = Math.min(this.startY, e.offsetY) + const w = Math.abs(e.offsetX - this.startX) + const h = Math.abs(e.offsetY - this.startY) + + this.currentBox = { x, y, w, h } + } + + onPointerUp(_e: PointerEvent): void { + if (!this.drawing) return + this.drawing = false + + if (this.currentBox && this.currentBox.w > 5 && this.currentBox.h > 5) { + this.callback(this.currentBox) + } + + this.currentBox = null + } + + render(ctx: CanvasRenderingContext2D): void { + if (!this.currentBox) return + + const box = this.currentBox + + ctx.strokeStyle = '#4f9cf9' + ctx.lineWidth = 2 + ctx.setLineDash([6, 3]) + ctx.strokeRect(box.x, box.y, box.w, box.h) + ctx.setLineDash([]) + + // Semi-transparent fill + ctx.fillStyle = 'rgba(79, 156, 249, 0.1)' + ctx.fillRect(box.x, box.y, box.w, box.h) + } +} diff --git a/soleprint/common/ui/src/plugins/CrosshairPlugin.ts b/soleprint/common/ui/src/plugins/CrosshairPlugin.ts new file mode 100644 index 0000000..0011b5d --- /dev/null +++ b/soleprint/common/ui/src/plugins/CrosshairPlugin.ts @@ -0,0 +1,60 @@ +/** + * CrosshairPlugin — synchronized vertical crosshair across time-series panels. + * + * When the user hovers on any panel with this plugin, the crosshair + * position (as a timestamp) is written to the selection store. + * All panels with this plugin render a vertical line at that timestamp. + */ + +import type { InteractionPlugin, PluginContext } from './InteractionPlugin' + +export type CrosshairCallback = (timestamp: number | null) => void + +export class CrosshairPlugin implements InteractionPlugin { + name = 'crosshair' + + private width = 0 + private callback: CrosshairCallback + + /** Current crosshair X position (pixels), set externally from store */ + public crosshairX: number | null = null + + constructor(callback: CrosshairCallback) { + this.callback = callback + } + + onMount(context: PluginContext): void { + this.width = context.width + } + + onUnmount(): void { + this.crosshairX = null + } + + onPointerMove(e: PointerEvent): void { + // Convert pixel X to normalized position (0-1) + const normalized = e.offsetX / this.width + this.callback(normalized) + } + + onPointerDown(_e: PointerEvent): void { + // no-op for crosshair + } + + onPointerUp(_e: PointerEvent): void { + this.callback(null) + } + + render(ctx: CanvasRenderingContext2D): void { + if (this.crosshairX === null) return + + ctx.strokeStyle = '#a78bfa' + ctx.lineWidth = 1 + ctx.setLineDash([4, 4]) + ctx.beginPath() + ctx.moveTo(this.crosshairX, 0) + ctx.lineTo(this.crosshairX, ctx.canvas.height) + ctx.stroke() + ctx.setLineDash([]) + } +} diff --git a/soleprint/common/ui/src/plugins/InteractionPlugin.ts b/soleprint/common/ui/src/plugins/InteractionPlugin.ts new file mode 100644 index 0000000..82fd944 --- /dev/null +++ b/soleprint/common/ui/src/plugins/InteractionPlugin.ts @@ -0,0 +1,36 @@ +/** + * Interaction plugin interface. + * + * Plugins attach to a Panel's overlay canvas. They receive pointer events + * and emit typed results via the callback. The panel handles rendering + * the overlay and routing events to the active plugin. + */ + +export interface PluginContext { + /** Canvas element for drawing overlays */ + canvas: HTMLCanvasElement + /** 2D rendering context */ + ctx: CanvasRenderingContext2D + /** Canvas dimensions (may differ from display size) */ + width: number + height: number +} + +export interface InteractionPlugin { + /** Unique plugin name */ + name: string + + /** Called when the plugin is mounted on a panel */ + onMount(context: PluginContext): void + + /** Called when the plugin is unmounted */ + onUnmount(): void + + /** Pointer event handlers (optional) */ + onPointerDown?(e: PointerEvent): void + onPointerMove?(e: PointerEvent): void + onPointerUp?(e: PointerEvent): void + + /** Called each animation frame to render the overlay */ + render(ctx: CanvasRenderingContext2D): void +} diff --git a/soleprint/common/ui/src/renderers/FrameRenderer.vue b/soleprint/common/ui/src/renderers/FrameRenderer.vue new file mode 100644 index 0000000..50118c0 --- /dev/null +++ b/soleprint/common/ui/src/renderers/FrameRenderer.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/soleprint/common/ui/src/renderers/GraphRenderer.vue b/soleprint/common/ui/src/renderers/GraphRenderer.vue new file mode 100644 index 0000000..53017cb --- /dev/null +++ b/soleprint/common/ui/src/renderers/GraphRenderer.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/soleprint/common/ui/src/renderers/LogRenderer.vue b/soleprint/common/ui/src/renderers/LogRenderer.vue new file mode 100644 index 0000000..2974909 --- /dev/null +++ b/soleprint/common/ui/src/renderers/LogRenderer.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/soleprint/common/ui/src/renderers/TableRenderer.vue b/soleprint/common/ui/src/renderers/TableRenderer.vue new file mode 100644 index 0000000..d4c3d69 --- /dev/null +++ b/soleprint/common/ui/src/renderers/TableRenderer.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/soleprint/common/ui/src/renderers/TimeSeriesRenderer.vue b/soleprint/common/ui/src/renderers/TimeSeriesRenderer.vue new file mode 100644 index 0000000..c664ffb --- /dev/null +++ b/soleprint/common/ui/src/renderers/TimeSeriesRenderer.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/soleprint/common/ui/src/tokens.css b/soleprint/common/ui/src/tokens.css new file mode 100644 index 0000000..4858ec3 --- /dev/null +++ b/soleprint/common/ui/src/tokens.css @@ -0,0 +1,59 @@ +/* Framework design tokens — retheme by replacing this file */ +:root { + /* spacing scale (4px base) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + + /* color — dark theme (observability UIs are always dark) */ + --surface-0: #0d0d0f; + --surface-1: #16161a; + --surface-2: #1e1e24; + --surface-3: #26262f; + --border: #2e2e38; + + --text-primary: #e8e8f0; + --text-secondary: #8888a0; + --text-dim: #555568; + + /* status colors */ + --status-idle: #555568; + --status-live: #3ecf8e; + --status-processing: #4f9cf9; + --status-escalating: #f5a623; + --status-error: #f06565; + + /* confidence color scale (low → high) */ + --conf-low: #f06565; + --conf-mid: #f5a623; + --conf-high: #3ecf8e; + + /* typography */ + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --font-ui: 'Inter', system-ui, sans-serif; + --font-size-sm: 11px; + --font-size-base: 13px; + --font-size-lg: 15px; + + /* panel chrome */ + --panel-radius: 6px; + --panel-border: 1px solid var(--border); + --panel-header-height: 36px; +} + +/* Animated gradient outline for buttons in a waiting state. + Usage: add class="waiting" to any button/element. */ +@keyframes waiting-glow { + 0% { box-shadow: 0 0 3px 1px var(--status-processing); } + 33% { box-shadow: 0 0 3px 1px var(--status-live); } + 66% { box-shadow: 0 0 3px 1px var(--status-escalating); } + 100% { box-shadow: 0 0 3px 1px var(--status-processing); } +} + +.waiting { + animation: waiting-glow 2s linear infinite; + outline: 1px solid transparent; +} diff --git a/soleprint/common/ui/tsconfig.json b/soleprint/common/ui/tsconfig.json new file mode 100644 index 0000000..cee8317 --- /dev/null +++ b/soleprint/common/ui/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/soleprint/common/ui/vitest.config.ts b/soleprint/common/ui/vitest.config.ts new file mode 100644 index 0000000..2b1c323 --- /dev/null +++ b/soleprint/common/ui/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + }, +}) diff --git a/soleprint/run.py b/soleprint/run.py index da8aba0..2a3540c 100644 --- a/soleprint/run.py +++ b/soleprint/run.py @@ -610,13 +610,13 @@ def index(request: Request): showcase_url = config.get("showcase_url") return templates.TemplateResponse( + request, "index.html", - { - "request": request, + context={ "artery": "/artery", "atlas": "/atlas", "station": "/station", - "managed": managed, + "managed": bool(managed), "managed_url": managed_url, "showcase_url": showcase_url, }, diff --git a/soleprint/station/tools/hub/ports b/soleprint/station/tools/hub/ports deleted file mode 100644 index 8ae23fd..0000000 --- a/soleprint/station/tools/hub/ports +++ /dev/null @@ -1,13 +0,0 @@ -# Core Nest Ports -# Format: one port per line -# Comments allowed with # - -# Amar -3000 -8000 - -# Pawprint Services -13000 -13001 -13002 -13003 diff --git a/soleprint/station/tools/hub/update-ports.sh b/soleprint/station/tools/hub/update-ports.sh deleted file mode 100755 index 360f0d8..0000000 --- a/soleprint/station/tools/hub/update-ports.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash -# Update ports file from core_nest configuration -# Gathers ports from pawprint and amar .env files -# -# Usage: ./update-ports.sh - -set -e - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PORTS_FILE="$SCRIPT_DIR/ports" - -# TODO: Make these configurable or auto-detect -CORE_NEST_ROOT="${CORE_NEST_ROOT:-/home/mariano/core_nest}" -PAWPRINT_ENV="$CORE_NEST_ROOT/pawprint/.env" -AMAR_ENV="$CORE_NEST_ROOT/amar/.env" - -echo "=== Updating Core Nest Ports ===" -echo "" - -# Backup existing ports file -if [ -f "$PORTS_FILE" ]; then - cp "$PORTS_FILE" "$PORTS_FILE.bak" - echo " ✓ Backed up existing ports to ports.bak" -fi - -# Start new ports file -cat > "$PORTS_FILE" <<'EOF' -# Core Nest Ports -# Auto-generated by update-ports.sh -# Format: one port per line -# Comments allowed with # - -EOF - -# Extract ports from amar .env -if [ -f "$AMAR_ENV" ]; then - echo " Reading amar ports..." - echo "# Amar" >> "$PORTS_FILE" - - # Frontend port (default 3000) - AMAR_FRONTEND_PORT=$(grep "^AMAR_FRONTEND_PORT=" "$AMAR_ENV" 2>/dev/null | cut -d'=' -f2 || echo "3000") - echo "$AMAR_FRONTEND_PORT" >> "$PORTS_FILE" - - # Backend port (default 8000) - AMAR_BACKEND_PORT=$(grep "^AMAR_BACKEND_PORT=" "$AMAR_ENV" 2>/dev/null | cut -d'=' -f2 || echo "8000") - echo "$AMAR_BACKEND_PORT" >> "$PORTS_FILE" - - echo " ✓ Added amar ports: $AMAR_FRONTEND_PORT, $AMAR_BACKEND_PORT" -else - echo " ⚠ Amar .env not found, using defaults" - echo "# Amar (defaults)" >> "$PORTS_FILE" - echo "3000" >> "$PORTS_FILE" - echo "8000" >> "$PORTS_FILE" -fi - -echo "" >> "$PORTS_FILE" - -# Extract ports from pawprint .env -if [ -f "$PAWPRINT_ENV" ]; then - echo " Reading pawprint ports..." - echo "# Pawprint Services" >> "$PORTS_FILE" - - PAWPRINT_PORT=$(grep "^PAWPRINT_PORT=" "$PAWPRINT_ENV" 2>/dev/null | cut -d'=' -f2 || echo "13000") - ARTERY_PORT=$(grep "^ARTERY_PORT=" "$PAWPRINT_ENV" 2>/dev/null | cut -d'=' -f2 || echo "13001") - ALBUM_PORT=$(grep "^ALBUM_PORT=" "$PAWPRINT_ENV" 2>/dev/null | cut -d'=' -f2 || echo "13002") - WARD_PORT=$(grep "^WARD_PORT=" "$PAWPRINT_ENV" 2>/dev/null | cut -d'=' -f2 || echo "13003") - - echo "$PAWPRINT_PORT" >> "$PORTS_FILE" - echo "$ARTERY_PORT" >> "$PORTS_FILE" - echo "$ALBUM_PORT" >> "$PORTS_FILE" - echo "$WARD_PORT" >> "$PORTS_FILE" - - echo " ✓ Added pawprint ports: $PAWPRINT_PORT, $ARTERY_PORT, $ALBUM_PORT, $WARD_PORT" -else - echo " ⚠ Pawprint .env not found, using defaults" - echo "# Pawprint Services (defaults)" >> "$PORTS_FILE" - echo "13000" >> "$PORTS_FILE" - echo "13001" >> "$PORTS_FILE" - echo "13002" >> "$PORTS_FILE" - echo "13003" >> "$PORTS_FILE" -fi - -echo "" -echo "=== Done ===" -echo "" -echo "Updated ports file: $PORTS_FILE" -echo "" -cat "$PORTS_FILE" diff --git a/soleprint/station/tools/modelgen/generator/__init__.py b/soleprint/station/tools/modelgen/generator/__init__.py index 5abc0ac..7e1b55c 100644 --- a/soleprint/station/tools/modelgen/generator/__init__.py +++ b/soleprint/station/tools/modelgen/generator/__init__.py @@ -7,36 +7,38 @@ Supported generators: - TypeScriptGenerator: TypeScript interfaces - ProtobufGenerator: Protocol Buffer definitions - PrismaGenerator: Prisma schema -- GrapheneGenerator: Graphene ObjectType/InputObjectType classes +- StrawberryGenerator: Strawberry type/input/enum classes """ from typing import Dict, Type from .base import BaseGenerator from .django import DjangoGenerator -from .graphene import GrapheneGenerator from .prisma import PrismaGenerator from .protobuf import ProtobufGenerator from .pydantic import PydanticGenerator +from .sqlmodel import SQLModelGenerator +from .strawberry import StrawberryGenerator from .typescript import TypeScriptGenerator # Registry of available generators GENERATORS: Dict[str, Type[BaseGenerator]] = { "pydantic": PydanticGenerator, "django": DjangoGenerator, + "sqlmodel": SQLModelGenerator, "typescript": TypeScriptGenerator, "ts": TypeScriptGenerator, # Alias "protobuf": ProtobufGenerator, "proto": ProtobufGenerator, # Alias "prisma": PrismaGenerator, - "graphene": GrapheneGenerator, + "strawberry": StrawberryGenerator, } __all__ = [ "BaseGenerator", "PydanticGenerator", "DjangoGenerator", - "GrapheneGenerator", + "StrawberryGenerator", "TypeScriptGenerator", "ProtobufGenerator", "PrismaGenerator", diff --git a/soleprint/station/tools/modelgen/generator/pydantic.py b/soleprint/station/tools/modelgen/generator/pydantic.py index c2676f2..866f3b9 100644 --- a/soleprint/station/tools/modelgen/generator/pydantic.py +++ b/soleprint/station/tools/modelgen/generator/pydantic.py @@ -12,7 +12,7 @@ from enum import Enum from pathlib import Path from typing import Any, List, get_type_hints -from ..helpers import get_origin_name, get_type_name, unwrap_optional +from ..helpers import get_origin_name, get_type_name, is_dataclass_type, unwrap_optional from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition from ..types import PYDANTIC_RESOLVERS from .base import BaseGenerator @@ -54,8 +54,9 @@ class PydanticGenerator(BaseGenerator): if hasattr(models, "get_shared_component"): content = self._generate_from_config(models) elif hasattr(models, "models"): + all_models = models.models + getattr(models, "api_models", []) content = self._generate_from_definitions( - models.models, getattr(models, "enums", []) + all_models, getattr(models, "enums", []) ) elif isinstance(models, tuple): content = self._generate_from_definitions(models[0], models[1]) @@ -245,6 +246,7 @@ class PydanticGenerator(BaseGenerator): "", ] + def _generate_enum(self, enum_def: EnumDefinition) -> List[str]: lines = [f"class {enum_def.name}(str, Enum):"] for name, value in enum_def.values: @@ -307,6 +309,11 @@ class PydanticGenerator(BaseGenerator): if isinstance(base, type) and issubclass(base, Enum) else None ) + or ( + PYDANTIC_RESOLVERS["dataclass"] + if is_dataclass_type(base) + else None + ) ) result = resolver(base) if resolver else "str" return f"Optional[{result}]" if optional else result @@ -321,7 +328,12 @@ class PydanticGenerator(BaseGenerator): if isinstance(default, Enum): return f" = {default.__class__.__name__}.{default.name}" if callable(default): - return " = Field(default_factory=list)" if "list" in str(default) else "" + default_str = str(default) + if "list" in default_str: + return " = Field(default_factory=list)" + if "dict" in default_str: + return " = Field(default_factory=dict)" + return "" return f" = {default!r}" def _generate_from_config(self, config) -> str: diff --git a/soleprint/station/tools/modelgen/generator/sqlmodel.py b/soleprint/station/tools/modelgen/generator/sqlmodel.py new file mode 100644 index 0000000..40c574b --- /dev/null +++ b/soleprint/station/tools/modelgen/generator/sqlmodel.py @@ -0,0 +1,181 @@ +""" +SQLModel Generator + +Generates SQLModel table classes from model definitions. +Extends the Pydantic generator — SQLModel classes *are* Pydantic models +with table=True and SQLAlchemy column config for JSON fields. +""" + +import dataclasses as dc +import re +from enum import Enum +from typing import Any, List, get_type_hints + +from ..helpers import get_origin_name, get_type_name, unwrap_optional +from .pydantic import PydanticGenerator + + +# --------------------------------------------------------------------------- +# Field resolvers — each returns a Field() string or None to fall through +# --------------------------------------------------------------------------- + +def _resolve_special(name, _base, _origin, _optional, _default): + """id, created_at, updated_at get fixed Field() definitions.""" + specials = { + "id": "Field(default_factory=uuid4, primary_key=True)", + "created_at": "Field(default_factory=datetime.utcnow)", + "updated_at": "Field(default_factory=datetime.utcnow)", + } + return specials.get(name) + + +def _resolve_json(name, _base, origin, _optional, _default): + """Dict and List fields → sa_column=Column(JSON).""" + mapping = { + "dict": ("dict", "{}"), + "list": ("list", "[]"), + } + entry = mapping.get(origin) + if not entry: + return None + factory, server_default = entry + return ( + f"Field(default_factory={factory}, " + f"sa_column=Column(JSON, nullable=False, server_default='{server_default}'))" + ) + + +def _resolve_indexed(name, _base, _origin, optional, _default): + """Known indexed fields.""" + indexed = {"source_asset_id", "parent_job_id", "job_id", "canonical_name"} + if name not in indexed: + return None + if optional: + return "Field(default=None, index=True)" + return "Field(index=True)" + + +def _resolve_optional(_name, _base, _origin, optional, _default): + """Optional fields default to None.""" + if optional: + return "None" + return None + + +def _resolve_default(_name, _base, _origin, _optional, default): + """Fields with explicit defaults. Enum before str (str enums are both).""" + if default is dc.MISSING or default is None: + return None + if isinstance(default, Enum): + return f'"{default.value}"' + if isinstance(default, bool): + return str(default) + if isinstance(default, (int, float)): + return str(default) + if isinstance(default, str): + return f'"{default}"' + return None + + +# Resolver chain — first non-None result wins +_FIELD_RESOLVERS = [ + _resolve_special, + _resolve_json, + _resolve_indexed, + _resolve_optional, + _resolve_default, +] + + +def _resolve_field(name, type_hint, default): + """Run the resolver chain for a field. Returns ' = ...' string.""" + base, is_optional = unwrap_optional(type_hint) + origin = get_origin_name(base) + + for resolver in _FIELD_RESOLVERS: + result = resolver(name, base, origin, is_optional, default) + if result is not None: + return f" = {result}" + return "" + + +def _to_snake(name): + """CamelCase → snake_case for table names.""" + return re.sub(r"(?<=[a-z])(?=[A-Z])", "_", name).lower() + + +_HEADER = [ + '"""', + "SQLModel Table Models - GENERATED FILE", + "", + "Do not edit directly. Regenerate using modelgen.", + '"""', + "", + "from datetime import datetime", + "from enum import Enum", + "from typing import Any, Dict, List, Optional", + "from uuid import UUID, uuid4", + "", + "from sqlmodel import SQLModel, Field, Column", + "from sqlalchemy import JSON", + "", +] + + +class SQLModelGenerator(PydanticGenerator): + """Generates SQLModel table classes.""" + + def _generate_header(self) -> List[str]: + return list(_HEADER) + + def _generate_model_from_dataclass(self, cls: type) -> List[str]: + return _build_table( + cls.__name__, + cls.__doc__ or cls.__name__, + get_type_hints(cls), + {f.name: f for f in dc.fields(cls)}, + self._resolve_type, + ) + + def _generate_model_from_definition(self, model_def) -> List[str]: + hints = {f.name: f.type_hint for f in model_def.fields} + defaults = {f.name: f.default for f in model_def.fields} + + class FakeField: + def __init__(self, default): + self.default = default + + fields = {name: FakeField(defaults.get(name, dc.MISSING)) for name in hints} + return _build_table( + model_def.name, + model_def.docstring or model_def.name, + hints, + fields, + self._resolve_type, + ) + + +def _build_table(name, docstring, hints, fields, resolve_type_fn): + """Build a SQLModel table class from field data.""" + table_name = _to_snake(name) + lines = [ + f"class {name}(SQLModel, table=True):", + f' """{docstring.strip().split(chr(10))[0]}"""', + f' __tablename__ = "{table_name}"', + "", + ] + + for field_name, type_hint in hints.items(): + if field_name.startswith("_"): + continue + + field = fields.get(field_name) + default_val = dc.MISSING + if field and field.default is not dc.MISSING: + default_val = field.default + + py_type = resolve_type_fn(type_hint, False) + field_extra = _resolve_field(field_name, type_hint, default_val) + lines.append(f" {field_name}: {py_type}{field_extra}") + + return lines diff --git a/soleprint/station/tools/modelgen/generator/graphene.py b/soleprint/station/tools/modelgen/generator/strawberry.py similarity index 62% rename from soleprint/station/tools/modelgen/generator/graphene.py rename to soleprint/station/tools/modelgen/generator/strawberry.py index 503bbba..14ed78e 100644 --- a/soleprint/station/tools/modelgen/generator/graphene.py +++ b/soleprint/station/tools/modelgen/generator/strawberry.py @@ -1,28 +1,29 @@ """ -Graphene Generator +Strawberry Generator -Generates graphene ObjectType and InputObjectType classes from model definitions. +Generates strawberry type, input, and enum classes from model definitions. Only generates type definitions — queries, mutations, and resolvers are hand-written. """ +import dataclasses as dc from enum import Enum from pathlib import Path from typing import Any, List, get_type_hints from ..helpers import get_origin_name, get_type_name, unwrap_optional from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition -from ..types import GRAPHENE_RESOLVERS +from ..types import STRAWBERRY_RESOLVERS from .base import BaseGenerator -class GrapheneGenerator(BaseGenerator): - """Generates graphene type definition files.""" +class StrawberryGenerator(BaseGenerator): + """Generates strawberry type definition files.""" def file_extension(self) -> str: return ".py" def generate(self, models, output_path: Path) -> None: - """Generate graphene types to output_path.""" + """Generate strawberry types to output_path.""" output_path.parent.mkdir(parents=True, exist_ok=True) if hasattr(models, "models"): @@ -47,22 +48,18 @@ class GrapheneGenerator(BaseGenerator): enums: List[EnumDefinition], api_models: List[ModelDefinition], ) -> str: - """Generate from ModelDefinition objects.""" lines = self._generate_header() - # Generate enums as graphene.Enum for enum_def in enums: lines.extend(self._generate_enum(enum_def)) lines.append("") lines.append("") - # Generate domain models as ObjectType for model_def in models: lines.extend(self._generate_object_type(model_def)) lines.append("") lines.append("") - # Generate API models — request types as InputObjectType, others as ObjectType for model_def in api_models: if model_def.name.endswith("Request"): lines.extend(self._generate_input_type(model_def)) @@ -74,7 +71,6 @@ class GrapheneGenerator(BaseGenerator): return "\n".join(lines).rstrip() + "\n" def _generate_from_dataclasses(self, dataclasses: List[type]) -> str: - """Generate from Python dataclasses.""" lines = self._generate_header() enums_generated = set() @@ -99,37 +95,38 @@ class GrapheneGenerator(BaseGenerator): def _generate_header(self) -> List[str]: return [ '"""', - "Graphene Types - GENERATED FILE", + "Strawberry Types - GENERATED FILE", "", "Do not edit directly. Regenerate using modelgen.", '"""', "", - "import graphene", + "import strawberry", + "from enum import Enum", + "from typing import List, Optional", + "from uuid import UUID", + "from datetime import datetime", + "from strawberry.scalars import JSON", "", "", ] def _generate_enum(self, enum_def: EnumDefinition) -> List[str]: - """Generate graphene.Enum from EnumDefinition.""" - lines = [f"class {enum_def.name}(graphene.Enum):"] + lines = ["@strawberry.enum", f"class {enum_def.name}(Enum):"] for name, value in enum_def.values: lines.append(f' {name} = "{value}"') return lines def _generate_enum_from_python(self, enum_cls: type) -> List[str]: - """Generate graphene.Enum from Python Enum.""" - lines = [f"class {enum_cls.__name__}(graphene.Enum):"] + lines = ["@strawberry.enum", f"class {enum_cls.__name__}(Enum):"] for member in enum_cls: lines.append(f' {member.name} = "{member.value}"') return lines def _generate_object_type(self, model_def: ModelDefinition) -> List[str]: - """Generate graphene.ObjectType from ModelDefinition.""" name = model_def.name - # Append Type suffix if not already present type_name = f"{name}Type" if not name.endswith("Type") else name - lines = [f"class {type_name}(graphene.ObjectType):"] + lines = ["@strawberry.type", f"class {type_name}:"] if model_def.docstring: doc = model_def.docstring.strip().split("\n")[0] lines.append(f' """{doc}"""') @@ -139,23 +136,19 @@ class GrapheneGenerator(BaseGenerator): lines.append(" pass") else: for field in model_def.fields: - graphene_type = self._resolve_type(field.type_hint, field.optional) - lines.append(f" {field.name} = {graphene_type}") + type_str = self._resolve_type(field.type_hint, optional=True) + lines.append(f" {field.name}: {type_str} = None") return lines def _generate_input_type(self, model_def: ModelDefinition) -> List[str]: - """Generate graphene.InputObjectType from ModelDefinition.""" - import dataclasses as dc - name = model_def.name - # Convert FooRequest -> FooInput if name.endswith("Request"): input_name = name[: -len("Request")] + "Input" else: input_name = f"{name}Input" - lines = [f"class {input_name}(graphene.InputObjectType):"] + lines = ["@strawberry.input", f"class {input_name}:"] if model_def.docstring: doc = model_def.docstring.strip().split("\n")[0] lines.append(f' """{doc}"""') @@ -164,73 +157,64 @@ class GrapheneGenerator(BaseGenerator): if not model_def.fields: lines.append(" pass") else: + # Required fields first, then optional/defaulted + required = [] + optional = [] for field in model_def.fields: - graphene_type = self._resolve_type(field.type_hint, field.optional) - # Required only if not optional AND no default value has_default = field.default is not dc.MISSING if not field.optional and not has_default: - graphene_type = self._make_required(graphene_type) - elif has_default and not field.optional: - graphene_type = self._add_default(graphene_type, field.default) - lines.append(f" {field.name} = {graphene_type}") + required.append(field) + else: + optional.append(field) + + for field in required: + type_str = self._resolve_type(field.type_hint, optional=False) + lines.append(f" {field.name}: {type_str}") + + for field in optional: + has_default = field.default is not dc.MISSING + if has_default and not callable(field.default): + type_str = self._resolve_type(field.type_hint, optional=False) + lines.append(f" {field.name}: {type_str} = {field.default!r}") + else: + type_str = self._resolve_type(field.type_hint, optional=True) + lines.append(f" {field.name}: {type_str} = None") return lines def _generate_object_type_from_dataclass(self, cls: type) -> List[str]: - """Generate graphene.ObjectType from a dataclass.""" - import dataclasses as dc - type_name = f"{cls.__name__}Type" - lines = [f"class {type_name}(graphene.ObjectType):"] + lines = ["@strawberry.type", f"class {type_name}:"] hints = get_type_hints(cls) for name, type_hint in hints.items(): if name.startswith("_"): continue - graphene_type = self._resolve_type(type_hint, False) - lines.append(f" {name} = {graphene_type}") + type_str = self._resolve_type(type_hint, optional=True) + lines.append(f" {name}: {type_str} = None") return lines def _resolve_type(self, type_hint: Any, optional: bool) -> str: - """Resolve Python type to graphene field call string.""" + """Resolve Python type hint to a strawberry annotation string.""" base, is_optional = unwrap_optional(type_hint) optional = optional or is_optional origin = get_origin_name(base) type_name = get_type_name(base) - # Look up resolver resolver = ( - GRAPHENE_RESOLVERS.get(origin) - or GRAPHENE_RESOLVERS.get(type_name) - or GRAPHENE_RESOLVERS.get(base) + STRAWBERRY_RESOLVERS.get(origin) + or STRAWBERRY_RESOLVERS.get(type_name) + or STRAWBERRY_RESOLVERS.get(base) or ( - GRAPHENE_RESOLVERS["enum"] + STRAWBERRY_RESOLVERS["enum"] if isinstance(base, type) and issubclass(base, Enum) else None ) ) - result = resolver(base) if resolver else "graphene.String" + inner = resolver(base) if resolver else "str" - # List types already have () syntax from resolver - if result.startswith("graphene.List("): - return result - - # Scalar types: add () call - return f"{result}()" - - def _make_required(self, field_str: str) -> str: - """Add required=True to a graphene field.""" - if field_str.endswith("()"): - return field_str[:-1] + "required=True)" - return field_str - - def _add_default(self, field_str: str, default: Any) -> str: - """Add default_value to a graphene field.""" - if callable(default): - # default_factory — skip, graphene doesn't support factories - return field_str - if field_str.endswith("()"): - return field_str[:-1] + f"default_value={default!r})" - return field_str + if optional: + return f"Optional[{inner}]" + return inner diff --git a/soleprint/station/tools/modelgen/generator/typescript.py b/soleprint/station/tools/modelgen/generator/typescript.py index e1cc5f8..09d796f 100644 --- a/soleprint/station/tools/modelgen/generator/typescript.py +++ b/soleprint/station/tools/modelgen/generator/typescript.py @@ -8,7 +8,7 @@ from enum import Enum from pathlib import Path from typing import Any, List, get_type_hints -from ..helpers import get_origin_name, get_type_name, unwrap_optional +from ..helpers import get_origin_name, get_type_name, is_dataclass_type, unwrap_optional from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition from ..types import TS_RESOLVERS from .base import BaseGenerator @@ -139,6 +139,11 @@ class TypeScriptGenerator(BaseGenerator): if isinstance(base, type) and issubclass(base, Enum) else None ) + or ( + TS_RESOLVERS["dataclass"] + if is_dataclass_type(base) + else None + ) ) result = resolver(base) if resolver else "string" diff --git a/soleprint/station/tools/modelgen/helpers.py b/soleprint/station/tools/modelgen/helpers.py index 60d6c51..d445447 100644 --- a/soleprint/station/tools/modelgen/helpers.py +++ b/soleprint/station/tools/modelgen/helpers.py @@ -44,6 +44,17 @@ def get_list_inner(type_hint: Any) -> str: return "str" +def is_dataclass_type(type_hint: Any) -> bool: + """Check if type is a dataclass (nested model reference).""" + return isinstance(type_hint, type) and dc.is_dataclass(type_hint) + + +def get_list_inner_type(type_hint: Any) -> Any: + """Get the raw inner type of List[T] (not stringified).""" + args = get_args(type_hint) + return args[0] if args else None + + def get_field_default(field: dc.Field) -> Any: """Get default value from dataclass field.""" if field.default is not dc.MISSING: diff --git a/soleprint/station/tools/modelgen/loader/schema.py b/soleprint/station/tools/modelgen/loader/schema.py index 78833f3..10f461b 100644 --- a/soleprint/station/tools/modelgen/loader/schema.py +++ b/soleprint/station/tools/modelgen/loader/schema.py @@ -101,6 +101,12 @@ class SchemaLoader: for enum_cls in enums: self.enums.append(self._parse_enum(enum_cls)) + # Extract VIEWS (view/event projections) + if load_all or "views" in include: + views = getattr(module, "VIEWS", []) + for cls in views: + self.api_models.append(self._parse_dataclass(cls)) + # Extract GRPC_MESSAGES (optional) if load_all or "grpc" in include: grpc_messages = getattr(module, "GRPC_MESSAGES", []) @@ -117,6 +123,20 @@ class SchemaLoader: methods=grpc_service.get("methods", []), ) + # Generic group loader: any include group not handled above + # is looked up as UPPER_CASE attribute on the module. + # e.g. include "detect_views" → module.DETECT_VIEWS + if include: + known_groups = {"dataclasses", "enums", "api", "views", "grpc"} + for group in include - known_groups: + attr_name = group.upper() + items = getattr(module, attr_name, []) + for cls in items: + if isinstance(cls, type) and dc.is_dataclass(cls): + self.api_models.append(self._parse_dataclass(cls)) + elif isinstance(cls, type) and issubclass(cls, Enum): + self.enums.append(self._parse_enum(cls)) + return self def _import_module(self, path: Path): diff --git a/soleprint/station/tools/modelgen/pyproject.toml b/soleprint/station/tools/modelgen/pyproject.toml new file mode 100644 index 0000000..4f30d69 --- /dev/null +++ b/soleprint/station/tools/modelgen/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "soleprint-modelgen" +version = "0.2.0" +description = "Multi-source, multi-target model code generator" +requires-python = ">=3.10" +dependencies = [] + +[project.scripts] +modelgen = "modelgen.__main__:main" + +[tool.setuptools.packages.find] +include = ["modelgen*"] diff --git a/soleprint/station/tools/modelgen/types.py b/soleprint/station/tools/modelgen/types.py index cf35e48..274d14a 100644 --- a/soleprint/station/tools/modelgen/types.py +++ b/soleprint/station/tools/modelgen/types.py @@ -5,6 +5,7 @@ Type mappings for each output format. Used by generators to convert Python types to target framework types. """ +import dataclasses as dc from typing import Any, Callable, get_args # ============================================================================= @@ -39,8 +40,12 @@ DJANGO_SPECIAL: dict[str, str] = { def _get_list_inner(type_hint: Any) -> str: """Get inner type of List[T] for Pydantic.""" args = get_args(type_hint) - if args and args[0] in (str, int, float, bool): - return {str: "str", int: "int", float: "float", bool: "bool"}[args[0]] + if args: + inner = args[0] + if inner in (str, int, float, bool): + return {str: "str", int: "int", float: "float", bool: "bool"}[inner] + if isinstance(inner, type) and dc.is_dataclass(inner): + return inner.__name__ return "str" @@ -49,11 +54,13 @@ PYDANTIC_RESOLVERS: dict[Any, Callable[[Any], str]] = { int: lambda _: "int", float: lambda _: "float", bool: lambda _: "bool", + Any: lambda _: "Any", "UUID": lambda _: "UUID", "datetime": lambda _: "datetime", "dict": lambda _: "Dict[str, Any]", "list": lambda base: f"List[{_get_list_inner(base)}]", "enum": lambda base: base.__name__, + "dataclass": lambda base: base.__name__, } # ============================================================================= @@ -72,6 +79,8 @@ def _resolve_ts_list(base: Any) -> str: return "number[]" elif inner is bool: return "boolean[]" + elif isinstance(inner, type) and dc.is_dataclass(inner): + return f"{inner.__name__}[]" return "string[]" @@ -85,6 +94,7 @@ TS_RESOLVERS: dict[Any, Callable[[Any], str]] = { "dict": lambda _: "Record", "list": _resolve_ts_list, "enum": lambda base: base.__name__, + "dataclass": lambda base: base.__name__, } # ============================================================================= @@ -139,34 +149,34 @@ PRISMA_SPECIAL: dict[str, str] = { } # ============================================================================= -# Graphene Type Resolvers +# Strawberry Type Resolvers # ============================================================================= -def _resolve_graphene_list(base: Any) -> str: - """Resolve graphene List type.""" +def _resolve_strawberry_list(base: Any) -> str: + """Resolve strawberry List type annotation.""" args = get_args(base) if args: inner = args[0] if inner is str: - return "graphene.List(graphene.String)" + return "List[str]" elif inner is int: - return "graphene.List(graphene.Int)" + return "List[int]" elif inner is float: - return "graphene.List(graphene.Float)" + return "List[float]" elif inner is bool: - return "graphene.List(graphene.Boolean)" - return "graphene.List(graphene.String)" + return "List[bool]" + return "List[str]" -GRAPHENE_RESOLVERS: dict[Any, Callable[[Any], str]] = { - str: lambda _: "graphene.String", - int: lambda _: "graphene.Int", - float: lambda _: "graphene.Float", - bool: lambda _: "graphene.Boolean", - "UUID": lambda _: "graphene.UUID", - "datetime": lambda _: "graphene.DateTime", - "dict": lambda _: "graphene.JSONString", - "list": _resolve_graphene_list, - "enum": lambda base: f"graphene.String", # Enums exposed as strings in GQL +STRAWBERRY_RESOLVERS: dict[Any, Callable[[Any], str]] = { + str: lambda _: "str", + int: lambda _: "int", + float: lambda _: "float", + bool: lambda _: "bool", + "UUID": lambda _: "UUID", + "datetime": lambda _: "datetime", + "dict": lambda _: "JSON", + "list": _resolve_strawberry_list, + "enum": lambda base: base.__name__, } diff --git a/soleprint/station/tools/sbwrapper/config.json b/soleprint/station/tools/sbwrapper/config.json index 1250aa8..3a8bfed 100755 --- a/soleprint/station/tools/sbwrapper/config.json +++ b/soleprint/station/tools/sbwrapper/config.json @@ -1,5 +1,5 @@ { - "room_name": "amar", + "room_name": "standalone", "wrapper": { "enabled": true, "environment": { @@ -10,31 +10,19 @@ { "id": "admin", "label": "Admin", - "username": "admin@test.com", - "password": "Amar2025!", + "username": "admin@example.com", + "password": "admin", "icon": "👑", "role": "ADMIN" }, { - "id": "vet1", - "label": "Vet 1", - "username": "vet@test.com", - "password": "Amar2025!", - "icon": "🩺", - "role": "VET" - }, - { - "id": "tutor1", - "label": "Tutor 1", - "username": "tutor@test.com", - "password": "Amar2025!", - "icon": "🐶", + "id": "user1", + "label": "User 1", + "username": "user@example.com", + "password": "user", + "icon": "👤", "role": "USER" } - ], - "jira": { - "ticket_id": "VET-535", - "epic": "EPIC-51.3" - } + ] } } diff --git a/soleprint/station/tools/tester/base.py b/soleprint/station/tools/tester/base.py index 048c1cd..0cc6a62 100644 --- a/soleprint/station/tools/tester/base.py +++ b/soleprint/station/tools/tester/base.py @@ -2,12 +2,30 @@ Pure HTTP Contract Tests - Base Class Framework-agnostic: works against ANY backend implementation. +Does NOT manage database - expects a ready environment. + +Auth strategies (set CONTRACT_TEST_AUTH_TYPE env var): + - bearer (default): JWT token via CONTRACT_TEST_TOKEN or fetched from TOKEN_ENDPOINT + - api-key: API key via CONTRACT_TEST_API_KEY + - none: No authentication + +Usage: + CONTRACT_TEST_URL=http://127.0.0.1:8000 pytest + CONTRACT_TEST_URL=http://127.0.0.1:8000 CONTRACT_TEST_TOKEN=your_jwt pytest + CONTRACT_TEST_URL=http://127.0.0.1:8000 CONTRACT_TEST_AUTH_TYPE=api-key CONTRACT_TEST_API_KEY=key pytest """ +import os import unittest import httpx -from .config import config + +def get_base_url(): + """Get base URL from environment (required)""" + url = os.environ.get("CONTRACT_TEST_URL", "") + if not url: + raise ValueError("CONTRACT_TEST_URL environment variable required") + return url.rstrip("/") class ContractTestCase(unittest.TestCase): @@ -18,35 +36,71 @@ class ContractTestCase(unittest.TestCase): - Framework-agnostic (works with Django, FastAPI, Node, etc.) - Pure HTTP via httpx library - No database access - all data through API - - API Key authentication + - Configurable authentication (bearer, api-key, none) """ + # Auth config - override via environment or subclass + AUTH_TYPE = os.environ.get("CONTRACT_TEST_AUTH_TYPE", "bearer") + TEST_USER_EMAIL = os.environ.get("CONTRACT_TEST_USER", "contract_test@example.com") + TEST_USER_PASSWORD = os.environ.get("CONTRACT_TEST_PASSWORD", "testpass123") + TOKEN_ENDPOINT = os.environ.get("CONTRACT_TEST_TOKEN_ENDPOINT", "/api/token/") + + # Class-level cache _base_url = None + _token = None _api_key = None @classmethod def setUpClass(cls): """Set up once per test class""" super().setUpClass() - cls._base_url = config.get("CONTRACT_TEST_URL", "").rstrip("/") - if not cls._base_url: - raise ValueError("CONTRACT_TEST_URL required in environment") + cls._base_url = get_base_url() - cls._api_key = config.get("CONTRACT_TEST_API_KEY", "") - if not cls._api_key: - raise ValueError("CONTRACT_TEST_API_KEY required in environment") + if cls.AUTH_TYPE == "bearer": + cls._token = os.environ.get("CONTRACT_TEST_TOKEN", "") + if not cls._token: + cls._token = cls._fetch_token() + elif cls.AUTH_TYPE == "api-key": + cls._api_key = os.environ.get("CONTRACT_TEST_API_KEY", "") + if not cls._api_key: + raise ValueError("CONTRACT_TEST_API_KEY required for api-key auth") + + @classmethod + def _fetch_token(cls): + """Get JWT token for authentication""" + url = f"{cls._base_url}{cls.TOKEN_ENDPOINT}" + try: + response = httpx.post(url, json={ + "username": cls.TEST_USER_EMAIL, + "password": cls.TEST_USER_PASSWORD, + }, timeout=10) + if response.status_code == 200: + return response.json().get("access", "") + else: + print(f"Warning: Token request failed with {response.status_code}") + except httpx.RequestError as e: + print(f"Warning: Token request failed: {e}") + return "" @property def base_url(self): return self._base_url + @property + def token(self): + return self._token + @property def api_key(self): return self._api_key def _auth_headers(self): - """Get authorization headers""" - return {"Authorization": f"Api-Key {self.api_key}"} + """Get authorization headers based on auth type""" + if self.AUTH_TYPE == "bearer" and self._token: + return {"Authorization": f"Bearer {self._token}"} + elif self.AUTH_TYPE == "api-key" and self._api_key: + return {"Authorization": f"Api-Key {self._api_key}"} + return {} # ========================================================================= # HTTP helpers @@ -117,3 +171,6 @@ class ContractTestCase(unittest.TestCase): """Assert data is a list with minimum length""" self.assertIsInstance(data, list) self.assertGreaterEqual(len(data), min_length) + + +__all__ = ["ContractTestCase", "get_base_url"] diff --git a/soleprint/station/tools/tester/config.py b/soleprint/station/tools/tester/config.py index a477830..3059b77 100644 --- a/soleprint/station/tools/tester/config.py +++ b/soleprint/station/tools/tester/config.py @@ -53,7 +53,7 @@ def load_environments() -> list: { "id": "demo", "name": "Demo", - "url": config.get("CONTRACT_TEST_URL", "https://demo.amarmascotas.ar"), + "url": config.get("CONTRACT_TEST_URL", "http://localhost:8000"), "api_key": config.get("CONTRACT_TEST_API_KEY", ""), "description": "Demo environment", "default": True diff --git a/soleprint/station/tools/tester/endpoints.py b/soleprint/station/tools/tester/endpoints.py deleted file mode 100644 index 35a4dd5..0000000 --- a/soleprint/station/tools/tester/endpoints.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -API Endpoints - Single source of truth for contract tests. - -If API paths or versioning changes, update here only. -""" - - -class Endpoints: - """API endpoint paths""" - - # ========================================================================== - # Mascotas - # ========================================================================== - PET_OWNERS = "/mascotas/api/v1/pet-owners/" - PET_OWNER_DETAIL = "/mascotas/api/v1/pet-owners/{id}/" - PETS = "/mascotas/api/v1/pets/" - PET_DETAIL = "/mascotas/api/v1/pets/{id}/" - COVERAGE_CHECK = "/mascotas/api/v1/coverage/check/" - - # ========================================================================== - # Productos - # ========================================================================== - SERVICES = "/productos/api/v1/services/" - CART = "/productos/api/v1/cart/" - CART_DETAIL = "/productos/api/v1/cart/{id}/" - - # ========================================================================== - # Solicitudes - # ========================================================================== - SERVICE_REQUESTS = "/solicitudes/service-requests/" - SERVICE_REQUEST_DETAIL = "/solicitudes/service-requests/{id}/" - - # ========================================================================== - # Auth - # ========================================================================== - TOKEN = "/api/token/" - TOKEN_REFRESH = "/api/token/refresh/" diff --git a/soleprint/station/tools/tester/environments.json b/soleprint/station/tools/tester/environments.json index 075ebeb..f63ebcc 100644 --- a/soleprint/station/tools/tester/environments.json +++ b/soleprint/station/tools/tester/environments.json @@ -1,31 +1,10 @@ [ { - "id": "demo", - "name": "Demo", - "url": "https://demo.amarmascotas.ar", + "id": "local", + "name": "Local", + "url": "http://localhost:8000", "api_key": "", - "description": "Demo environment for testing", + "description": "Local development server", "default": true - }, - { - "id": "dev", - "name": "Development", - "url": "https://dev.amarmascotas.ar", - "api_key": "", - "description": "Development environment" - }, - { - "id": "stage", - "name": "Staging", - "url": "https://stage.amarmascotas.ar", - "api_key": "", - "description": "Staging environment" - }, - { - "id": "prod", - "name": "Production", - "url": "https://amarmascotas.ar", - "api_key": "", - "description": "Production environment (use with caution!)" } ] diff --git a/soleprint/station/tools/tester/helpers.py b/soleprint/station/tools/tester/helpers.py index 4fa5b0b..3393908 100644 --- a/soleprint/station/tools/tester/helpers.py +++ b/soleprint/station/tools/tester/helpers.py @@ -1,44 +1,17 @@ """ -Contract Tests - Shared test data helpers. +Contract Tests - Generic test data helpers. -Used across all endpoint tests to generate consistent test data. +Room-specific helpers belong in cfg//station/tools/tester/tests/helpers.py """ import time def unique_email(prefix="test"): - """Generate unique email for test data""" + """Generate unique email for test data (avoids collisions across runs)""" return f"{prefix}_{int(time.time() * 1000)}@contract-test.local" -def sample_pet_owner(email=None): - """Generate sample pet owner data""" - return { - "first_name": "Test", - "last_name": "Usuario", - "email": email or unique_email("owner"), - "phone": "1155667788", - "address": "Av. Santa Fe 1234", - "geo_latitude": -34.5955, - "geo_longitude": -58.4166, - } - - -SAMPLE_CAT = { - "name": "TestCat", - "pet_type": "CAT", - "is_neutered": False, -} - -SAMPLE_DOG = { - "name": "TestDog", - "pet_type": "DOG", - "is_neutered": False, -} - -SAMPLE_NEUTERED_CAT = { - "name": "NeuteredCat", - "pet_type": "CAT", - "is_neutered": True, -} +def unique_id(prefix="test"): + """Generate unique string identifier""" + return f"{prefix}_{int(time.time() * 1000)}" diff --git a/soleprint/station/tools/tester/tests/base.py b/soleprint/station/tools/tester/tests/base.py index 3120c06..1756c18 100644 --- a/soleprint/station/tools/tester/tests/base.py +++ b/soleprint/station/tools/tester/tests/base.py @@ -1,164 +1,4 @@ -""" -Pure HTTP Contract Tests - Base Class +"""Re-export from parent for backward compatibility.""" +from ..base import ContractTestCase, get_base_url -Framework-agnostic: works against ANY backend implementation. -Does NOT manage database - expects a ready environment. - -Requirements: - - Server running at CONTRACT_TEST_URL - - Database migrated and seeded - - Test user exists OR CONTRACT_TEST_TOKEN provided - -Usage: - CONTRACT_TEST_URL=http://127.0.0.1:8000 pytest - CONTRACT_TEST_TOKEN=your_jwt_token pytest -""" - -import os -import unittest -import httpx - -from .endpoints import Endpoints - - -def get_base_url(): - """Get base URL from environment (required)""" - url = os.environ.get("CONTRACT_TEST_URL", "") - if not url: - raise ValueError("CONTRACT_TEST_URL environment variable required") - return url.rstrip("/") - - -class ContractTestCase(unittest.TestCase): - """ - Base class for pure HTTP contract tests. - - Features: - - Framework-agnostic (works with Django, FastAPI, Node, etc.) - - Pure HTTP via requests library - - No database access - all data through API - - JWT authentication - """ - - # Auth credentials - override via environment - TEST_USER_EMAIL = os.environ.get("CONTRACT_TEST_USER", "contract_test@example.com") - TEST_USER_PASSWORD = os.environ.get("CONTRACT_TEST_PASSWORD", "testpass123") - - # Class-level cache - _base_url = None - _token = None - - @classmethod - def setUpClass(cls): - """Set up once per test class""" - super().setUpClass() - cls._base_url = get_base_url() - - # Use provided token or fetch one - cls._token = os.environ.get("CONTRACT_TEST_TOKEN", "") - if not cls._token: - cls._token = cls._fetch_token() - - @classmethod - def _fetch_token(cls): - """Get JWT token for authentication""" - url = f"{cls._base_url}{Endpoints.TOKEN}" - try: - response = httpx.post(url, json={ - "username": cls.TEST_USER_EMAIL, - "password": cls.TEST_USER_PASSWORD, - }, timeout=10) - if response.status_code == 200: - return response.json().get("access", "") - else: - print(f"Warning: Token request failed with {response.status_code}") - except httpx.RequestError as e: - print(f"Warning: Token request failed: {e}") - return "" - - @property - def base_url(self): - return self._base_url - - @property - def token(self): - return self._token - - def _auth_headers(self): - """Get authorization headers""" - if self.token: - return {"Authorization": f"Bearer {self.token}"} - return {} - - # ========================================================================= - # HTTP helpers - # ========================================================================= - - def get(self, path: str, params: dict = None, **kwargs): - """GET request""" - url = f"{self.base_url}{path}" - headers = {**self._auth_headers(), **kwargs.pop("headers", {})} - response = httpx.get(url, params=params, headers=headers, timeout=30, **kwargs) - return self._wrap_response(response) - - def post(self, path: str, data: dict = None, **kwargs): - """POST request with JSON""" - url = f"{self.base_url}{path}" - headers = {**self._auth_headers(), **kwargs.pop("headers", {})} - response = httpx.post(url, json=data, headers=headers, timeout=30, **kwargs) - return self._wrap_response(response) - - def put(self, path: str, data: dict = None, **kwargs): - """PUT request with JSON""" - url = f"{self.base_url}{path}" - headers = {**self._auth_headers(), **kwargs.pop("headers", {})} - response = httpx.put(url, json=data, headers=headers, timeout=30, **kwargs) - return self._wrap_response(response) - - def patch(self, path: str, data: dict = None, **kwargs): - """PATCH request with JSON""" - url = f"{self.base_url}{path}" - headers = {**self._auth_headers(), **kwargs.pop("headers", {})} - response = httpx.patch(url, json=data, headers=headers, timeout=30, **kwargs) - return self._wrap_response(response) - - def delete(self, path: str, **kwargs): - """DELETE request""" - url = f"{self.base_url}{path}" - headers = {**self._auth_headers(), **kwargs.pop("headers", {})} - response = httpx.delete(url, headers=headers, timeout=30, **kwargs) - return self._wrap_response(response) - - def _wrap_response(self, response): - """Add .data attribute for consistency with DRF responses""" - try: - response.data = response.json() - except Exception: - response.data = None - return response - - # ========================================================================= - # Assertion helpers - # ========================================================================= - - def assert_status(self, response, expected_status: int): - """Assert response has expected status code""" - self.assertEqual( - response.status_code, - expected_status, - f"Expected {expected_status}, got {response.status_code}. " - f"Response: {response.data if hasattr(response, 'data') else response.content[:500]}" - ) - - def assert_has_fields(self, data: dict, *fields: str): - """Assert dictionary has all specified fields""" - missing = [f for f in fields if f not in data] - self.assertEqual(missing, [], f"Missing fields: {missing}. Got: {list(data.keys())}") - - def assert_is_list(self, data, min_length: int = 0): - """Assert data is a list with minimum length""" - self.assertIsInstance(data, list) - self.assertGreaterEqual(len(data), min_length) - - -__all__ = ["ContractTestCase"] +__all__ = ["ContractTestCase", "get_base_url"] diff --git a/soleprint/station/tools/tester/tests/conftest.py b/soleprint/station/tools/tester/tests/conftest.py deleted file mode 100644 index cfbc6dd..0000000 --- a/soleprint/station/tools/tester/tests/conftest.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Contract Tests Configuration - -Supports two testing modes via CONTRACT_TEST_MODE environment variable: - - # Fast mode (default) - Django test client, test DB - pytest tests/contracts/ - - # Live mode - Real HTTP with LiveServerTestCase, test DB - CONTRACT_TEST_MODE=live pytest tests/contracts/ -""" - -import os -import pytest - -# Let pytest-django handle Django setup via pytest.ini DJANGO_SETTINGS_MODULE - - -def pytest_configure(config): - """Register custom markers""" - config.addinivalue_line( - "markers", "workflow: marks test as a workflow/flow test (runs endpoint tests in sequence)" - ) - - -@pytest.fixture(scope="session") -def contract_test_mode(): - """Return current test mode""" - return os.environ.get("CONTRACT_TEST_MODE", "api") diff --git a/soleprint/station/tools/tester/tests/endpoints.py b/soleprint/station/tools/tester/tests/endpoints.py deleted file mode 100644 index 7af2031..0000000 --- a/soleprint/station/tools/tester/tests/endpoints.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -API Endpoints - Single source of truth for contract tests. - -If API paths or versioning changes, update here only. -""" - - -class Endpoints: - """API endpoint paths""" - - # ========================================================================== - # Mascotas - # ========================================================================== - PET_OWNERS = "/mascotas/api/v1/pet-owners/" - PET_OWNER_DETAIL = "/mascotas/api/v1/pet-owners/{id}/" - PETS = "/mascotas/api/v1/pets/" - PET_DETAIL = "/mascotas/api/v1/pets/{id}/" - COVERAGE_CHECK = "/mascotas/api/v1/coverage/check/" - - # ========================================================================== - # Productos - # ========================================================================== - SERVICES = "/productos/api/v1/services/" - CATEGORIES = "/productos/api/v1/categories/" - CART = "/productos/api/v1/cart/" - CART_DETAIL = "/productos/api/v1/cart/{id}/" - - # ========================================================================== - # Solicitudes - # ========================================================================== - SERVICE_REQUESTS = "/solicitudes/service-requests/" - SERVICE_REQUEST_DETAIL = "/solicitudes/service-requests/{id}/" - - # ========================================================================== - # Auth - # ========================================================================== - TOKEN = "/api/token/" - TOKEN_REFRESH = "/api/token/refresh/" diff --git a/soleprint/station/tools/tester/tests/helpers.py b/soleprint/station/tools/tester/tests/helpers.py deleted file mode 100644 index 4fa5b0b..0000000 --- a/soleprint/station/tools/tester/tests/helpers.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Contract Tests - Shared test data helpers. - -Used across all endpoint tests to generate consistent test data. -""" - -import time - - -def unique_email(prefix="test"): - """Generate unique email for test data""" - return f"{prefix}_{int(time.time() * 1000)}@contract-test.local" - - -def sample_pet_owner(email=None): - """Generate sample pet owner data""" - return { - "first_name": "Test", - "last_name": "Usuario", - "email": email or unique_email("owner"), - "phone": "1155667788", - "address": "Av. Santa Fe 1234", - "geo_latitude": -34.5955, - "geo_longitude": -58.4166, - } - - -SAMPLE_CAT = { - "name": "TestCat", - "pet_type": "CAT", - "is_neutered": False, -} - -SAMPLE_DOG = { - "name": "TestDog", - "pet_type": "DOG", - "is_neutered": False, -} - -SAMPLE_NEUTERED_CAT = { - "name": "NeuteredCat", - "pet_type": "CAT", - "is_neutered": True, -}