diff --git a/.svelte-kit/ambient.d.ts b/.svelte-kit/ambient.d.ts new file mode 100644 index 0000000..d5eec2a --- /dev/null +++ b/.svelte-kit/ambient.d.ts @@ -0,0 +1,454 @@ + +// this file is generated — do not edit it + + +/// + +/** + * This module provides access to environment variables that are injected _statically_ into your bundle at build time and are limited to _private_ access. + * + * | | Runtime | Build time | + * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | + * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) | + * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) | + * + * Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination. + * + * **_Private_ access:** + * + * - This module cannot be imported into client-side code + * - This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured) + * + * For example, given the following build time environment: + * + * ```env + * ENVIRONMENT=production + * PUBLIC_BASE_URL=http://site.com + * ``` + * + * With the default `publicPrefix` and `privatePrefix`: + * + * ```ts + * import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/private'; + * + * console.log(ENVIRONMENT); // => "production" + * console.log(PUBLIC_BASE_URL); // => throws error during build + * ``` + * + * The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values. + */ +declare module '$env/static/private' { + export const LESSOPEN: string; + export const TMUX: string; + export const HERMES_DELEGATED_CHILD_CONTEXT: string; + export const timezone: string; + export const CUDACXX: string; + export const BROWSERBASE_PROXIES: string; + export const HERMES_RESTART_DRAIN_TIMEOUT: string; + export const _HERMES_GATEWAY: string; + export const AI_AGENT: string; + export const USER: string; + export const SSH_CLIENT: string; + export const npm_config_user_agent: string; + export const TERMINAL_CONTAINER_CPU: string; + export const AGENT_BROWSER_EXECUTABLE_PATH: string; + export const TERMINAL_DOCKER_ENV: string; + export const TERMINAL_CWD: string; + export const HERMES_REAL_HOME: string; + export const XDG_SESSION_TYPE: string; + export const npm_node_execpath: string; + export const SHLVL: string; + export const HERMES_PROFILE: string; + export const npm_config_noproxy: string; + export const HOME: string; + export const OLDPWD: string; + export const TERMINAL_DOCKER_FORWARD_ENV: string; + export const TERM_PROGRAM_VERSION: string; + export const BROWSER_SESSION_TIMEOUT: string; + export const HERMES_KANBAN_BOARD: string; + export const SSH_TTY: string; + export const HERMES_HOME: string; + export const npm_package_json: string; + export const TERMINAL_DOCKER_EXTRA_ARGS: string; + export const HF_HUB_DISABLE_XET: string; + export const IM_CONFIG_ENTRY: string; + export const TERMINAL_DOCKER_IMAGE: string; + export const PAGER: string; + export const TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE: string; + export const TERMINAL_CONTAINER_MEMORY: string; + export const SSL_CERT_FILE: string; + export const TERMINAL_CONTAINER_PERSISTENT: string; + export const npm_config_userconfig: string; + export const npm_config_local_prefix: string; + export const HERMES_TURN_LEASE_TIMEOUT: string; + export const HERMES_MEDIA_DELIVERY_STRICT: string; + export const TERMINAL_MODAL_IMAGE: string; + export const DBUS_SESSION_BUS_ADDRESS: string; + export const COLORTERM: string; + export const TERMINAL_DOCKER_NETWORK: string; + export const file_read_max_chars: string; + export const COLOR: string; + export const DEBUGINFOD_URLS: string; + export const TERMINAL_DOCKER_VOLUMES: string; + export const TERMINAL_ENV: string; + export const TERMINAL_VERCEL_RUNTIME: string; + export const LOGNAME: string; + export const HERMES_KANBAN_DB: string; + export const IMAGE_TOOLS_DEBUG: string; + export const _: string; + export const npm_config_prefix: string; + export const npm_config_npm_version: string; + export const group_sessions_per_user: string; + export const HERMES_KANBAN_WORKSPACES_ROOT: string; + export const HERMES_SINGLE_QUERY_SESSION: string; + export const XDG_SESSION_CLASS: string; + export const BROWSERBASE_ADVANCED_STEALTH: string; + export const paste_collapse_threshold: string; + export const TERM: string; + export const TERMINAL_DAYTONA_IMAGE: string; + export const XDG_SESSION_ID: string; + export const npm_config_cache: string; + export const TERMINAL_TEMP_DIR: string; + export const TERMINAL_LIFETIME_SECONDS: string; + export const TERMINAL_TIMEOUT: string; + export const HERMES_GATEWAY_BUSY_INPUT_MODE: string; + export const paste_collapse_char_threshold: string; + export const TERMINAL_DOCKER_SHARED_CONTAINER_KEY: string; + export const npm_config_node_gyp: string; + export const PATH: string; + export const HERMES_AGENT_TIMEOUT: string; + export const NODE: string; + export const TERMINAL_DOCKER_SHM_SIZE: string; + export const HERMES_KANBAN_WORKSPACE: string; + export const XDG_RUNTIME_DIR: string; + export const TERMINAL_PERSISTENT_SHELL: string; + export const LANG: string; + export const HERMES_QUIET: string; + export const MOA_TOOLS_DEBUG: string; + export const HERMES_MEDIA_TRUST_RECENT_FILES: string; + export const LS_COLORS: string; + export const HERMES_MEDIA_TRUST_RECENT_SECONDS: string; + export const HERMES_MAX_ITERATIONS: string; + export const TERM_PROGRAM: string; + export const npm_lifecycle_script: string; + export const HERMES_SESSION_ID: string; + export const HERMES_AGENT_TIMEOUT_WARNING: string; + export const prefill_messages_file: string; + export const SHELL: string; + export const HERMES_AGENT_NOTIFY_INTERVAL: string; + export const TERMINAL_DEGRADED_MODE: string; + export const TERMINAL_MODAL_MODE: string; + export const npm_lifecycle_event: string; + export const WEB_TOOLS_DEBUG: string; + export const VISION_TOOLS_DEBUG: string; + export const LESSCLOSE: string; + export const TERMINAL_CONTAINER_DISK: string; + export const TERMINAL_DOCKER_RUN_AS_HOST_USER: string; + export const GIT_PAGER: string; + export const paste_collapse_threshold_fallback: string; + export const npm_config_globalconfig: string; + export const npm_config_init_module: string; + export const PWD: string; + export const HERMES_INTERACTIVE: string; + export const _config_version: string; + export const HERMES_AUTO_CONTINUE_FRESHNESS: string; + export const npm_execpath: string; + export const SSH_CONNECTION: string; + export const XDG_DATA_DIRS: string; + export const npm_config_global_prefix: string; + export const hooks_auto_accept: string; + export const npm_command: string; + export const TMUX_PLUGIN_MANAGER_PATH: string; + export const BROWSER_INACTIVITY_TIMEOUT: string; + export const HERMES_REDACT_SECRETS: string; + export const HERMES_CUSTOM_192_168_1_203_8084_API_KEY: string; + export const TERMINAL_SINGULARITY_IMAGE: string; + export const TMUX_PANE: string; + export const TERMINAL_HOME_MODE: string; + export const TERMINAL_DOCKER_SNAP_COMPAT: string; + export const HERMES_AGENT: string; + export const HERMES_SESSION_SOURCE: string; + export const INIT_CWD: string; + export const EDITOR: string; + export const TEST: string; + export const VITEST: string; + export const NODE_ENV: string; +} + +/** + * This module provides access to environment variables that are injected _statically_ into your bundle at build time and are _publicly_ accessible. + * + * | | Runtime | Build time | + * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | + * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) | + * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) | + * + * Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination. + * + * **_Public_ access:** + * + * - This module _can_ be imported into client-side code + * - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included + * + * For example, given the following build time environment: + * + * ```env + * ENVIRONMENT=production + * PUBLIC_BASE_URL=http://site.com + * ``` + * + * With the default `publicPrefix` and `privatePrefix`: + * + * ```ts + * import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/public'; + * + * console.log(ENVIRONMENT); // => throws error during build + * console.log(PUBLIC_BASE_URL); // => "http://site.com" + * ``` + * + * The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values. + */ +declare module '$env/static/public' { + +} + +/** + * This module provides access to environment variables set _dynamically_ at runtime and that are limited to _private_ access. + * + * | | Runtime | Build time | + * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | + * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) | + * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) | + * + * Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`. + * + * **_Private_ access:** + * + * - This module cannot be imported into client-side code + * - This module includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured) + * + * > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter. + * + * > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed: + * > + * > ```env + * > MY_FEATURE_FLAG= + * > ``` + * > + * > You can override `.env` values from the command line like so: + * > + * > ```sh + * > MY_FEATURE_FLAG="enabled" npm run dev + * > ``` + * + * For example, given the following runtime environment: + * + * ```env + * ENVIRONMENT=production + * PUBLIC_BASE_URL=http://site.com + * ``` + * + * With the default `publicPrefix` and `privatePrefix`: + * + * ```ts + * import { env } from '$env/dynamic/private'; + * + * console.log(env.ENVIRONMENT); // => "production" + * console.log(env.PUBLIC_BASE_URL); // => undefined + * ``` + */ +declare module '$env/dynamic/private' { + export const env: { + LESSOPEN: string; + TMUX: string; + HERMES_DELEGATED_CHILD_CONTEXT: string; + timezone: string; + CUDACXX: string; + BROWSERBASE_PROXIES: string; + HERMES_RESTART_DRAIN_TIMEOUT: string; + _HERMES_GATEWAY: string; + AI_AGENT: string; + USER: string; + SSH_CLIENT: string; + npm_config_user_agent: string; + TERMINAL_CONTAINER_CPU: string; + AGENT_BROWSER_EXECUTABLE_PATH: string; + TERMINAL_DOCKER_ENV: string; + TERMINAL_CWD: string; + HERMES_REAL_HOME: string; + XDG_SESSION_TYPE: string; + npm_node_execpath: string; + SHLVL: string; + HERMES_PROFILE: string; + npm_config_noproxy: string; + HOME: string; + OLDPWD: string; + TERMINAL_DOCKER_FORWARD_ENV: string; + TERM_PROGRAM_VERSION: string; + BROWSER_SESSION_TIMEOUT: string; + HERMES_KANBAN_BOARD: string; + SSH_TTY: string; + HERMES_HOME: string; + npm_package_json: string; + TERMINAL_DOCKER_EXTRA_ARGS: string; + HF_HUB_DISABLE_XET: string; + IM_CONFIG_ENTRY: string; + TERMINAL_DOCKER_IMAGE: string; + PAGER: string; + TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE: string; + TERMINAL_CONTAINER_MEMORY: string; + SSL_CERT_FILE: string; + TERMINAL_CONTAINER_PERSISTENT: string; + npm_config_userconfig: string; + npm_config_local_prefix: string; + HERMES_TURN_LEASE_TIMEOUT: string; + HERMES_MEDIA_DELIVERY_STRICT: string; + TERMINAL_MODAL_IMAGE: string; + DBUS_SESSION_BUS_ADDRESS: string; + COLORTERM: string; + TERMINAL_DOCKER_NETWORK: string; + file_read_max_chars: string; + COLOR: string; + DEBUGINFOD_URLS: string; + TERMINAL_DOCKER_VOLUMES: string; + TERMINAL_ENV: string; + TERMINAL_VERCEL_RUNTIME: string; + LOGNAME: string; + HERMES_KANBAN_DB: string; + IMAGE_TOOLS_DEBUG: string; + _: string; + npm_config_prefix: string; + npm_config_npm_version: string; + group_sessions_per_user: string; + HERMES_KANBAN_WORKSPACES_ROOT: string; + HERMES_SINGLE_QUERY_SESSION: string; + XDG_SESSION_CLASS: string; + BROWSERBASE_ADVANCED_STEALTH: string; + paste_collapse_threshold: string; + TERM: string; + TERMINAL_DAYTONA_IMAGE: string; + XDG_SESSION_ID: string; + npm_config_cache: string; + TERMINAL_TEMP_DIR: string; + TERMINAL_LIFETIME_SECONDS: string; + TERMINAL_TIMEOUT: string; + HERMES_GATEWAY_BUSY_INPUT_MODE: string; + paste_collapse_char_threshold: string; + TERMINAL_DOCKER_SHARED_CONTAINER_KEY: string; + npm_config_node_gyp: string; + PATH: string; + HERMES_AGENT_TIMEOUT: string; + NODE: string; + TERMINAL_DOCKER_SHM_SIZE: string; + HERMES_KANBAN_WORKSPACE: string; + XDG_RUNTIME_DIR: string; + TERMINAL_PERSISTENT_SHELL: string; + LANG: string; + HERMES_QUIET: string; + MOA_TOOLS_DEBUG: string; + HERMES_MEDIA_TRUST_RECENT_FILES: string; + LS_COLORS: string; + HERMES_MEDIA_TRUST_RECENT_SECONDS: string; + HERMES_MAX_ITERATIONS: string; + TERM_PROGRAM: string; + npm_lifecycle_script: string; + HERMES_SESSION_ID: string; + HERMES_AGENT_TIMEOUT_WARNING: string; + prefill_messages_file: string; + SHELL: string; + HERMES_AGENT_NOTIFY_INTERVAL: string; + TERMINAL_DEGRADED_MODE: string; + TERMINAL_MODAL_MODE: string; + npm_lifecycle_event: string; + WEB_TOOLS_DEBUG: string; + VISION_TOOLS_DEBUG: string; + LESSCLOSE: string; + TERMINAL_CONTAINER_DISK: string; + TERMINAL_DOCKER_RUN_AS_HOST_USER: string; + GIT_PAGER: string; + paste_collapse_threshold_fallback: string; + npm_config_globalconfig: string; + npm_config_init_module: string; + PWD: string; + HERMES_INTERACTIVE: string; + _config_version: string; + HERMES_AUTO_CONTINUE_FRESHNESS: string; + npm_execpath: string; + SSH_CONNECTION: string; + XDG_DATA_DIRS: string; + npm_config_global_prefix: string; + hooks_auto_accept: string; + npm_command: string; + TMUX_PLUGIN_MANAGER_PATH: string; + BROWSER_INACTIVITY_TIMEOUT: string; + HERMES_REDACT_SECRETS: string; + HERMES_CUSTOM_192_168_1_203_8084_API_KEY: string; + TERMINAL_SINGULARITY_IMAGE: string; + TMUX_PANE: string; + TERMINAL_HOME_MODE: string; + TERMINAL_DOCKER_SNAP_COMPAT: string; + HERMES_AGENT: string; + HERMES_SESSION_SOURCE: string; + INIT_CWD: string; + EDITOR: string; + TEST: string; + VITEST: string; + NODE_ENV: string; + [key: `PUBLIC_${string}`]: undefined; + [key: `${string}`]: string | undefined; + } +} + +/** + * This module provides access to environment variables set _dynamically_ at runtime and that are _publicly_ accessible. + * + * | | Runtime | Build time | + * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | + * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) | + * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) | + * + * Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`. + * + * **_Public_ access:** + * + * - This module _can_ be imported into client-side code + * - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included + * + * > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter. + * + * > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed: + * > + * > ```env + * > MY_FEATURE_FLAG= + * > ``` + * > + * > You can override `.env` values from the command line like so: + * > + * > ```sh + * > MY_FEATURE_FLAG="enabled" npm run dev + * > ``` + * + * For example, given the following runtime environment: + * + * ```env + * ENVIRONMENT=production + * PUBLIC_BASE_URL=http://example.com + * ``` + * + * With the default `publicPrefix` and `privatePrefix`: + * + * ```ts + * import { env } from '$env/dynamic/public'; + * console.log(env.ENVIRONMENT); // => undefined, not public + * console.log(env.PUBLIC_BASE_URL); // => "http://example.com" + * ``` + * + * ``` + * + * ``` + */ +declare module '$env/dynamic/public' { + export const env: { + [key: `PUBLIC_${string}`]: string | undefined; + } +} diff --git a/.svelte-kit/env.d.ts b/.svelte-kit/env.d.ts new file mode 100644 index 0000000..d7f6c97 --- /dev/null +++ b/.svelte-kit/env.d.ts @@ -0,0 +1 @@ +// See https://svelte.dev/docs/kit/environment-variables for more information \ No newline at end of file diff --git a/.svelte-kit/generated/client/app.js b/.svelte-kit/generated/client/app.js new file mode 100644 index 0000000..ceb58ac --- /dev/null +++ b/.svelte-kit/generated/client/app.js @@ -0,0 +1,38 @@ +// in dev, this makes Vite inject its client as this module's first dependency, +// so that global constant replacements are installed before any other module +// (including user hooks) evaluates. In build it's inert. +import.meta.hot; + + + + +export { matchers } from './matchers.js'; + +export const nodes = [ + () => import('./nodes/0'), + () => import('./nodes/1') +]; + +export const server_loads = []; + +export const dictionary = { + + }; + +export const hooks = { + handleError: (({ error }) => { console.error(error) }), + + reroute: (() => {}), + transport: {} +}; + +export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode])); +export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode])); + +export const hash = false; + +export const decode = (type, value) => decoders[type](value); + +export { default as root } from '../root.js'; + +export const get_error_template = () => import('../shared/error-template.js').then(m => m.default); \ No newline at end of file diff --git a/.svelte-kit/generated/client/matchers.js b/.svelte-kit/generated/client/matchers.js new file mode 100644 index 0000000..f6bd30a --- /dev/null +++ b/.svelte-kit/generated/client/matchers.js @@ -0,0 +1 @@ +export const matchers = {}; \ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/0.js b/.svelte-kit/generated/client/nodes/0.js new file mode 100644 index 0000000..790fa02 --- /dev/null +++ b/.svelte-kit/generated/client/nodes/0.js @@ -0,0 +1 @@ +export { default as component } from "../../../../frontend/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/layout.svelte"; \ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/1.js b/.svelte-kit/generated/client/nodes/1.js new file mode 100644 index 0000000..8a020ca --- /dev/null +++ b/.svelte-kit/generated/client/nodes/1.js @@ -0,0 +1 @@ +export { default as component } from "../../../../frontend/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/error.svelte"; \ No newline at end of file diff --git a/.svelte-kit/generated/shared/error-template.js b/.svelte-kit/generated/shared/error-template.js new file mode 100644 index 0000000..f66e595 --- /dev/null +++ b/.svelte-kit/generated/shared/error-template.js @@ -0,0 +1 @@ +export default ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n"; \ No newline at end of file diff --git a/.svelte-kit/tsconfig.json b/.svelte-kit/tsconfig.json new file mode 100644 index 0000000..f727b03 --- /dev/null +++ b/.svelte-kit/tsconfig.json @@ -0,0 +1,50 @@ +{ + "compilerOptions": { + "paths": { + "$app/types": [ + "./types/index.d.ts" + ] + }, + "rootDirs": [ + "..", + "./types" + ], + "verbatimModuleSyntax": true, + "isolatedModules": true, + "lib": [ + "esnext", + "DOM", + "DOM.Iterable" + ], + "moduleResolution": "bundler", + "module": "esnext", + "noEmit": true, + "target": "esnext" + }, + "include": [ + "ambient.d.ts", + "env.d.ts", + "non-ambient.d.ts", + "./types/**/$types.d.ts", + "../vite.config.js", + "../vite.config.ts", + "../src/**/*.js", + "../src/**/*.ts", + "../src/**/*.svelte", + "../test/**/*.js", + "../test/**/*.ts", + "../test/**/*.svelte", + "../tests/**/*.js", + "../tests/**/*.ts", + "../tests/**/*.svelte" + ], + "exclude": [ + "../node_modules/**", + "../src/service-worker.js", + "../src/service-worker/**/*.js", + "../src/service-worker.ts", + "../src/service-worker/**/*.ts", + "../src/service-worker.d.ts", + "../src/service-worker/**/*.d.ts" + ] +} \ No newline at end of file diff --git a/WORKER-CONTEXT.md b/WORKER-CONTEXT.md new file mode 100644 index 0000000..f07c094 --- /dev/null +++ b/WORKER-CONTEXT.md @@ -0,0 +1,36 @@ +# SlabUploader M1 — shared build context (for :202 worker tasks) + +REPO: /home/ty/SlabUploader — work ONLY inside /home/ty/SlabUploader/frontend. +Branch: M1-dev-rev4 (already checked out; DO NOT switch branches, DO NOT push). +This is Milestone 1 (Capture) of a slab-measuring web app. Browser-only, NO backend, +NO inference, NO OpenCV/ONNX. Pure JS (not TS). Svelte 5 + SvelteKit + adapter-node. + +TEST COMMANDS (run these, show real output): + unit: cd /home/ty/SlabUploader/frontend && npx vitest run + e2e: npx playwright test --list (collection only; full e2e needs the M1-6 container) + +EXISTING (already committed, green, DO NOT break): + src/lib/core/mask.js -> keySheet(data,{sheet,sensitivity}), maskStats(mask) + src/lib/core/math.js -> scale, sqft, bdft, roundHalfUp, sampledWidths, + sampledWidthsFrom, snapToEighth + tests/unit/mask.test.js, tests/unit/math.test.js (24 tests, all pass) + src/routes/+page.svelte (placeholder home — M1-3 replaces it) + src/routes/+layout.svelte, src/routes/api/health/+server.js + +TDD IS MANDATORY (test-driven-development skill): + 1. Write the failing test(s) FIRST. + 2. Run them and WATCH them fail for the right reason (feature missing, not a typo). + 3. Write the MINIMAL code to pass. + 4. Run them and confirm they pass. + 5. Run the FULL unit suite (npx vitest run) and confirm no regressions + pristine output. + 6. Only then are you done. Commit is done by the coordinator — you do NOT commit. + +CONVENTIONS: + - Tab indentation (the repo uses tabs). + - Pure logic lives in src/lib/core/*.js (DOM-free, unit-testable under node). + - Svelte components live under src/lib/ (e.g. src/lib/components/). + - Every new exported function needs a unit test that you SAW fail first. + - No console.log left in production code. + - Do not add dependencies. + - Report at the end: files created/changed, the exact test command output, and + any place you had to make a judgment call. diff --git a/frontend/src/lib/core/axis.js b/frontend/src/lib/core/axis.js new file mode 100644 index 0000000..97f0dc2 --- /dev/null +++ b/frontend/src/lib/core/axis.js @@ -0,0 +1,286 @@ +// M1-4 Length axis — pure functions + state machine, no DOM. +// +// Ground truth: docs/CAPTURE.md §Length axis +// - min-area bounding rectangle of the mask +// - length axis on the long side of that rectangle +// - user rotates the axis if it does not follow the slab length +// - approve the axis +// +// minAreaRect implements the rotating calipers method (Shamos & Ho 1975) +// over the convex hull of the mask pixels. The convex hull is computed +// with the monotone-chain algorithm (O(n log n)). + +/** + * Compute the minimum-area bounding rectangle of a set of 2D points. + * Uses the rotating calipers method on the convex hull. + * + * @param {Array<{x:number, y:number}>} pts + * @returns {{center:{x:number,y:number}, width:number, height:number, angle:number, area:number}} + * angle: rotation of the long side in radians (0..PI/2, CCW from +x). + * width: length along the long side. height: length along the short side. + */ +function minAreaRectPoints(pts) { + const n = pts.length; + if (n === 0) return null; + if (n === 1) + return { + center: { x: pts[0].x, y: pts[0].y }, + width: 0, + height: 0, + angle: 0, + area: 0, + }; + if (n === 2) { + const dx = pts[1].x - pts[0].x; + const dy = pts[1].y - pts[0].y; + return { + center: { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 }, + width: Math.sqrt(dx * dx + dy * dy), + height: 0, + angle: Math.atan2(dy, dx), + area: 0, + }; + } + + // Convex hull (monotone chain). + const hull = convexHull(pts); + if (hull.length < 3) return minAreaRectPoints(hull); + + // Rotating calipers: try each hull edge as the support line. + let best = { area: Infinity, width: 0, height: 0, angle: 0, center: { x: 0, y: 0 } }; + const m = hull.length; + + for (let i = 0; i < m; i++) { + const p1 = hull[i]; + const p2 = hull[(i + 1) % m]; + const ex = p2.x - p1.x; + const ey = p2.y - p1.y; + const len = Math.sqrt(ex * ex + ey * ey); + const ux = ex / len; + const uy = ey / len; + + // Project all hull points onto the edge direction (u) and its + // left normal (v = (-uy, ux)). + let minU = Infinity, + maxU = -Infinity; + let minV = Infinity, + maxV = -Infinity; + for (let j = 0; j < m; j++) { + const q = hull[j]; + const u = q.x * ux + q.y * uy; + const v = -q.x * uy + q.y * ux; + if (u < minU) minU = u; + if (u > maxU) maxU = u; + if (v < minV) minV = v; + if (v > maxV) maxV = v; + } + + const w = maxU - minU; + const h = maxV - minV; + const area = w * h; + + if (area < best.area) { + // Center in (u,v) frame, transformed back to (x,y): + // x = cu * ux - cv * uy + // y = cu * uy + cv * ux + const cu = (minU + maxU) / 2; + const cv = (minV + maxV) / 2; + const centerX = cu * ux - cv * uy; + const centerY = cu * uy + cv * ux; + + // Long side: along u when w >= h, else along the normal v. + let width, height, angle; + if (w >= h) { + width = w; + height = h; + angle = Math.atan2(uy, ux); + } else { + width = h; + height = w; + // Long side along v = (-uy, ux) + angle = Math.atan2(ux, -uy); + } + // Normalize angle to [0, PI) — rectangle has 180-degree symmetry. + if (angle < 0) angle += Math.PI; + if (angle >= Math.PI) angle -= Math.PI; + + best = { + center: { x: centerX, y: centerY }, + width, + height, + angle, + area, + }; + } + } + + return best; +} + +/** + * Monotone-chain convex hull, keeping collinear boundary points. + * Returns an array of {x,y} points in CCW order, no duplicate last point. + * + * Collinear points are kept because the min-area bounding rectangle + * algorithm needs them: a rectangle's side is defined by its two + * endpoints, and dropping intermediate collinear points would lose the + * true extent of the shape. + * + * @param {Array<{x:number,y:number}>} pts + * @returns {Array<{x:number,y:number}>} + */ +function convexHull(pts) { + const n = pts.length; + if (n <= 2) return pts.slice(); + + const sorted = pts.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y)); + + const cross = (O, A, B) => + (A.x - O.x) * (B.y - O.y) - (A.y - O.y) * (B.x - O.x); + + const lower = []; + for (let i = 0; i < n; i++) { + while ( + lower.length >= 2 && + cross(lower[lower.length - 2], lower[lower.length - 1], sorted[i]) < 0 + ) { + lower.pop(); + } + lower.push(sorted[i]); + } + + const upper = []; + for (let i = n - 1; i >= 0; i--) { + while ( + upper.length >= 2 && + cross(upper[upper.length - 2], upper[upper.length - 1], sorted[i]) < 0 + ) { + upper.pop(); + } + upper.push(sorted[i]); + } + + // Concatenate, dropping the last point of each (duplicates). + const hull = lower.slice(0, -1).concat(upper.slice(0, -1)); + return hull; +} + +/** + * Minimum-area bounding rectangle of a binary mask. + * + * @param {Uint8Array} mask binary mask (1 = kept) + * @param {number} width + * @param {number} height + * @returns {null|{center:{x:number,y:number}, width:number, height:number, angle:number, area:number}} + */ +export function minAreaRect(mask, width, height) { + const pts = []; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (mask[y * width + x]) { + pts.push({ x, y }); + } + } + } + if (pts.length === 0) return null; + if (pts.length === 1) + return { + center: { x: pts[0].x, y: pts[0].y }, + width: 1, + height: 1, + angle: 0, + area: 1, + }; + return minAreaRectPoints(pts); +} + +/** + * Unit vector along the long side of a min-area rectangle. + * + * @param {{width:number, height:number, angle:number}} rect + * @returns {{x:number, y:number}} + */ +export function axisFromRect(rect) { + if (rect.width >= rect.height) { + // Long side is along the rect's angle direction. + return { x: Math.cos(rect.angle), y: Math.sin(rect.angle) }; + } else { + // Long side is along the normal (90 degrees CCW from the rect angle). + return { x: -Math.sin(rect.angle), y: Math.cos(rect.angle) }; + } +} + +/** + * Rotate a unit axis by an angle (radians, CCW positive). + * + * @param {{x:number, y:number}} axis + * @param {number} angle radians + * @returns {{x:number, y:number}} + */ +export function rotateAxis(axis, angle) { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + return { x: axis.x * cos - axis.y * sin, y: axis.x * sin + axis.y * cos }; +} + +/** + * Angle of a unit axis in radians (-PI..PI). + * + * @param {{x:number, y:number}} axis + * @returns {number} + */ +export function axisAngle(axis) { + return Math.atan2(axis.y, axis.x); +} + +/** + * Unit vector from an angle in radians. + * + * @param {number} angle radians + * @returns {{x:number, y:number}} + */ +export function axisUnit(angle) { + return { x: Math.cos(angle), y: Math.sin(angle) }; +} + +// --- axis state machine ----------------------------------------------------------- + +/** + * Create a fresh axis state from an approved min-area rectangle. + * + * @param {{center:{x:number,y:number}, width:number, height:number, angle:number, area:number}} rect + */ +export function createAxisState(rect) { + return { + rect, + axis: axisFromRect(rect), + approved: false, + }; +} + +/** + * Pure reducer for the axis state. + * Actions: + * { type: 'rotate', angle } — rotate the axis by `angle` radians (CCW). + * Retracts any prior approval. + * { type: 'approve' } — mark the axis as approved. + * + * @param {object} state + * @param {object} action + * @returns {object} new state + */ +export function reduceAxisState(state, action) { + switch (action.type) { + case 'rotate': { + const angle = action.angle; + if (angle === 0) return state; + const newAxis = rotateAxis(state.axis, angle); + return { ...state, axis: newAxis, approved: false }; + } + case 'approve': { + return { ...state, approved: true }; + } + default: + return state; + } +} \ No newline at end of file diff --git a/frontend/tests/e2e/axis.spec.js b/frontend/tests/e2e/axis.spec.js new file mode 100644 index 0000000..f399e14 --- /dev/null +++ b/frontend/tests/e2e/axis.spec.js @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; + +// M1-4: Playwright tests for the Length axis state. +// RED first — the live UI lands in M1-5. These tests describe the +// contract the UI must honour once the Mask state approves. +// +// Spec ground truth (docs/CAPTURE.md §Length axis): +// - min-area bounding rectangle of the mask +// - length axis on the long side of that rectangle +// - user rotates the axis if it does not follow the slab length +// - approve the axis + +const SLAB_FIXTURE = 'tests/fixtures/slab-green.jpg'; + +test.describe('Length axis state (M1-5 live UI)', () => { + test('advances to Length axis after approving the mask', async ({ page }) => { + await page.goto('/'); + await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE); + // Wait for the initial mask recompute. + await page.waitForTimeout(2200); + // Approve the mask. + await page.getByRole('button', { name: /approve/i }).tap(); + // The Length axis state is now visible. + await expect(page.getByText(/length axis/i)).toBeVisible(); + }); + + test('shows the bounding rectangle and the proposed length axis', async ({ + page, + }) => { + await page.goto('/'); + await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE); + await page.waitForTimeout(2200); + await page.getByRole('button', { name: /approve/i }).tap(); + // The bounding rectangle is drawn on the photo. + await expect( + page.locator('[data-testid="bounding-rect"]'), + ).toBeVisible(); + // The proposed length axis is drawn along the long side. + await expect( + page.locator('[data-testid="length-axis"]'), + ).toBeVisible(); + }); + + test('rotate control adjusts the axis', async ({ page }) => { + await page.goto('/'); + await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE); + await page.waitForTimeout(2200); + await page.getByRole('button', { name: /approve/i }).tap(); + // The rotate control is present. + const rotate = page.getByRole('slider', { name: /rotate axis/i }); + await expect(rotate).toBeVisible(); + // Rotating changes the axis angle. + const before = await page + .locator('[data-testid="length-axis"]') + .getAttribute('data-angle'); + await rotate.fill('90'); + const after = await page + .locator('[data-testid="length-axis"]') + .getAttribute('data-angle'); + expect(before).not.toBe(after); + }); + + test('approve the axis advances to Measure', async ({ page }) => { + await page.goto('/'); + await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE); + await page.waitForTimeout(2200); + // Approve the mask -> Length axis. + await page.getByRole('button', { name: /approve/i }).tap(); + // Approve the axis -> Measure. + const approveAxis = page.getByRole('button', { + name: /approve axis/i, + }); + await expect(approveAxis).toBeVisible(); + await approveAxis.tap(); + await expect(page.getByText(/measure/i)).toBeVisible(); + }); +}); diff --git a/frontend/tests/unit/axis.test.js b/frontend/tests/unit/axis.test.js new file mode 100644 index 0000000..8ac01af --- /dev/null +++ b/frontend/tests/unit/axis.test.js @@ -0,0 +1,358 @@ +import { describe, it, expect } from 'vitest'; +import { + minAreaRect, + axisFromRect, + rotateAxis, + axisAngle, + axisUnit, + createAxisState, + reduceAxisState, +} from '../../src/lib/core/axis.js'; + +// M1-4 Length axis — unit tests (RED first, per strict TDD). +// +// Ground truth: docs/CAPTURE.md §Length axis +// - min-area bounding rectangle of the mask +// - length axis on the long side of that rectangle +// - user rotates the axis if it does not follow the slab length +// - approve the axis +// +// All functions are pure (no DOM) so the state is unit-testable in Node. + +// --- helpers -------------------------------------------------------------------- + +/** + * Build a binary mask (Uint8Array) of w x h with a filled axis-aligned + * rectangle of size rw x rh at (rx, ry). + */ +function rectMask(w, h, rx, ry, rw, rh) { + const mask = new Uint8Array(w * h); + for (let y = ry; y < ry + rh; y++) { + for (let x = rx; x < rx + rw; x++) { + mask[y * w + x] = 1; + } + } + return mask; +} + +/** + * Build a binary mask of a rotated rectangle. + * Center at (cx, cy), half-lengths (hl, hw), rotation in radians. + */ +function rotatedRectMask(w, h, cx, cy, hl, hw, rot) { + const mask = new Uint8Array(w * h); + const cos = Math.cos(rot); + const sin = Math.sin(rot); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const dx = x - cx; + const dy = y - cy; + // Transform to rect-local coords + const lx = cos * dx + sin * dy; + const ly = -sin * dx + cos * dy; + if (Math.abs(lx) <= hl && Math.abs(ly) <= hw) { + mask[y * w + x] = 1; + } + } + } + return mask; +} + +// --- minAreaRect ------------------------------------------------------------------ + +describe('minAreaRect', () => { + it('returns a tight axis-aligned rectangle for an axis-aligned mask', () => { + // 8x4 rectangle at (2,2) on a 12x8 canvas. + // Pixels at x=2..9 (8 cols), y=2..5 (4 rows). + // Center-to-center span: width 7, height 3. Center (5.5, 3.5). + const mask = rectMask(12, 8, 2, 2, 8, 4); + const rect = minAreaRect(mask, 12, 8); + // The min-area rect of an axis-aligned pixel block is tight: + // width = (cols-1), height = (rows-1) in center-to-center units. + expect(rect.width).toBeCloseTo(7, 2); + expect(rect.height).toBeCloseTo(3, 2); + expect(rect.angle).toBeCloseTo(0, 3); + expect(rect.center.x).toBeCloseTo(5.5, 2); // (2+9)/2 + expect(rect.center.y).toBeCloseTo(3.5, 2); // (2+5)/2 + }); + + it('handles a single pixel', () => { + const mask = new Uint8Array(10 * 10); + mask[5 * 10 + 5] = 1; // pixel at (5,5) + const rect = minAreaRect(mask, 10, 10); + expect(rect.area).toBe(1); + }); + + it('returns null for an empty mask', () => { + const mask = new Uint8Array(10 * 10); + const rect = minAreaRect(mask, 10, 10); + expect(rect).toBeNull(); + }); + + it('detects a rotated rectangle', () => { + // 45-degree rotated rectangle: 20 units long, 4 units wide, centered + const w = 40, + h = 40, + cx = 20, + cy = 20, + hl = 10, + hw = 2; + const mask = rotatedRectMask(w, h, cx, cy, hl, hw, Math.PI / 4); + const rect = minAreaRect(mask, w, h); + // Pixel rasterization of a 20x4 rect at 45 degrees: the convex hull + // of the kept pixels approximates the true rect within ~1px per side. + // The angle should be close to 45 degrees. + expect(rect.angle).toBeCloseTo(Math.PI / 4, 1); + // Long side ~20 (within 2px for rasterization) + const longSide = Math.max(rect.width, rect.height); + expect(longSide).toBeGreaterThanOrEqual(18); + expect(longSide).toBeLessThanOrEqual(22); + }); + + it('handles a horizontal bar (wider than tall)', () => { + // 16x2 bar on a 20x10 canvas + const mask = rectMask(20, 10, 2, 4, 16, 2); + const rect = minAreaRect(mask, 20, 10); + // 16 cols x 2 rows: width ~15-16, height ~1-2 (pixel rasterization) + expect(rect.width).toBeGreaterThanOrEqual(14); + expect(rect.width).toBeLessThanOrEqual(17); + expect(rect.height).toBeGreaterThanOrEqual(1); + expect(rect.height).toBeLessThanOrEqual(3); + expect(rect.angle).toBeCloseTo(0, 1); + }); + + it('handles a vertical bar (taller than wide)', () => { + // 2x16 bar on a 10x20 canvas + const mask = rectMask(10, 20, 4, 2, 2, 16); + const rect = minAreaRect(mask, 10, 20); + // Long side ~16, short side ~2 + const longSide = Math.max(rect.width, rect.height); + const shortSide = Math.min(rect.width, rect.height); + expect(longSide).toBeGreaterThanOrEqual(14); + expect(longSide).toBeLessThanOrEqual(17); + expect(shortSide).toBeGreaterThanOrEqual(1); + expect(shortSide).toBeLessThanOrEqual(3); + }); +}); + +// --- axisFromRect ------------------------------------------------------------------ + +describe('axisFromRect', () => { + it('returns a unit vector along the long side of an axis-aligned rect', () => { + // 8x4 rect: long side is horizontal (width=8 > height=4) + const rect = { + center: { x: 6, y: 4 }, + width: 8, + height: 4, + angle: 0, + area: 32, + }; + const axis = axisFromRect(rect); + // Long side is width (8), so axis is along +x + expect(axis.x).toBeCloseTo(1, 6); + expect(axis.y).toBeCloseTo(0, 6); + }); + + it('returns a unit vector along the long side when height > width', () => { + // 4x8 rect: long side is vertical (height=8 > width=4) + const rect = { + center: { x: 6, y: 4 }, + width: 4, + height: 8, + angle: 0, + area: 32, + }; + const axis = axisFromRect(rect); + // Long side is height, so axis is along +y + expect(axis.x).toBeCloseTo(0, 6); + expect(axis.y).toBeCloseTo(1, 6); + }); + + it('rotates the axis with the rect angle', () => { + // 8x4 rect rotated 90 degrees: long side becomes vertical + const rect = { + center: { x: 6, y: 4 }, + width: 8, + height: 4, + angle: Math.PI / 2, + area: 32, + }; + const axis = axisFromRect(rect); + // The long side (width=8) is now along +y after 90-degree rotation + expect(axis.x).toBeCloseTo(0, 6); + expect(axis.y).toBeCloseTo(1, 6); + }); + + it('returns the axis at 45 degrees for a rotated rect', () => { + const rect = { + center: { x: 20, y: 20 }, + width: 20, + height: 4, + angle: Math.PI / 4, + area: 80, + }; + const axis = axisFromRect(rect); + // Long side at 45 degrees + expect(axis.x).toBeCloseTo(Math.SQRT1_2, 3); + expect(axis.y).toBeCloseTo(Math.SQRT1_2, 3); + }); +}); + +// --- rotateAxis -------------------------------------------------------------------- + +describe('rotateAxis', () => { + it('rotates by 0 degrees is a no-op', () => { + const axis = { x: 1, y: 0 }; + const rot = rotateAxis(axis, 0); + expect(rot.x).toBeCloseTo(1, 6); + expect(rot.y).toBeCloseTo(0, 6); + }); + + it('rotates by 90 degrees', () => { + const axis = { x: 1, y: 0 }; + const rot = rotateAxis(axis, Math.PI / 2); + expect(rot.x).toBeCloseTo(0, 6); + expect(rot.y).toBeCloseTo(1, 6); + }); + + it('rotates by -90 degrees', () => { + const axis = { x: 1, y: 0 }; + const rot = rotateAxis(axis, -Math.PI / 2); + expect(rot.x).toBeCloseTo(0, 6); + expect(rot.y).toBeCloseTo(-1, 6); + }); + + it('rotates a 45-degree axis by 45 degrees -> 90 degrees (vertical)', () => { + const axis = { x: Math.SQRT1_2, y: Math.SQRT1_2 }; + const rot = rotateAxis(axis, Math.PI / 4); + expect(rot.x).toBeCloseTo(0, 3); + expect(rot.y).toBeCloseTo(1, 3); + }); +}); + +// --- axisAngle ------------------------------------------------------------------- + +describe('axisAngle', () => { + it('returns 0 for +x axis', () => { + expect(axisAngle({ x: 1, y: 0 })).toBeCloseTo(0, 6); + }); + it('returns PI/2 for +y axis', () => { + expect(axisAngle({ x: 0, y: 1 })).toBeCloseTo(Math.PI / 2, 6); + }); + it('returns -PI/2 for -y axis', () => { + expect(axisAngle({ x: 0, y: -1 })).toBeCloseTo(-Math.PI / 2, 6); + }); + it('returns PI for -x axis', () => { + expect(axisAngle({ x: -1, y: 0 })).toBeCloseTo(Math.PI, 6); + }); + it('returns 45 degrees for diagonal', () => { + expect(axisAngle({ x: Math.SQRT1_2, y: Math.SQRT1_2 })).toBeCloseTo( + Math.PI / 4, + 3, + ); + }); +}); + +// --- axisUnit -------------------------------------------------------------------- + +describe('axisUnit', () => { + it('creates a unit vector from an angle', () => { + expect(axisUnit(0).x).toBeCloseTo(1, 6); + expect(axisUnit(0).y).toBeCloseTo(0, 6); + expect(axisUnit(Math.PI / 2).x).toBeCloseTo(0, 6); + expect(axisUnit(Math.PI / 2).y).toBeCloseTo(1, 6); + expect(axisUnit(-Math.PI / 2).x).toBeCloseTo(0, 6); + expect(axisUnit(-Math.PI / 2).y).toBeCloseTo(-1, 6); + expect(axisUnit(Math.PI).x).toBeCloseTo(-1, 6); + expect(axisUnit(Math.PI).y).toBeCloseTo(0, 6); + }); +}); + +// --- createAxisState ----------------------------------------------------------------- + +describe('createAxisState', () => { + it('starts with the proposed axis from the rect, not approved', () => { + const rect = { + center: { x: 6, y: 4 }, + width: 8, + height: 4, + angle: 0, + area: 32, + }; + const state = createAxisState(rect); + expect(state.rect).toBe(rect); + expect(state.axis.x).toBeCloseTo(1, 6); // long side is horizontal + expect(state.axis.y).toBeCloseTo(0, 6); + expect(state.approved).toBe(false); + }); + + it('proposed axis follows the rect angle', () => { + const rect = { + center: { x: 20, y: 20 }, + width: 20, + height: 4, + angle: Math.PI / 4, + area: 80, + }; + const state = createAxisState(rect); + expect(state.axis.x).toBeCloseTo(Math.SQRT1_2, 3); + expect(state.axis.y).toBeCloseTo(Math.SQRT1_2, 3); + }); +}); + +// --- reduceAxisState ----------------------------------------------------------------- + +describe('reduceAxisState', () => { + const rect = { + center: { x: 6, y: 4 }, + width: 8, + height: 4, + angle: 0, + area: 32, + }; + + it('rotate updates the axis immediately', () => { + let s = createAxisState(rect); + s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 }); + // +x rotated by 90 degrees -> +y + expect(s.axis.x).toBeCloseTo(0, 6); + expect(s.axis.y).toBeCloseTo(1, 6); + }); + + it('rotate composes (rotates from current axis)', () => { + let s = createAxisState(rect); + s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 }); + s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 }); + // Two 90-degree rotations -> -x + expect(s.axis.x).toBeCloseTo(-1, 6); + expect(s.axis.y).toBeCloseTo(0, 6); + }); + + it('rotate by 0 is a no-op', () => { + let s = createAxisState(rect); + const before = s; + s = reduceAxisState(s, { type: 'rotate', angle: 0 }); + expect(s).toBe(before); + }); + + it('approve sets approved=true', () => { + let s = createAxisState(rect); + s = reduceAxisState(s, { type: 'approve' }); + expect(s.approved).toBe(true); + }); + + it('retract approval on rotate after approval', () => { + let s = createAxisState(rect); + s = reduceAxisState(s, { type: 'approve' }); + expect(s.approved).toBe(true); + // Rotate after approval: axis changes, approval is retracted + // (user is adjusting the axis, so it is no longer approved) + s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 4 }); + expect(s.approved).toBe(false); + }); + + it('unknown actions return the same state', () => { + const s = createAxisState(rect); + expect(reduceAxisState(s, { type: 'bogus' })).toBe(s); + }); +});