diff --git a/.github/workflows/client-test.yml b/.github/workflows/client-test.yml new file mode 100644 index 00000000..cc7f8cbf --- /dev/null +++ b/.github/workflows/client-test.yml @@ -0,0 +1,31 @@ +name: Client Unit Tests + +on: [pull_request] + +permissions: + checks: write + pull-requests: write + +jobs: + run-client-tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./client + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 22 + - name: Install npm 10 + run: npm install -g npm@10 + - name: Install dependencies + run: npm ci + - name: Run tests + run: npm run test:run + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + check_name: 'Client Test Results' + junit_files: '**/client/junit/*.xml' \ No newline at end of file diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index f9059835..ba705cb3 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -24,7 +24,7 @@ jobs: pip install -r requirements.txt -r test_requirements.txt - name: Run ruff linter run: | - ruff check $(git ls-files '*.py') --output-format github + ruff check . --output-format github - name: Run ruff formatter run: | - ruff format --check $(git ls-files '*.py') + ruff format --check . diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 600d1989..1e0dd0fc 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -30,4 +30,5 @@ jobs: uses: EnricoMi/publish-unit-test-result-action@v2 if: always() with: + check_name: 'Python Test Results' junit_files: '**/server/junit/*.xml' \ No newline at end of file diff --git a/.idea/DigiScript-2.iml b/.idea/DigiScript-2.iml index d7e9a0fb..e1b482f6 100644 --- a/.idea/DigiScript-2.iml +++ b/.idea/DigiScript-2.iml @@ -16,6 +16,8 @@ + + diff --git a/client/.gitignore b/client/.gitignore index b3bd0731..aa5e5f99 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -25,3 +25,6 @@ pnpm-debug.log* # Generated documentation (copied from /docs during build) public/docs/ + +# Test output +junit/ diff --git a/client/package-lock.json b/client/package-lock.json index 8c10ab18..7242f089 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,18 +1,21 @@ { "name": "client", - "version": "0.20.1", + "version": "0.20.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "0.20.1", + "version": "0.20.2", "dependencies": { "bootstrap": "4.6.2", "bootstrap-vue": "2.23.1", "bootswatch": "4.6.2", "contrast-color": "1.0.1", "core-js": "3.47.0", + "d3-hierarchy": "^3.1.2", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", "deep-object-diff": "1.1.9", "dompurify": "3.3.1", "fuse.js": "7.1.0", @@ -35,22 +38,88 @@ "@babel/preset-env": "7.28.5", "@types/vuelidate": "^0.7.22", "@vitejs/plugin-vue2": "2.3.4", + "@vitest/ui": "^4.0.16", + "@vue/test-utils": "^2.4.6", "eslint": "8.57.0", "eslint-config-airbnb-base": "15.0.0", "eslint-import-resolver-alias": "1.1.2", "eslint-plugin-import": "2.32.0", "eslint-plugin-vue": "^9.27.0", "eslint-plugin-vuejs-accessibility": "1.2.0", + "jsdom": "^27.3.0", "node-sass": "7.0.3", "sass": "1.97.0", "sass-loader": "13.3.3", - "vite": "4.5.5" + "vite": "4.5.5", + "vitest": "^4.0.16" }, "engines": { "node": ">=22.0.0 <23", "npm": ">=10.0.0 <11" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.29", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.29.tgz", + "integrity": "sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1580,6 +1649,158 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.21.tgz", + "integrity": "sha512-plP8N8zKfEZ26figX4Nvajx8DuzfuRpLTqglQ5d0chfnt35Qt3X+m6ASZ+rG0D0kxe/upDVNwSIVJP5n4FuNfw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/android-arm": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", @@ -1852,6 +2073,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", @@ -1869,6 +2107,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", @@ -1886,6 +2141,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", @@ -2104,6 +2376,109 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.12", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", @@ -2149,9 +2524,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, @@ -2271,6 +2646,13 @@ "npm": ">=5.0.0" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -2581,36 +2963,387 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, "license": "MIT", + "optional": true, "engines": { - "node": ">= 6" + "node": ">=14" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } + "license": "MIT" }, - "node_modules/@types/eslint-scope": { + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", + "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", + "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz", + "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", + "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", + "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", + "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", + "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", + "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", + "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", + "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", + "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", + "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", + "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", + "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", + "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", + "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", + "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", + "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", + "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", + "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", + "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", + "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { "version": "3.7.7", "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", @@ -2623,12 +3356,11 @@ } }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", @@ -2653,14 +3385,14 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", - "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/normalize-package-data": { @@ -2744,14 +3476,120 @@ "vue": "^2.7.0-0" } }, - "node_modules/@vue/compiler-sfc": { - "version": "2.7.14", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.14.tgz", - "integrity": "sha512-aNmNHyLPsw+sVvlQFQ2/8sjNuLtK54TC6cuKnVzAY93ks4ZBrvwQSnkkIh7bsbNhum5hJBS00wSDipQ937f5DA==", + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/parser": "^7.18.4", - "postcss": "^8.4.14", - "source-map": "^0.6.1" + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.16.tgz", + "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.16" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "2.7.14", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.14.tgz", + "integrity": "sha512-aNmNHyLPsw+sVvlQFQ2/8sjNuLtK54TC6cuKnVzAY93ks4ZBrvwQSnkkIh7bsbNhum5hJBS00wSDipQ937f5DA==", + "dependencies": { + "@babel/parser": "^7.18.4", + "postcss": "^8.4.14", + "source-map": "^0.6.1" } }, "node_modules/@vue/compiler-sfc/node_modules/source-map": { @@ -2763,6 +3601,17 @@ "node": ">=0.10.0" } }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -3269,6 +4118,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-foreach": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", @@ -3387,6 +4246,16 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -3666,6 +4535,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3800,6 +4679,17 @@ "dev": true, "license": "MIT" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", @@ -3883,6 +4773,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3896,12 +4800,141 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz", + "integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -3915,6 +4948,57 @@ "node": ">=0.10" } }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4024,6 +5108,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4150,6 +5241,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -4168,6 +5266,74 @@ "dev": true, "license": "MIT" }, + "node_modules/editorconfig": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", + "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/editorconfig/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.180", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.180.tgz", @@ -4207,6 +5373,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4324,12 +5503,11 @@ } }, "node_modules/es-module-lexer": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", - "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -4909,6 +6087,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4930,6 +6118,16 @@ "node": ">=0.8.x" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4978,6 +6176,13 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -5038,9 +6243,9 @@ } }, "node_modules/flatted": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", - "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, @@ -5060,6 +6265,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -5604,6 +6839,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -5670,8 +6918,8 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -5759,6 +7007,13 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -6079,6 +7334,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6252,6 +7514,22 @@ "dev": true, "license": "MIT" }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -6298,6 +7576,121 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/js-beautify/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/js-beautify/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/js-beautify/node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6325,6 +7718,144 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "27.3.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.3.0.tgz", + "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -6520,6 +8051,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-fetch-happen": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", @@ -6603,6 +8144,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/meow": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", @@ -6848,6 +8396,16 @@ "node": ">=10" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6863,9 +8421,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -7266,6 +8824,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7370,6 +8939,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7402,6 +8978,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7439,6 +9028,47 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -7497,9 +9127,9 @@ } }, "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -7516,7 +9146,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7593,6 +9223,13 @@ "node": ">=10" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -7996,10 +9633,20 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "dev": true, "license": "MIT", "dependencies": { @@ -8267,6 +9914,19 @@ } } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -8481,6 +10141,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -8488,6 +10155,21 @@ "dev": true, "license": "ISC" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -8660,6 +10342,20 @@ "node": ">= 8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdout-stream": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/stdout-stream/-/stdout-stream-1.4.1.tgz", @@ -8749,6 +10445,29 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -8828,6 +10547,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -8889,6 +10622,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -8998,6 +10738,101 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9012,6 +10847,16 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", @@ -9222,9 +11067,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT", "peer": true @@ -9250,170 +11095,830 @@ "unicode-property-aliases-ecmascript": "^2.0.0" }, "engines": { - "node": ">=4" + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/vite": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.5.tgz", + "integrity": "sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", - "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/unique-filename": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", - "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^2.0.0" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/unique-slug": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", - "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, - "bin": { - "update-browserslist-db": "cli.js" + "funding": { + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "node_modules/vitest/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "bin": { - "uuid": "bin/uuid" + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/vitest/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "node_modules/vitest/node_modules/rollup": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", + "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", "dev": true, - "engines": [ - "node >=0.6.0" - ], "license": "MIT", "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.5", + "@rollup/rollup-android-arm64": "4.53.5", + "@rollup/rollup-darwin-arm64": "4.53.5", + "@rollup/rollup-darwin-x64": "4.53.5", + "@rollup/rollup-freebsd-arm64": "4.53.5", + "@rollup/rollup-freebsd-x64": "4.53.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", + "@rollup/rollup-linux-arm-musleabihf": "4.53.5", + "@rollup/rollup-linux-arm64-gnu": "4.53.5", + "@rollup/rollup-linux-arm64-musl": "4.53.5", + "@rollup/rollup-linux-loong64-gnu": "4.53.5", + "@rollup/rollup-linux-ppc64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-musl": "4.53.5", + "@rollup/rollup-linux-s390x-gnu": "4.53.5", + "@rollup/rollup-linux-x64-gnu": "4.53.5", + "@rollup/rollup-linux-x64-musl": "4.53.5", + "@rollup/rollup-openharmony-arm64": "4.53.5", + "@rollup/rollup-win32-arm64-msvc": "4.53.5", + "@rollup/rollup-win32-ia32-msvc": "4.53.5", + "@rollup/rollup-win32-x64-gnu": "4.53.5", + "@rollup/rollup-win32-x64-msvc": "4.53.5", + "fsevents": "~2.3.2" } }, - "node_modules/vite": { - "version": "4.5.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.5.tgz", - "integrity": "sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ==", + "node_modules/vitest/node_modules/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.18.10", - "postcss": "^8.4.27", - "rollup": "^3.27.1" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": ">= 14", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -9423,6 +11928,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -9431,6 +11939,12 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, @@ -9445,6 +11959,13 @@ "csstype": "^3.1.0" } }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-eslint-parser": { "version": "9.4.3", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", @@ -17407,6 +19928,29 @@ "vuex": "^2.0.0 || ^3.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", @@ -17487,6 +20031,29 @@ "node": ">=10.13.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -17602,6 +20169,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -17640,6 +20224,25 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -17647,6 +20250,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -17657,6 +20282,13 @@ "node": ">=12" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/client/package.json b/client/package.json index 13ee8c59..2c8086de 100644 --- a/client/package.json +++ b/client/package.json @@ -1,13 +1,16 @@ { "name": "client", - "version": "0.20.1", + "version": "0.20.2", "private": true, "scripts": { "prebuild": "scripts/copy-docs.sh && node scripts/generate-doc-manifest.js", "build": "vite build", "lint": "eslint 'src/**/*.{js,vue}' --fix", "ci-lint": "eslint 'src/**/*.{js,vue}'", - "lint:filter": "./scripts/eslint-filter.sh" + "lint:filter": "./scripts/eslint-filter.sh", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run" }, "engines": { "npm": ">=10.0.0 <11", @@ -19,6 +22,9 @@ "bootswatch": "4.6.2", "contrast-color": "1.0.1", "core-js": "3.47.0", + "d3-hierarchy": "^3.1.2", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", "deep-object-diff": "1.1.9", "dompurify": "3.3.1", "fuse.js": "7.1.0", @@ -41,16 +47,20 @@ "@babel/preset-env": "7.28.5", "@types/vuelidate": "^0.7.22", "@vitejs/plugin-vue2": "2.3.4", + "@vitest/ui": "^4.0.16", + "@vue/test-utils": "^2.4.6", "eslint": "8.57.0", "eslint-config-airbnb-base": "15.0.0", "eslint-import-resolver-alias": "1.1.2", "eslint-plugin-import": "2.32.0", "eslint-plugin-vue": "^9.27.0", "eslint-plugin-vuejs-accessibility": "1.2.0", + "jsdom": "^27.3.0", "node-sass": "7.0.3", "sass": "1.97.0", "sass-loader": "13.3.3", - "vite": "4.5.5" + "vite": "4.5.5", + "vitest": "^4.0.16" }, "browserslist": [ "> 1%", diff --git a/client/src/js/micConflictUtils.js b/client/src/js/micConflictUtils.js new file mode 100644 index 00000000..4c6096d2 --- /dev/null +++ b/client/src/js/micConflictUtils.js @@ -0,0 +1,289 @@ +export function buildSceneGraph(scenes, acts, currentShow) { + if (!currentShow?.first_act_id || !scenes?.length || !acts?.length) { + return []; + } + + const sceneById = {}; + scenes.forEach((scene) => { + sceneById[scene.id] = scene; + }); + + const actById = {}; + acts.forEach((act) => { + actById[act.id] = act; + }); + + const graph = []; + const graphById = {}; // Lookup object to avoid .find() in loops + let globalPosition = 0; + + // Traverse acts in linked list order + let currentAct = actById[currentShow.first_act_id]; + let previousActLastSceneId = null; + + while (currentAct != null) { + let scenePosition = 0; + let previousSceneId = null; + + // Traverse scenes within this act + let currentScene = currentAct.first_scene ? sceneById[currentAct.first_scene] : null; + + while (currentScene != null) { + const node = { + sceneId: currentScene.id, + actId: currentScene.act, + sceneName: currentScene.name, + actName: currentAct.name, + globalPosition, + scenePositionInAct: scenePosition, + // Within-act adjacency + previousSceneInAct: previousSceneId, + nextSceneInAct: null, // Will be set when we process next scene + // Cross-act adjacency + previousSceneInShow: null, + nextSceneInShow: null, + }; + + // Set next pointer for previous scene + if (previousSceneId) { + const prevNode = graphById[previousSceneId]; + if (prevNode) { + prevNode.nextSceneInAct = currentScene.id; + prevNode.nextSceneInShow = currentScene.id; + node.previousSceneInShow = previousSceneId; + } + } + + // Handle cross-act boundary (last scene of previous act → first scene of this act) + if (scenePosition === 0 && previousActLastSceneId) { + const prevActLastNode = graphById[previousActLastSceneId]; + if (prevActLastNode) { + prevActLastNode.nextSceneInShow = currentScene.id; + node.previousSceneInShow = previousActLastSceneId; + } + } + + graph.push(node); + graphById[currentScene.id] = node; + + previousSceneId = currentScene.id; + currentScene = currentScene.next_scene ? sceneById[currentScene.next_scene] : null; + scenePosition++; + globalPosition++; + } + + // Remember last scene of this act for cross-act linking + previousActLastSceneId = previousSceneId; + + // Move to next act + currentAct = currentAct.next_act ? actById[currentAct.next_act] : null; + } + + return graph; +} + +export function getAdjacentScenes(sceneId, sceneGraph) { + const node = sceneGraph.find((n) => n.sceneId === sceneId); + + if (!node) { + return { + sameActPrev: null, + sameActNext: null, + crossActPrev: null, + crossActNext: null, + }; + } + + const prevNode = node.previousSceneInShow + ? sceneGraph.find((n) => n.sceneId === node.previousSceneInShow) + : null; + const nextNode = node.nextSceneInShow + ? sceneGraph.find((n) => n.sceneId === node.nextSceneInShow) + : null; + + return { + // Same act adjacency + sameActPrev: prevNode && prevNode.actId === node.actId ? prevNode.sceneId : null, + sameActNext: nextNode && nextNode.actId === node.actId ? nextNode.sceneId : null, + // Cross-act adjacency (last scene of act N → first scene of act N+1) + crossActPrev: prevNode && prevNode.actId !== node.actId ? prevNode.sceneId : null, + crossActNext: nextNode && nextNode.actId !== node.actId ? nextNode.sceneId : null, + }; +} + +export function areScenesInSameAct(sceneId1, sceneId2, sceneGraph) { + const node1 = sceneGraph.find((n) => n.sceneId === sceneId1); + const node2 = sceneGraph.find((n) => n.sceneId === sceneId2); + + if (!node1 || !node2) { + return false; + } + + return node1.actId === node2.actId; +} + +export function isSameCastMember(characterId1, characterId2, characters, castList) { + if (characterId1 === characterId2) { + return true; // Same character, trivially same cast member + } + + const char1 = characters.find((c) => c.id === characterId1); + const char2 = characters.find((c) => c.id === characterId2); + + if (!char1 || !char2) { + return false; + } + + // Check cast_member.id (nested relationship) instead of played_by + const castId1 = char1.cast_member?.id; + const castId2 = char2.cast_member?.id; + + // Check if both have cast assignments (use == null to allow 0 as valid ID) + if (castId1 == null || castId2 == null) { + return false; // Conservative: no cast assignment = different cast members + } + + return castId1 === castId2; +} + +export function getConflictSeverity(sceneId1, sceneId2, sceneGraph) { + const sameAct = areScenesInSameAct(sceneId1, sceneId2, sceneGraph); + return sameAct ? 'WARNING' : 'INFO'; +} + +export function detectMicConflicts(allocations, scenes, acts, currentShow, characters, castList) { + if (!allocations || !scenes?.length || !acts?.length || !currentShow) { + return { + conflicts: [], + conflictsByScene: {}, + conflictsByMic: {}, + }; + } + + const sceneGraph = buildSceneGraph(scenes, acts, currentShow); + + if (sceneGraph.length === 0) { + return { + conflicts: [], + conflictsByScene: {}, + conflictsByMic: {}, + }; + } + + const conflicts = []; + + // For each microphone + Object.keys(allocations).forEach((micId) => { + const micAllocations = allocations[micId]; + + if (!micAllocations || typeof micAllocations !== 'object') { + return; + } + + // For each scene where this mic is allocated + Object.keys(micAllocations).forEach((sceneId) => { + const characterId = micAllocations[sceneId]; + + if (characterId == null) { + return; // No allocation in this scene + } + + const sceneIdNum = parseInt(sceneId, 10); + const adjacentScenes = getAdjacentScenes(sceneIdNum, sceneGraph); + + // Check all adjacent scenes (same act + cross act) + const adjacentSceneIds = [ + adjacentScenes.sameActPrev, + adjacentScenes.sameActNext, + adjacentScenes.crossActPrev, + adjacentScenes.crossActNext, + ].filter((id) => id != null); + + adjacentSceneIds.forEach((adjacentSceneId) => { + const adjacentCharacterId = micAllocations[adjacentSceneId]; + + if (adjacentCharacterId == null) { + return; // No allocation in adjacent scene + } + + if (adjacentCharacterId === characterId) { + return; // Same character keeps the mic (no conflict) + } + + // Check if same cast member (no conflict if true) + if (isSameCastMember(characterId, adjacentCharacterId, characters, castList)) { + return; // Same actor keeps mic across characters + } + + // We have a conflict! Determine severity + const severity = getConflictSeverity(sceneIdNum, adjacentSceneId, sceneGraph); + + const currentSceneNode = sceneGraph.find((n) => n.sceneId === sceneIdNum); + const adjacentSceneNode = sceneGraph.find((n) => n.sceneId === adjacentSceneId); + + const char1 = characters.find((c) => c.id === characterId); + const char2 = characters.find((c) => c.id === adjacentCharacterId); + + // Build conflict message (shown on the destination scene being changed INTO) + let message = `Quick-change from "${currentSceneNode?.sceneName || 'Unknown'}"`; + if (char1 && char2) { + message += ` (${char1.name} → ${char2.name})`; + } + if (severity === 'WARNING') { + message += ' - Tight changeover required'; + } else { + message += ' - Interval provides changeover time'; + } + + // Avoid duplicate conflicts (if scene A→B is a conflict, don't also add B→A) + const isDuplicate = conflicts.some( + (c) => c.micId === parseInt(micId, 10) + && c.sceneId === adjacentSceneId + && c.adjacentSceneId === sceneIdNum, + ); + + if (!isDuplicate) { + conflicts.push({ + micId: parseInt(micId, 10), + sceneId: sceneIdNum, + sceneName: currentSceneNode?.sceneName || 'Unknown', + actName: currentSceneNode?.actName || 'Unknown', + characterId, + characterName: char1?.name || 'Unknown', + adjacentSceneId, + adjacentSceneName: adjacentSceneNode?.sceneName || 'Unknown', + adjacentActName: adjacentSceneNode?.actName || 'Unknown', + adjacentCharacterId, + adjacentCharacterName: char2?.name || 'Unknown', + severity, + message, + }); + } + }); + }); + }); + + // Build indexed lookups + const conflictsByScene = {}; + const conflictsByMic = {}; + + conflicts.forEach((conflict) => { + // Index by scene + if (!conflictsByScene[conflict.sceneId]) { + conflictsByScene[conflict.sceneId] = []; + } + conflictsByScene[conflict.sceneId].push(conflict); + + // Index by mic + if (!conflictsByMic[conflict.micId]) { + conflictsByMic[conflict.micId] = []; + } + conflictsByMic[conflict.micId].push(conflict); + }); + + return { + conflicts, + conflictsByScene, + conflictsByMic, + }; +} diff --git a/client/src/js/micConflictUtils.test.js b/client/src/js/micConflictUtils.test.js new file mode 100644 index 00000000..f95cf026 --- /dev/null +++ b/client/src/js/micConflictUtils.test.js @@ -0,0 +1,700 @@ +import { describe, it, expect } from 'vitest'; +import { + buildSceneGraph, + getAdjacentScenes, + areScenesInSameAct, + isSameCastMember, + getConflictSeverity, + detectMicConflicts, +} from './micConflictUtils'; + +describe('micConflictUtils', () => { + describe('buildSceneGraph', () => { + it('should return empty array when currentShow is null', () => { + const scenes = [{ id: 1, name: 'Scene 1' }]; + const acts = [{ id: 1, name: 'Act 1' }]; + const result = buildSceneGraph(scenes, acts, null); + expect(result).toEqual([]); + }); + + it('should return empty array when scenes array is empty', () => { + const scenes = []; + const acts = [{ id: 1, name: 'Act 1' }]; + const currentShow = { first_act_id: 1 }; + const result = buildSceneGraph(scenes, acts, currentShow); + expect(result).toEqual([]); + }); + + it('should return empty array when acts array is empty', () => { + const scenes = [{ id: 1, name: 'Scene 1' }]; + const acts = []; + const currentShow = { first_act_id: 1 }; + const result = buildSceneGraph(scenes, acts, currentShow); + expect(result).toEqual([]); + }); + + it('should build graph for single act with single scene', () => { + const scenes = [ + { + id: 1, act: 1, name: 'Scene 1', next_scene: null, + }, + ]; + const acts = [ + { + id: 1, name: 'Act 1', first_scene: 1, next_act: null, + }, + ]; + const currentShow = { first_act_id: 1 }; + + const result = buildSceneGraph(scenes, acts, currentShow); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + sceneId: 1, + actId: 1, + sceneName: 'Scene 1', + actName: 'Act 1', + globalPosition: 0, + scenePositionInAct: 0, + previousSceneInAct: null, + nextSceneInAct: null, + previousSceneInShow: null, + nextSceneInShow: null, + }); + }); + + it('should link multiple scenes within same act', () => { + const scenes = [ + { + id: 1, act: 1, name: 'Scene 1', next_scene: 2, + }, + { + id: 2, act: 1, name: 'Scene 2', next_scene: 3, + }, + { + id: 3, act: 1, name: 'Scene 3', next_scene: null, + }, + ]; + const acts = [ + { + id: 1, name: 'Act 1', first_scene: 1, next_act: null, + }, + ]; + const currentShow = { first_act_id: 1 }; + + const result = buildSceneGraph(scenes, acts, currentShow); + + expect(result).toHaveLength(3); + + // Scene 1 + expect(result[0]).toMatchObject({ + sceneId: 1, + previousSceneInAct: null, + nextSceneInAct: 2, + previousSceneInShow: null, + nextSceneInShow: 2, + }); + + // Scene 2 + expect(result[1]).toMatchObject({ + sceneId: 2, + previousSceneInAct: 1, + nextSceneInAct: 3, + previousSceneInShow: 1, + nextSceneInShow: 3, + }); + + // Scene 3 + expect(result[2]).toMatchObject({ + sceneId: 3, + previousSceneInAct: 2, + nextSceneInAct: null, + previousSceneInShow: 2, + nextSceneInShow: null, + }); + }); + + it('should link scenes across act boundaries', () => { + const scenes = [ + { + id: 1, act: 1, name: 'Act 1 Scene 1', next_scene: 2, + }, + { + id: 2, act: 1, name: 'Act 1 Scene 2', next_scene: null, + }, + { + id: 3, act: 2, name: 'Act 2 Scene 1', next_scene: null, + }, + ]; + const acts = [ + { + id: 1, name: 'Act 1', first_scene: 1, next_act: 2, + }, + { + id: 2, name: 'Act 2', first_scene: 3, next_act: null, + }, + ]; + const currentShow = { first_act_id: 1 }; + + const result = buildSceneGraph(scenes, acts, currentShow); + + expect(result).toHaveLength(3); + + // Last scene of Act 1 (Scene 2) + expect(result[1]).toMatchObject({ + sceneId: 2, + actId: 1, + nextSceneInAct: null, // No next scene in Act 1 + nextSceneInShow: 3, // But links to first scene of Act 2 + }); + + // First scene of Act 2 (Scene 3) + expect(result[2]).toMatchObject({ + sceneId: 3, + actId: 2, + previousSceneInAct: null, // No previous scene in Act 2 + previousSceneInShow: 2, // But links to last scene of Act 1 + }); + }); + + it('should set correct global positions', () => { + const scenes = [ + { + id: 1, act: 1, name: 'Scene 1', next_scene: 2, + }, + { + id: 2, act: 1, name: 'Scene 2', next_scene: null, + }, + { + id: 3, act: 2, name: 'Scene 3', next_scene: null, + }, + ]; + const acts = [ + { + id: 1, name: 'Act 1', first_scene: 1, next_act: 2, + }, + { + id: 2, name: 'Act 2', first_scene: 3, next_act: null, + }, + ]; + const currentShow = { first_act_id: 1 }; + + const result = buildSceneGraph(scenes, acts, currentShow); + + expect(result[0].globalPosition).toBe(0); + expect(result[1].globalPosition).toBe(1); + expect(result[2].globalPosition).toBe(2); + }); + }); + + describe('getAdjacentScenes', () => { + it('should return all nulls for non-existent scene', () => { + const sceneGraph = []; + const result = getAdjacentScenes(999, sceneGraph); + + expect(result).toEqual({ + sameActPrev: null, + sameActNext: null, + crossActPrev: null, + crossActNext: null, + }); + }); + + it('should return same-act adjacency for middle scene', () => { + const sceneGraph = [ + { + sceneId: 1, + actId: 1, + previousSceneInShow: null, + nextSceneInShow: 2, + }, + { + sceneId: 2, + actId: 1, + previousSceneInShow: 1, + nextSceneInShow: 3, + }, + { + sceneId: 3, + actId: 1, + previousSceneInShow: 2, + nextSceneInShow: null, + }, + ]; + + const result = getAdjacentScenes(2, sceneGraph); + + expect(result).toEqual({ + sameActPrev: 1, + sameActNext: 3, + crossActPrev: null, + crossActNext: null, + }); + }); + + it('should return cross-act adjacency for last scene of act', () => { + const sceneGraph = [ + { + sceneId: 1, + actId: 1, + previousSceneInShow: null, + nextSceneInShow: 2, + }, + { + sceneId: 2, + actId: 2, + previousSceneInShow: 1, + nextSceneInShow: null, + }, + ]; + + const result = getAdjacentScenes(1, sceneGraph); + + expect(result).toEqual({ + sameActPrev: null, + sameActNext: null, + crossActPrev: null, + crossActNext: 2, // Cross-act to scene in Act 2 + }); + }); + + it('should return cross-act adjacency for first scene of act', () => { + const sceneGraph = [ + { + sceneId: 1, + actId: 1, + previousSceneInShow: null, + nextSceneInShow: 2, + }, + { + sceneId: 2, + actId: 2, + previousSceneInShow: 1, + nextSceneInShow: null, + }, + ]; + + const result = getAdjacentScenes(2, sceneGraph); + + expect(result).toEqual({ + sameActPrev: null, + sameActNext: null, + crossActPrev: 1, // Cross-act from scene in Act 1 + crossActNext: null, + }); + }); + }); + + describe('areScenesInSameAct', () => { + it('should return false when first scene not found', () => { + const sceneGraph = [ + { sceneId: 1, actId: 1 }, + { sceneId: 2, actId: 2 }, + ]; + + const result = areScenesInSameAct(999, 2, sceneGraph); + expect(result).toBe(false); + }); + + it('should return false when second scene not found', () => { + const sceneGraph = [ + { sceneId: 1, actId: 1 }, + { sceneId: 2, actId: 2 }, + ]; + + const result = areScenesInSameAct(1, 999, sceneGraph); + expect(result).toBe(false); + }); + + it('should return true when scenes are in same act', () => { + const sceneGraph = [ + { sceneId: 1, actId: 1 }, + { sceneId: 2, actId: 1 }, + ]; + + const result = areScenesInSameAct(1, 2, sceneGraph); + expect(result).toBe(true); + }); + + it('should return false when scenes are in different acts', () => { + const sceneGraph = [ + { sceneId: 1, actId: 1 }, + { sceneId: 2, actId: 2 }, + ]; + + const result = areScenesInSameAct(1, 2, sceneGraph); + expect(result).toBe(false); + }); + }); + + describe('isSameCastMember', () => { + it('should return true for same character ID', () => { + const characters = [ + { id: 1, name: 'Hamlet', cast_member: { id: 10 } }, + ]; + const result = isSameCastMember(1, 1, characters, []); + expect(result).toBe(true); + }); + + it('should return false when first character not found', () => { + const characters = [ + { id: 1, name: 'Hamlet', cast_member: { id: 10 } }, + ]; + const result = isSameCastMember(999, 1, characters, []); + expect(result).toBe(false); + }); + + it('should return false when second character not found', () => { + const characters = [ + { id: 1, name: 'Hamlet', cast_member: { id: 10 } }, + ]; + const result = isSameCastMember(1, 999, characters, []); + expect(result).toBe(false); + }); + + it('should return false when first character has no cast assignment', () => { + const characters = [ + { id: 1, name: 'Hamlet', cast_member: null }, + { id: 2, name: 'Ophelia', cast_member: { id: 20 } }, + ]; + const result = isSameCastMember(1, 2, characters, []); + expect(result).toBe(false); + }); + + it('should return false when second character has no cast assignment', () => { + const characters = [ + { id: 1, name: 'Hamlet', cast_member: { id: 10 } }, + { id: 2, name: 'Ophelia', cast_member: null }, + ]; + const result = isSameCastMember(1, 2, characters, []); + expect(result).toBe(false); + }); + + it('should return true when same cast member plays both characters', () => { + const characters = [ + { id: 1, name: 'Hamlet', cast_member: { id: 10 } }, + { id: 2, name: 'Ghost', cast_member: { id: 10 } }, // Same actor + ]; + const result = isSameCastMember(1, 2, characters, []); + expect(result).toBe(true); + }); + + it('should return false when different cast members', () => { + const characters = [ + { id: 1, name: 'Hamlet', cast_member: { id: 10 } }, + { id: 2, name: 'Ophelia', cast_member: { id: 20 } }, + ]; + const result = isSameCastMember(1, 2, characters, []); + expect(result).toBe(false); + }); + }); + + describe('getConflictSeverity', () => { + it('should return WARNING for scenes in same act', () => { + const sceneGraph = [ + { sceneId: 1, actId: 1 }, + { sceneId: 2, actId: 1 }, + ]; + const result = getConflictSeverity(1, 2, sceneGraph); + expect(result).toBe('WARNING'); + }); + + it('should return INFO for scenes in different acts', () => { + const sceneGraph = [ + { sceneId: 1, actId: 1 }, + { sceneId: 2, actId: 2 }, + ]; + const result = getConflictSeverity(1, 2, sceneGraph); + expect(result).toBe('INFO'); + }); + }); + + describe('detectMicConflicts', () => { + it('should return empty conflicts when allocations is null', () => { + const result = detectMicConflicts(null, [], [], {}, [], []); + expect(result).toEqual({ + conflicts: [], + conflictsByScene: {}, + conflictsByMic: {}, + }); + }); + + it('should return empty conflicts when no scenes', () => { + const allocations = { 1: { 1: 10 } }; + const result = detectMicConflicts(allocations, [], [], {}, [], []); + expect(result).toEqual({ + conflicts: [], + conflictsByScene: {}, + conflictsByMic: {}, + }); + }); + + it('should not detect conflict when same character in adjacent scenes', () => { + const allocations = { + 1: { // Mic 1 + 1: 10, // Scene 1 -> Character 10 + 2: 10, // Scene 2 -> Same character + }, + }; + const scenes = [ + { + id: 1, act: 1, name: 'Scene 1', next_scene: 2, + }, + { + id: 2, act: 1, name: 'Scene 2', next_scene: null, + }, + ]; + const acts = [ + { + id: 1, name: 'Act 1', first_scene: 1, next_act: null, + }, + ]; + const currentShow = { first_act_id: 1 }; + const characters = [ + { id: 10, name: 'Hamlet', cast_member: { id: 100 } }, + ]; + + const result = detectMicConflicts(allocations, scenes, acts, currentShow, characters, []); + + expect(result.conflicts).toHaveLength(0); + }); + + it('should not detect conflict when same cast member plays different characters', () => { + const allocations = { + 1: { // Mic 1 + 1: 10, // Scene 1 -> Hamlet + 2: 11, // Scene 2 -> Ghost (same actor) + }, + }; + const scenes = [ + { + id: 1, act: 1, name: 'Scene 1', next_scene: 2, + }, + { + id: 2, act: 1, name: 'Scene 2', next_scene: null, + }, + ]; + const acts = [ + { + id: 1, name: 'Act 1', first_scene: 1, next_act: null, + }, + ]; + const currentShow = { first_act_id: 1 }; + const characters = [ + { id: 10, name: 'Hamlet', cast_member: { id: 100 } }, + { id: 11, name: 'Ghost', cast_member: { id: 100 } }, // Same actor + ]; + + const result = detectMicConflicts(allocations, scenes, acts, currentShow, characters, []); + + expect(result.conflicts).toHaveLength(0); + }); + + it('should detect conflict when different cast members in same act', () => { + const allocations = { + 1: { // Mic 1 + 1: 10, // Scene 1 -> Hamlet + 2: 11, // Scene 2 -> Ophelia (different actor) + }, + }; + const scenes = [ + { + id: 1, act: 1, name: 'Scene 1', next_scene: 2, + }, + { + id: 2, act: 1, name: 'Scene 2', next_scene: null, + }, + ]; + const acts = [ + { + id: 1, name: 'Act 1', first_scene: 1, next_act: null, + }, + ]; + const currentShow = { first_act_id: 1 }; + const characters = [ + { id: 10, name: 'Hamlet', cast_member: { id: 100 } }, + { id: 11, name: 'Ophelia', cast_member: { id: 200 } }, // Different actor + ]; + + const result = detectMicConflicts(allocations, scenes, acts, currentShow, characters, []); + + expect(result.conflicts).toHaveLength(1); + expect(result.conflicts[0]).toMatchObject({ + micId: 1, + sceneId: 1, + sceneName: 'Scene 1', + characterId: 10, + characterName: 'Hamlet', + adjacentSceneId: 2, + adjacentSceneName: 'Scene 2', + adjacentCharacterId: 11, + adjacentCharacterName: 'Ophelia', + severity: 'WARNING', // Same act + }); + expect(result.conflicts[0].message).toContain('Tight changeover required'); + expect(result.conflicts[0].message).toContain('Quick-change from "Scene 1"'); + }); + + it('should detect conflict when different cast members across acts', () => { + const allocations = { + 1: { // Mic 1 + 1: 10, // Last scene of Act 1 -> Hamlet + 2: 11, // First scene of Act 2 -> Ophelia (different actor) + }, + }; + const scenes = [ + { + id: 1, act: 1, name: 'Act 1 Scene 1', next_scene: null, + }, + { + id: 2, act: 2, name: 'Act 2 Scene 1', next_scene: null, + }, + ]; + const acts = [ + { + id: 1, name: 'Act 1', first_scene: 1, next_act: 2, + }, + { + id: 2, name: 'Act 2', first_scene: 2, next_act: null, + }, + ]; + const currentShow = { first_act_id: 1 }; + const characters = [ + { id: 10, name: 'Hamlet', cast_member: { id: 100 } }, + { id: 11, name: 'Ophelia', cast_member: { id: 200 } }, // Different actor + ]; + + const result = detectMicConflicts(allocations, scenes, acts, currentShow, characters, []); + + expect(result.conflicts).toHaveLength(1); + expect(result.conflicts[0]).toMatchObject({ + micId: 1, + severity: 'INFO', // Cross-act + }); + expect(result.conflicts[0].message).toContain('Interval provides changeover time'); + }); + + it('should avoid duplicate conflicts', () => { + const allocations = { + 1: { // Mic 1 + 1: 10, // Scene 1 -> Hamlet + 2: 11, // Scene 2 -> Ophelia + 3: 10, // Scene 3 -> Hamlet again + }, + }; + const scenes = [ + { + id: 1, act: 1, name: 'Scene 1', next_scene: 2, + }, + { + id: 2, act: 1, name: 'Scene 2', next_scene: 3, + }, + { + id: 3, act: 1, name: 'Scene 3', next_scene: null, + }, + ]; + const acts = [ + { + id: 1, name: 'Act 1', first_scene: 1, next_act: null, + }, + ]; + const currentShow = { first_act_id: 1 }; + const characters = [ + { id: 10, name: 'Hamlet', cast_member: { id: 100 } }, + { id: 11, name: 'Ophelia', cast_member: { id: 200 } }, + ]; + + const result = detectMicConflicts(allocations, scenes, acts, currentShow, characters, []); + + // Should have 2 conflicts: 1→2 and 2→3, but not 2→1 (duplicate of 1→2) + expect(result.conflicts).toHaveLength(2); + }); + + it('should index conflicts by scene', () => { + const allocations = { + 1: { 1: 10, 2: 11 }, + }; + const scenes = [ + { + id: 1, act: 1, name: 'Scene 1', next_scene: 2, + }, + { + id: 2, act: 1, name: 'Scene 2', next_scene: null, + }, + ]; + const acts = [ + { + id: 1, name: 'Act 1', first_scene: 1, next_act: null, + }, + ]; + const currentShow = { first_act_id: 1 }; + const characters = [ + { id: 10, name: 'Hamlet', cast_member: { id: 100 } }, + { id: 11, name: 'Ophelia', cast_member: { id: 200 } }, + ]; + + const result = detectMicConflicts(allocations, scenes, acts, currentShow, characters, []); + + expect(result.conflictsByScene[1]).toHaveLength(1); + expect(result.conflictsByScene[1][0].sceneId).toBe(1); + }); + + it('should index conflicts by mic', () => { + const allocations = { + 1: { 1: 10, 2: 11 }, + }; + const scenes = [ + { + id: 1, act: 1, name: 'Scene 1', next_scene: 2, + }, + { + id: 2, act: 1, name: 'Scene 2', next_scene: null, + }, + ]; + const acts = [ + { + id: 1, name: 'Act 1', first_scene: 1, next_act: null, + }, + ]; + const currentShow = { first_act_id: 1 }; + const characters = [ + { id: 10, name: 'Hamlet', cast_member: { id: 100 } }, + { id: 11, name: 'Ophelia', cast_member: { id: 200 } }, + ]; + + const result = detectMicConflicts(allocations, scenes, acts, currentShow, characters, []); + + expect(result.conflictsByMic[1]).toHaveLength(1); + expect(result.conflictsByMic[1][0].micId).toBe(1); + }); + + it('should handle characters with no cast assignment conservatively', () => { + const allocations = { + 1: { 1: 10, 2: 11 }, + }; + const scenes = [ + { + id: 1, act: 1, name: 'Scene 1', next_scene: 2, + }, + { + id: 2, act: 1, name: 'Scene 2', next_scene: null, + }, + ]; + const acts = [ + { + id: 1, name: 'Act 1', first_scene: 1, next_act: null, + }, + ]; + const currentShow = { first_act_id: 1 }; + const characters = [ + { id: 10, name: 'Hamlet', cast_member: null }, // No cast + { id: 11, name: 'Ophelia', cast_member: { id: 200 } }, + ]; + + const result = detectMicConflicts(allocations, scenes, acts, currentShow, characters, []); + + // Should detect conflict because no cast assignment = conservative approach + expect(result.conflicts).toHaveLength(1); + }); + }); +}); diff --git a/client/src/router/index.js b/client/src/router/index.js index cc6f98b1..60afee7b 100644 --- a/client/src/router/index.js +++ b/client/src/router/index.js @@ -209,6 +209,13 @@ router.beforeEach(async (to, from, next) => { return showAllowed || scriptAllowed || cueTypesAllowed; }; + // Check if we are navigating to the login page while already authenticated + // If so, redirect to where the user just was + if (to.path === '/login' && isAuthenticated) { + Vue.$toast.info('You are already logged in'); + return next(from.fullPath); + } + // Check authentication requirements if (requiresAuth && !isAuthenticated) { Vue.$toast.error('Please log in to access this page'); diff --git a/client/src/store/modules/script.js b/client/src/store/modules/script.js index 7dccacb3..8e452fec 100644 --- a/client/src/store/modules/script.js +++ b/client/src/store/modules/script.js @@ -44,12 +44,26 @@ export default { } }, async ADD_SCRIPT_REVISION(context, scriptRevision) { + const payload = { + description: scriptRevision.description, + }; + + // Add optional parent_revision_id if provided + if (scriptRevision.parent_revision_id != null) { + payload.parent_revision_id = scriptRevision.parent_revision_id; + } + + // Add optional set_as_current if provided (defaults to true on backend) + if (scriptRevision.set_as_current != null) { + payload.set_as_current = scriptRevision.set_as_current; + } + const response = await fetch(`${makeURL('/api/v1/show/script/revisions')}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(scriptRevision), + body: JSON.stringify(payload), }); if (response.ok) { context.dispatch('GET_SCRIPT_REVISIONS'); diff --git a/client/src/store/modules/show.js b/client/src/store/modules/show.js index 4f50eca7..acbda485 100644 --- a/client/src/store/modules/show.js +++ b/client/src/store/modules/show.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import log from 'loglevel'; import { makeURL } from '@/js/utils'; +import { buildSceneGraph, detectMicConflicts } from '@/js/micConflictUtils'; export default { state: { @@ -660,6 +661,67 @@ export default { MIC_ALLOCATIONS(state) { return state.micAllocations; }, + ORDERED_SCENES(state, getters) { + if (!getters.CURRENT_SHOW?.first_act_id || !getters.SCENE_LIST?.length || !getters.ACT_LIST?.length) { + return []; + } + + const scenes = []; + let currentAct = getters.ACT_BY_ID(getters.CURRENT_SHOW.first_act_id); + + while (currentAct != null) { + let currentScene = getters.SCENE_BY_ID(currentAct.first_scene); + while (currentScene != null) { + scenes.push(currentScene); + currentScene = getters.SCENE_BY_ID(currentScene.next_scene); + } + currentAct = getters.ACT_BY_ID(currentAct.next_act); + } + + return scenes; + }, + MIC_CONFLICTS(state, getters) { + // Transform MIC_ALLOCATIONS from array format to object format + const allocationsObj = {}; + Object.keys(getters.MIC_ALLOCATIONS).forEach((micId) => { + const allocs = getters.MIC_ALLOCATIONS[micId]; + const sceneData = {}; + if (Array.isArray(allocs)) { + allocs.forEach((alloc) => { + sceneData[alloc.scene_id] = alloc.character_id; + }); + } + allocationsObj[micId] = sceneData; + }); + + return detectMicConflicts( + allocationsObj, + getters.SCENE_LIST, + getters.ACT_LIST, + getters.CURRENT_SHOW, + getters.CHARACTER_LIST, + getters.CAST_LIST, + ); + }, + CONFLICTS_BY_SCENE(state, getters) { + return getters.MIC_CONFLICTS.conflictsByScene || {}; + }, + CONFLICTS_BY_MIC(state, getters) { + return getters.MIC_CONFLICTS.conflictsByMic || {}; + }, + MIC_TIMELINE_DATA(state, getters) { + const scenes = getters.ORDERED_SCENES; + const allocations = getters.MIC_ALLOCATIONS; + const conflicts = getters.MIC_CONFLICTS.conflicts || []; + + return { + scenes, + allocations, + conflicts, + microphones: getters.MICROPHONES, + characters: getters.CHARACTER_LIST, + }; + }, NO_LEADER_TOAST(state) { return state.noLeaderToast; }, diff --git a/client/src/views/show/config/ConfigMics.vue b/client/src/views/show/config/ConfigMics.vue index cd16129e..92c79e9b 100644 --- a/client/src/views/show/config/ConfigMics.vue +++ b/client/src/views/show/config/ConfigMics.vue @@ -17,6 +17,21 @@ > + + + + + + + + + @@ -37,10 +52,15 @@ import { mapActions } from 'vuex'; import MicList from '@/vue_components/show/config/mics/MicList.vue'; import MicAllocations from '@/vue_components/show/config/mics/MicAllocations.vue'; +import MicTimeline from '@/vue_components/show/config/mics/MicTimeline.vue'; +import SceneDensityHeatmap from '@/vue_components/show/config/mics/SceneDensityHeatmap.vue'; +import ResourceAvailability from '@/vue_components/show/config/mics/ResourceAvailability.vue'; export default { name: 'ConfigMics', - components: { MicAllocations, MicList }, + components: { + MicAllocations, MicList, MicTimeline, SceneDensityHeatmap, ResourceAvailability, + }, data() { return { loaded: false, @@ -50,13 +70,14 @@ export default { await this.GET_SCENE_LIST(); await this.GET_ACT_LIST(); await this.GET_CHARACTER_LIST(); + await this.GET_CAST_LIST(); await this.GET_MICROPHONE_LIST(); await this.GET_MIC_ALLOCATIONS(); this.loaded = true; }, methods: { ...mapActions(['GET_SCENE_LIST', 'GET_ACT_LIST', 'GET_CHARACTER_LIST', - 'GET_MICROPHONE_LIST', 'GET_MIC_ALLOCATIONS']), + 'GET_CAST_LIST', 'GET_MICROPHONE_LIST', 'GET_MIC_ALLOCATIONS']), }, }; diff --git a/client/src/views/show/config/ConfigScript.vue b/client/src/views/show/config/ConfigScript.vue index 96da24e1..eb3cdac5 100644 --- a/client/src/views/show/config/ConfigScript.vue +++ b/client/src/views/show/config/ConfigScript.vue @@ -10,6 +10,41 @@ title="Revisions" active > + + + + + + + + + + + + + + + +

+ This will create a new revision based on revision {{ branchFormState.sourceRevision }} + (current revision) and set it as the new current revision. +

+

+ This will create a new branch from revision {{ branchFormState.sourceRevision }}. + The new revision will NOT be set as current. +

+
+ + + + + This is a required field. + + + +
@@ -143,10 +241,17 @@ import { required } from 'vuelidate/lib/validators'; import log from 'loglevel'; import ScriptConfig from '@/vue_components/show/config/script/ScriptEditor.vue'; import StageDirectionStyles from '@/vue_components/show/config/script/StageDirectionStyles.vue'; +import RevisionGraph from '@/vue_components/show/config/script/RevisionGraph.vue'; +import RevisionDetailModal from '@/vue_components/show/config/script/RevisionDetailModal.vue'; export default { name: 'ConfigScript', - components: { ScriptConfig, StageDirectionConfigs: StageDirectionStyles }, + components: { + ScriptConfig, + StageDirectionConfigs: StageDirectionStyles, + RevisionGraph, + RevisionDetailModal, + }, data() { return { revisionColumns: [ @@ -161,9 +266,21 @@ export default { newRevFormState: { description: '', }, + branchFormState: { + description: '', + sourceRevisionId: null, + sourceRevision: null, + isCurrentRevision: false, + }, submittingNewRevision: false, + submittingBranch: false, submittingLoadRevision: false, deletingRevision: false, + // Graph state + graphCollapsed: this.getGraphCollapseState(), + selectedRevisionId: null, + selectedRevision: null, + modalSubmitting: false, }; }, validations: { @@ -172,6 +289,11 @@ export default { required, }, }, + branchFormState: { + description: { + required, + }, + }, }, computed: { ...mapGetters(['SCRIPT_REVISIONS', 'CURRENT_REVISION', 'CURRENT_EDITOR', 'INTERNAL_UUID', 'IS_SCRIPT_EDITOR']), @@ -179,6 +301,11 @@ export default { return this.CURRENT_EDITOR == null || this.CURRENT_EDITOR === this.INTERNAL_UUID; }, }, + watch: { + graphCollapsed(newVal) { + localStorage.setItem('revisionGraphCollapsed', JSON.stringify(newVal)); + }, + }, async beforeMount() { await this.GET_SCRIPT_CONFIG_STATUS(); }, @@ -261,8 +388,108 @@ export default { } } }, + // Graph interaction handlers + getGraphCollapseState() { + const saved = localStorage.getItem('revisionGraphCollapsed'); + return saved !== null ? JSON.parse(saved) : false; + }, + handleNodeClick(revision) { + this.selectedRevisionId = revision.id; + this.selectedRevision = revision; + this.$bvModal.show('revision-detail'); + }, + async handleModalLoadRevision(revision) { + const msg = `Are you sure you want to load revision ${revision.revision}?`; + const action = await this.$bvModal.msgBoxConfirm(msg, {}); + if (action === true) { + this.modalSubmitting = 'load'; + try { + await this.LOAD_SCRIPT_REVISION(revision.id); + this.$bvModal.hide('revision-detail'); + } catch (error) { + log.error('Error loading revision:', error); + } finally { + this.modalSubmitting = false; + } + } + }, + handleModalCreateFrom(revision) { + // Set up the branch form state with the source revision info + this.branchFormState.sourceRevisionId = revision.id; + this.branchFormState.sourceRevision = revision.revision; + this.branchFormState.isCurrentRevision = revision.id === this.CURRENT_REVISION; + + // Show the branch creation modal + this.$bvModal.show('create-branch-modal'); + }, + setupBranchForm() { + // Reset only the description field, preserving the source revision info + // that was set in handleModalCreateFrom + this.branchFormState.description = ''; + this.submittingBranch = false; + + this.$nextTick(() => { + this.$v.$reset(); + }); + }, + resetBranchForm() { + this.branchFormState = { + description: '', + sourceRevisionId: null, + sourceRevision: null, + isCurrentRevision: false, + }; + this.submittingBranch = false; + + this.$nextTick(() => { + this.$v.$reset(); + }); + }, + validateBranchState(name) { + const { $dirty, $error } = this.$v.branchFormState[name]; + return $dirty ? !$error : null; + }, + async onSubmitBranch(event) { + this.$v.branchFormState.$touch(); + if (this.$v.branchFormState.$anyError || this.submittingBranch) { + event.preventDefault(); + return; + } + + this.submittingBranch = true; + try { + await this.ADD_SCRIPT_REVISION({ + description: this.branchFormState.description, + parent_revision_id: this.branchFormState.sourceRevisionId, + set_as_current: this.branchFormState.isCurrentRevision, + }); + this.$bvModal.hide('create-branch-modal'); + this.$bvModal.hide('revision-detail'); + this.resetBranchForm(); + } catch (error) { + log.error('Error creating branch:', error); + event.preventDefault(); + } finally { + this.submittingBranch = false; + } + }, + handleModalClose() { + this.$bvModal.hide('revision-detail'); + }, + handleModalHidden() { + this.selectedRevisionId = null; + this.selectedRevision = null; + this.modalSubmitting = false; + }, ...mapActions(['GET_SCRIPT_REVISIONS', 'ADD_SCRIPT_REVISION', 'LOAD_SCRIPT_REVISION', 'DELETE_SCRIPT_REVISION', 'GET_SCRIPT_CONFIG_STATUS']), }, }; + + diff --git a/client/src/vue_components/config/ConfigSettings.vue b/client/src/vue_components/config/ConfigSettings.vue index 0bba08d1..d40da65a 100644 --- a/client/src/vue_components/config/ConfigSettings.vue +++ b/client/src/vue_components/config/ConfigSettings.vue @@ -9,11 +9,11 @@
{ + if (!this.RAW_SETTINGS[x].hide_from_ui) { + visibleSettings[x] = this.RAW_SETTINGS[x]; + } + }); + return visibleSettings; + }, }, watch: { RAW_SETTINGS() { - this.resetForm(); + this.loaded = false; + this.resetForm(false); + this.resetEditSettings(); + this.loaded = true; }, }, mounted() { - Object.keys(this.RAW_SETTINGS).forEach(function setEditSettings(x) { - this.editSettings[x] = this.RAW_SETTINGS[x].value; - }, this); + this.resetEditSettings(); this.loaded = true; }, validations() { @@ -165,13 +175,22 @@ export default { this.$toast.success('Saved settings'); } }, - resetForm() { - this.loaded = false; + resetEditSettings() { + Object.keys(this.visibleSettings).forEach(function setEditSettings(x) { + this.editSettings[x] = this.visibleSettings[x].value; + }, this); + }, + resetForm(toggleLoaded) { + if (toggleLoaded) { + this.loaded = false; + } this.toggle = !this.toggle; Object.keys(this.RAW_SETTINGS).forEach(function resetEditSettings(x) { this.editSettings[x] = this.RAW_SETTINGS[x].value; }, this); - this.loaded = true; + if (toggleLoaded) { + this.loaded = true; + } }, }, }; diff --git a/client/src/vue_components/show/config/mics/MicAllocations.vue b/client/src/vue_components/show/config/mics/MicAllocations.vue index 783b64ea..46f6cd31 100644 --- a/client/src/vue_components/show/config/mics/MicAllocations.vue +++ b/client/src/vue_components/show/config/mics/MicAllocations.vue @@ -6,6 +6,7 @@ + + + Auto-Allocate + + + + + Reset Current + + + Reset All + + + + Clear Current + + + Clear All + + + + Save + - - Save - @@ -109,12 +158,25 @@ @@ -128,9 +190,11 @@ - diff --git a/client/src/vue_components/show/config/mics/MicAutoPopulateModal.vue b/client/src/vue_components/show/config/mics/MicAutoPopulateModal.vue new file mode 100644 index 00000000..119776c7 --- /dev/null +++ b/client/src/vue_components/show/config/mics/MicAutoPopulateModal.vue @@ -0,0 +1,356 @@ + + + + + diff --git a/client/src/vue_components/show/config/mics/MicTimeline.vue b/client/src/vue_components/show/config/mics/MicTimeline.vue new file mode 100644 index 00000000..b3385ac4 --- /dev/null +++ b/client/src/vue_components/show/config/mics/MicTimeline.vue @@ -0,0 +1,827 @@ + + + + + diff --git a/client/src/vue_components/show/config/mics/ResourceAvailability.vue b/client/src/vue_components/show/config/mics/ResourceAvailability.vue new file mode 100644 index 00000000..868a6bfa --- /dev/null +++ b/client/src/vue_components/show/config/mics/ResourceAvailability.vue @@ -0,0 +1,513 @@ + + + + + diff --git a/client/src/vue_components/show/config/mics/SceneDensityHeatmap.vue b/client/src/vue_components/show/config/mics/SceneDensityHeatmap.vue new file mode 100644 index 00000000..8cdd32e1 --- /dev/null +++ b/client/src/vue_components/show/config/mics/SceneDensityHeatmap.vue @@ -0,0 +1,407 @@ + + + + + diff --git a/client/src/vue_components/show/config/script/RevisionDetailModal.vue b/client/src/vue_components/show/config/script/RevisionDetailModal.vue new file mode 100644 index 00000000..78f8696b --- /dev/null +++ b/client/src/vue_components/show/config/script/RevisionDetailModal.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/client/src/vue_components/show/config/script/RevisionGraph.vue b/client/src/vue_components/show/config/script/RevisionGraph.vue new file mode 100644 index 00000000..83a2796c --- /dev/null +++ b/client/src/vue_components/show/config/script/RevisionGraph.vue @@ -0,0 +1,531 @@ + + + + + diff --git a/client/vitest.config.js b/client/vitest.config.js new file mode 100644 index 00000000..221491e5 --- /dev/null +++ b/client/vitest.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; +import vue from '@vitejs/plugin-vue2'; +import path from 'path'; + +export default defineConfig({ + plugins: [vue()], + test: { + globals: true, + environment: 'jsdom', + reporters: ['default', 'junit'], + outputFile: { + junit: './junit/test-results.xml', + }, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/docs/images/config_show/mics_availability.png b/docs/images/config_show/mics_availability.png new file mode 100644 index 00000000..9db29b83 Binary files /dev/null and b/docs/images/config_show/mics_availability.png differ diff --git a/docs/images/config_show/mics_conflict_highlighting.png b/docs/images/config_show/mics_conflict_highlighting.png new file mode 100644 index 00000000..24349b68 Binary files /dev/null and b/docs/images/config_show/mics_conflict_highlighting.png differ diff --git a/docs/images/config_show/mics_density_heatmap.png b/docs/images/config_show/mics_density_heatmap.png new file mode 100644 index 00000000..2a9dc1fe Binary files /dev/null and b/docs/images/config_show/mics_density_heatmap.png differ diff --git a/docs/images/config_show/mics_timeline.png b/docs/images/config_show/mics_timeline.png new file mode 100644 index 00000000..5406b7d7 Binary files /dev/null and b/docs/images/config_show/mics_timeline.png differ diff --git a/docs/images/config_show/script_create_branch_modal.png b/docs/images/config_show/script_create_branch_modal.png new file mode 100644 index 00000000..a27288ea Binary files /dev/null and b/docs/images/config_show/script_create_branch_modal.png differ diff --git a/docs/images/config_show/script_revision_detail_modal.png b/docs/images/config_show/script_revision_detail_modal.png new file mode 100644 index 00000000..447eba9f Binary files /dev/null and b/docs/images/config_show/script_revision_detail_modal.png differ diff --git a/docs/images/config_show/script_revision_graph_branched.png b/docs/images/config_show/script_revision_graph_branched.png new file mode 100644 index 00000000..c104226b Binary files /dev/null and b/docs/images/config_show/script_revision_graph_branched.png differ diff --git a/docs/images/config_show/script_revision_graph_single.png b/docs/images/config_show/script_revision_graph_single.png new file mode 100644 index 00000000..ea9baa89 Binary files /dev/null and b/docs/images/config_show/script_revision_graph_single.png differ diff --git a/docs/pages/script_config.md b/docs/pages/script_config.md index 1883d2a4..1130d590 100644 --- a/docs/pages/script_config.md +++ b/docs/pages/script_config.md @@ -15,6 +15,49 @@ Script revisions function similar to version control systems like Git - they tra - Compare what has changed between revisions - Maintain different versions of the script for different performances +#### Revision Branch Graph + +The **Revision Branch Graph** provides a visual representation of your script's revision history, showing how revisions branch and evolve over time. + +![](../images/config_show/script_revision_graph_branched.png) + +The graph displays: +- **Blue nodes**: Regular revisions +- **Green nodes**: The current active revision (with animated pulse effect) +- **Lines with arrows**: Show the parent-child relationship between revisions + +##### Interacting with the Graph + +You can interact with the revision graph in several ways: + +**Clicking on Nodes**: Click any node in the graph to open a detailed modal showing: +- Revision metadata (number, description, dates) +- Previous (parent) revision +- Child revisions (branches created from this revision) +- Actions: Load This Revision, Create Branch From Here + +![](../images/config_show/script_revision_detail_modal.png) + +**Pan and Zoom**: Use your mouse or trackpad to pan around the graph and see all revisions. The zoom controls in the top-right corner allow you to: +- Zoom in (+) +- Zoom out (-) +- Reset zoom (↻) + +**Collapse the Graph**: Click the chevron icon in the graph header to collapse/expand the graph card, saving screen space when not needed. + +##### Creating Branches + +You can create alternative versions of your script by branching from any revision: + +1. Click on the revision you want to branch from +2. Click **Create Branch From Here** in the detail modal +3. Enter a description for the new branch +4. Click **OK** + +![](../images/config_show/script_create_branch_modal.png) + +**Important**: When creating a branch from the current revision, the new revision becomes the active revision. When branching from a non-current revision, the new branch is created as an alternative version without changing which revision is currently loaded. + #### Creating a New Revision Click **New Revision** to create a new revision. You'll need to provide a description for the revision to help identify it later. diff --git a/docs/pages/show_config/microphones.md b/docs/pages/show_config/microphones.md index 5699fa16..984452e9 100644 --- a/docs/pages/show_config/microphones.md +++ b/docs/pages/show_config/microphones.md @@ -37,8 +37,22 @@ To assign a microphone to a character, follow these steps: #### Allocation Constraints - You **cannot** allocate the same microphone to multiple characters in the same scene -- You **cannot** allocate multiple microphones to a single character for a given scene -- These constraints ensure practical microphone management during live shows + - This reflects the physical constraint that one microphone can only be worn by one person at a time + +#### Multiple Microphones Per Character + +DigiScript supports assigning multiple microphones to a single character in a scene. This is useful for: + +- **Primary + Backup Configuration**: Lead characters who aren't offstage long enough to swap out microphone packs can have both a primary and backup microphone assigned +- **Redundancy**: Ensuring continuity in case of technical failures during performance +- **Technical Flexibility**: Accommodating different microphone types or configurations for the same character + +To assign multiple microphones to a character: +1. Select the first microphone from the dropdown and allocate it to the character +2. Select the second microphone from the dropdown and allocate it to the same character +3. The character will now show both microphones in the view mode (e.g., "Mic 1, Mic 2") + +In the Timeline view, characters with multiple microphones will display stacked bars showing all assigned microphones with consistent color-coding. #### Saving Allocations @@ -47,3 +61,99 @@ After making your allocations, click the **Save** button to confirm your changes ![](../../images/config_show/mics_allocations_saved.png) The saved view provides a clear overview of microphone usage throughout the entire show, helping sound engineers plan microphone management and character coverage. + +#### Conflict Detection + +DigiScript automatically detects potential microphone conflicts when the same microphone is allocated to different characters in adjacent scenes. Conflicts are tracked individually per microphone - a character with multiple microphones may have conflicts on some mics but not others. Conflicts are highlighted in the allocations matrix to alert you to quick-changes that may require attention: + +![](../../images/config_show/mics_conflict_highlighting.png) + +- **Orange highlights** indicate conflicts within the same act (tight changeover required) +- **Blue highlights** indicate conflicts across act boundaries (interval provides changeover time) + +Hovering over a highlighted allocation shows: +- The full list of microphones assigned to that character in that scene +- Details about which specific microphones have conflicts +- Information about which scenes and characters are involved in each conflict + +When a character has multiple microphones, the tooltip will clearly indicate which microphone(s) have conflicts, allowing you to plan changeovers for specific microphones while others remain assigned. + +### Microphone Timeline View + +The **Timeline** tab provides a visual representation of microphone allocations across all scenes in the show. This view helps you understand allocation patterns and identify conflicts at a glance. + +![](../../images/config_show/mics_timeline.png) + +#### Timeline Features + +- **View Modes**: Switch between three different perspectives: + - **By Microphone**: Shows which characters use each microphone across scenes + - **By Character**: Shows which microphones each character uses across scenes + - **By Cast**: Shows microphone usage grouped by cast member + +- **Visual Layout**: The timeline uses color-coded bars to represent allocations: + - Each row represents a microphone, character, or cast member (depending on view mode) + - Each column represents a scene in the show + - Acts are labeled at the top for easy reference + - Colored bars show continuous allocations (same entity across multiple scenes) + +- **Export**: Click the download button to export the timeline as a PNG image for documentation or planning purposes + +#### Using the Timeline + +1. Select your preferred view mode using the buttons at the top +2. Scroll horizontally to see all scenes in large shows +3. Click on allocation bars to see detailed information +4. Colors are automatically assigned to distinguish different entities + +### Scene Density Heatmap + +The **Density** tab visualizes technical complexity by showing how many microphones are active in each scene. This helps identify technically demanding scenes that may require additional crew or planning. + +![](../../images/config_show/mics_density_heatmap.png) + +#### Heatmap Features + +- **Color Gradient**: Scenes are color-coded from blue (low mic count) to red (high mic count) +- **Act Grouping**: Scenes are organized by act for easy reference +- **Scene Statistics**: Displays total scenes, average mics per scene, peak usage, and total active mics +- **Interactive Bars**: Click on scene bars to see detailed information + +#### Interpreting the Heatmap + +- Darker red scenes require more microphones and may be more technically complex +- Blue scenes with fewer microphones may be simpler to manage +- Use peak usage information to ensure you have enough microphone inventory +- Identify scenes that may require additional sound crew attention + +### Resource Availability Dashboard + +The **Availability** tab provides a comprehensive overview of microphone resource planning across the entire show. This view helps with inventory management and resource allocation. + +![](../../images/config_show/mics_availability.png) + +#### Dashboard Features + +- **Summary Statistics**: + - Total microphones in inventory + - Peak simultaneous usage (maximum mics needed at once) + - Total conflicts detected across the show + - Average utilization rate (percentage of mic inventory in use) + +- **Scene-by-Scene Breakdown**: For each scene, see: + - Number of available microphones (not in use) + - Number of microphones in use + - Number of conflicts requiring attention + +- **Microphone Status Grid**: Color-coded cards show the status of each microphone in each scene: + - **Green**: Microphone is available (not allocated) + - **Blue**: Microphone is in use by a character + - **Red (pulsing)**: Microphone has a conflict with adjacent scenes + +#### Using the Availability Dashboard + +1. Review summary statistics to ensure sufficient microphone inventory +2. Check the utilization rate to identify over or under-provisioning +3. Scan the scene-by-scene breakdown for scenes with conflicts +4. Click on microphone cards to see detailed allocation information +5. Use conflict indicators to identify scenes requiring changeover planning diff --git a/server/alembic_config/versions/fa8ee07e45fc_fix_orphaned_script_revisions.py b/server/alembic_config/versions/fa8ee07e45fc_fix_orphaned_script_revisions.py new file mode 100644 index 00000000..40b01b8b --- /dev/null +++ b/server/alembic_config/versions/fa8ee07e45fc_fix_orphaned_script_revisions.py @@ -0,0 +1,81 @@ +"""fix_orphaned_script_revisions + +Revision ID: fa8ee07e45fc +Revises: 42d0eaa5d07e +Create Date: 2025-12-18 22:55:57.281038 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "fa8ee07e45fc" +down_revision: Union[str, None] = "42d0eaa5d07e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Fix orphaned script revisions by linking them to the initial revision.""" + conn = op.get_bind() + + # Find all orphaned revisions (NULL previous_revision_id and NOT revision 1) + orphaned_revisions = conn.execute( + sa.text(""" + SELECT id, script_id, revision + FROM script_revisions + WHERE previous_revision_id IS NULL + AND revision != 1 + """) + ).fetchall() + + # For each orphaned revision, find the initial revision (revision 1) for that script + for orphaned in orphaned_revisions: + orphaned_id, script_id, revision_number = orphaned + + # Find the initial revision (revision = 1) for this script + initial_revision = conn.execute( + sa.text(""" + SELECT id + FROM script_revisions + WHERE script_id = :script_id + AND revision = 1 + LIMIT 1 + """), + {"script_id": script_id}, + ).fetchone() + + if initial_revision: + # Update the orphaned revision to point to the initial revision + conn.execute( + sa.text(""" + UPDATE script_revisions + SET previous_revision_id = :initial_id + WHERE id = :orphaned_id + """), + {"initial_id": initial_revision[0], "orphaned_id": orphaned_id}, + ) + print( + f"Fixed orphaned revision {revision_number} (id: {orphaned_id}) " + f"for script {script_id}, linked to initial revision (id: {initial_revision[0]})" + ) + else: + print( + f"WARNING: Could not find initial revision for script {script_id}, " + f"orphaned revision {revision_number} (id: {orphaned_id}) remains unfixed" + ) + + +def downgrade() -> None: + """ + Downgrade is not implemented for this data migration. + + This migration fixes data integrity by linking orphaned revisions to their + script's initial revision. There is no meaningful way to reverse this + operation as we don't track which revisions were originally orphaned. + """ + pass diff --git a/server/controllers/api/show/microphones.py b/server/controllers/api/show/microphones.py index 12af7e21..22afa1bb 100644 --- a/server/controllers/api/show/microphones.py +++ b/server/controllers/api/show/microphones.py @@ -1,12 +1,19 @@ -from typing import List +from collections import defaultdict +from typing import Dict, List, Tuple from sqlalchemy import select from tornado import escape from models.mics import Microphone, MicrophoneAllocation -from models.show import Character, Scene, Show +from models.script import Script, ScriptRevision +from models.show import Act, Character, Scene, Show from rbac.role import Role from schemas.schemas import MicrophoneAllocationSchema, MicrophoneSchema +from utils.show.mic_assignment import ( + SceneMetadata, + collect_character_appearances, + find_best_mic, +) from utils.web.base_controller import BaseAPIController from utils.web.route import ApiRoute, ApiVersion from utils.web.web_decorators import no_live_session, requires_show @@ -263,3 +270,307 @@ async def patch(self): else: self.set_status(404) await self.finish({"message": "404 show not found"}) + + +@ApiRoute("show/microphones/suggest", ApiVersion.V1) +class MicrophoneAutoAssignmentController(BaseAPIController): + @requires_show + @no_live_session + async def post(self): + current_show = self.get_current_show() + show_id = current_show["id"] + + with self.make_session() as session: + show = session.get(Show, show_id) + if show: + self.requires_role(show, Role.WRITE) + data = escape.json_decode(self.request.body) + + if "excluded_mics" not in data: + self.set_status(400) + await self.finish({"message": "excluded_mics missing"}) + return + excluded_mics: List[int] = data["excluded_mics"] + + if "static_characters" not in data: + self.set_status(400) + await self.finish({"message": "static_characters missing"}) + return + static_characters: List[int] = data["static_characters"] + + if "gap_mode" not in data: + self.set_status(400) + await self.finish({"message": "gap_mode missing"}) + return + gap_mode: str = data["gap_mode"] + if gap_mode not in ["leave_gaps", "no_gaps"]: + self.set_status(400) + await self.finish({"message": "Invalid gap_mode value"}) + return + + # Get all scenes in the show, and construct a list of lists, where each inner list + # is a list of scenes in order which are not separated by an interval + ordered_acts: List[Act] = [] + iter_act: Act = show.first_act + if not iter_act: + self.set_status(400) + await self.finish({"message": "No acts in show"}) + return + while iter_act: + ordered_acts.append(iter_act) + iter_act = iter_act.next_act + + ordered_scenes: List[List[Scene]] = [] + current_scenes = [] + for act in ordered_acts: + iter_scene = act.first_scene + while iter_scene: + current_scenes.append(iter_scene) + iter_scene = iter_scene.next_scene + if act.interval_after: + ordered_scenes.append(current_scenes) + current_scenes = [] + if current_scenes: + ordered_scenes.append(current_scenes) + + if not ordered_scenes: + self.set_status(400) + await self.finish({"message": "No scenes in show"}) + return + + # Get available microphones + mics: List[Microphone] = session.scalars( + select(Microphone).where(Microphone.show_id == show.id) + ).all() + if not mics: + self.set_status(400) + await self.finish({"message": "No microphones available"}) + return + available_mic_ids = [mic.id for mic in mics] + + # Filter out excluded mics - this list is used in the allocation algorithm + allocatable_mic_ids = [ + mic_id + for mic_id in available_mic_ids + if mic_id not in excluded_mics + ] + if not allocatable_mic_ids: + self.set_status(400) + await self.finish( + {"message": "No microphones available for allocation"} + ) + return + + # Get script and current revision + script: Script = session.scalars( + select(Script).where(Script.show_id == show.id) + ).first() + if not script or not script.current_revision: + self.set_status(400) + await self.finish( + {"message": "No script or current revision available"} + ) + return + revision: ScriptRevision = session.get( + ScriptRevision, script.current_revision + ) + + # Load existing allocations + existing_allocations: List[MicrophoneAllocation] = session.scalars( + select(MicrophoneAllocation).where( + MicrophoneAllocation.mic_id.in_(available_mic_ids) + ) + ).all() + + # Collect character appearances (only unallocated pairs) + unallocated_appearances, character_total_lines = ( + collect_character_appearances( + session, revision, existing_allocations + ) + ) + + # Sort characters by total line count (descending) - high priority first + sorted_characters = sorted( + character_total_lines.keys(), + key=lambda char_id: character_total_lines[char_id], + reverse=True, + ) + + # Build comprehensive scene metadata (replaces all_scene_ids and scene_position_map) + scene_metadata: Dict[int, SceneMetadata] = {} + position = 0 + for group_idx, scene_group in enumerate(ordered_scenes): + for scene_idx, scene in enumerate(scene_group): + scene_metadata[scene.id] = SceneMetadata( + scene_id=scene.id, + group_idx=group_idx, + scene_idx=scene_idx, + position=position, + ) + position += 1 + + # Derive other structures from metadata + all_scene_ids = set(scene_metadata.keys()) + + # Initialize mic usage tracker with existing allocations + mic_usage_tracker: Dict[int, List[Tuple[int, int]]] = defaultdict(list) + for alloc in existing_allocations: + mic_usage_tracker[alloc.mic_id].append( + (alloc.scene_id, alloc.character_id) + ) + + new_allocations: List[Tuple[int, int, int]] = [] + hints = [] + + # Assign mics per static character allocation + static_mic_options = { + mic_id + for mic_id in allocatable_mic_ids + if mic_id not in mic_usage_tracker + } + static_sorted_characters = [ + char_id + for char_id in sorted_characters + if char_id in static_characters + ] + for character_id in static_sorted_characters: + # Check if character already has an allocation, skip and record hint if so + if any( + alloc.character_id == character_id + for alloc in existing_allocations + ): + hints.append( + { + "character_id": character_id, + "reason": "Character already has existing microphone allocation", + "type": "static", + } + ) + continue + + # Try get next available static mic, if there are not any left, skip and record hint + try: + next_mic = static_mic_options.pop() + except KeyError: + hints.append( + { + "character_id": character_id, + "reason": "No available microphone for static assignment", + "type": "static", + } + ) + else: + # Record new allocation for each scene + for scene_id in all_scene_ids: + new_allocations.append((next_mic, scene_id, character_id)) + mic_usage_tracker[next_mic].append((scene_id, character_id)) + + # Assign mics per character + for character_id in sorted_characters: + # Skip static characters since they are already processed + if character_id in static_sorted_characters: + continue + + # Get all scenes where this character needs a mic (sorted by scene order) + character_scenes = [ + (scene_id, line_count) + for ( + scene_id, + char_id, + ), line_count in unallocated_appearances.items() + if char_id == character_id + ] + # Sort by scene position (chronological order) + character_scenes.sort( + key=lambda x: scene_metadata[x[0]].position + if x[0] in scene_metadata + else 0 + ) + + # Assign mic for each scene + for scene_id, line_count in character_scenes: + best_mic = find_best_mic( + session, + character_id, + scene_id, + allocatable_mic_ids, + mic_usage_tracker, + existing_allocations, + new_allocations, + scene_metadata, + ) + + if best_mic: + # Record new allocation + new_allocations.append((best_mic, scene_id, character_id)) + mic_usage_tracker[best_mic].append((scene_id, character_id)) + else: + # No available mic - record hint + hints.append( + { + "character_id": character_id, + "scene_id": scene_id, + "reason": "No available microphone", + "type": "allocation", + } + ) + + # Build response in PATCH-compatible format + suggestions: Dict[int, Dict[int, int]] = {} + + # Include existing allocations in response (complete picture) + for alloc in existing_allocations: + if alloc.mic_id not in suggestions: + suggestions[alloc.mic_id] = {} + suggestions[alloc.mic_id][alloc.scene_id] = alloc.character_id + + # Add new suggestions + for mic_id, scene_id, character_id in new_allocations: + if mic_id not in suggestions: + suggestions[mic_id] = {} + suggestions[mic_id][scene_id] = character_id + + # Process gap mode if needed + if gap_mode == "no_gaps": + for mic_id, mic_allocations in suggestions.items(): + seen_scenes = [] + single_character = True + character_id = None + # For each scene, see whether the same character is assigned to the mic + for scene_id, char_id in mic_allocations.items(): + seen_scenes.append(scene_id) + if character_id is not None and char_id != character_id: + single_character = False + break + character_id = char_id + + # Only proceed if a single character is assigned to this mic + if not single_character: + continue + + # Fill in gaps for scenes where the character wasn't assigned a mic + gapped_scenes = all_scene_ids - set(seen_scenes) + if gapped_scenes: + hints.append( + { + "character_id": character_id, + "reason": "Filled gap in microphone assignment", + "type": "gap_fill", + "scenes": list(gapped_scenes), + } + ) + for scene_id in gapped_scenes: + mic_allocations[scene_id] = character_id + + # Return response + self.set_status(200) + await self.finish( + { + "allocations": suggestions, + "hints": hints, + } + ) + + else: + self.set_status(404) + await self.finish({"message": "404 show not found"}) diff --git a/server/controllers/api/show/script/revisions.py b/server/controllers/api/show/script/revisions.py index cd763be3..44c46aaa 100644 --- a/server/controllers/api/show/script/revisions.py +++ b/server/controllers/api/show/script/revisions.py @@ -73,18 +73,32 @@ async def post(self): return self.requires_role(script, Role.WRITE) - current_rev_id = script.current_revision - if not current_rev_id: + # Get parent revision ID (defaults to current revision) + parent_rev_id = data.get("parent_revision_id", None) + if parent_rev_id is None: + parent_rev_id = script.current_revision + + if not parent_rev_id: self.set_status(404) await self.finish({"message": "404 script revision not found"}) return - current_rev: ScriptRevision = session.get( - ScriptRevision, current_rev_id - ) - if not current_rev: + # Get set_as_current flag (defaults to True for backward compatibility) + set_as_current = data.get("set_as_current", True) + + # Validate parent revision exists + parent_rev: ScriptRevision = session.get(ScriptRevision, parent_rev_id) + if not parent_rev: self.set_status(404) - await self.finish({"message": "404 script revision not found"}) + await self.finish({"message": "404 parent revision not found"}) + return + + # Validate parent belongs to this script + if parent_rev.script_id != script.id: + self.set_status(400) + await self.finish( + {"message": "Parent revision belongs to different script"} + ) return max_rev = session.scalar( @@ -106,11 +120,13 @@ async def post(self): created_at=now_time, edited_at=now_time, description=description, - previous_revision_id=current_rev.id, + previous_revision_id=parent_rev.id, ) session.add(new_rev) session.flush() - for line_association in current_rev.line_associations: + + # Copy associations from parent revision + for line_association in parent_rev.line_associations: new_rev.line_associations.append( ScriptLineRevisionAssociation( revision_id=new_rev.id, @@ -119,7 +135,7 @@ async def post(self): previous_line_id=line_association.previous_line_id, ) ) - for cue_association in current_rev.cue_associations: + for cue_association in parent_rev.cue_associations: new_rev.cue_associations.append( CueAssociation( revision_id=new_rev.id, @@ -127,7 +143,7 @@ async def post(self): cue_id=cue_association.cue_id, ) ) - for cut_association in current_rev.line_part_cuts: + for cut_association in parent_rev.line_part_cuts: new_rev.line_part_cuts.append( ScriptCuts( revision_id=new_rev.id, @@ -135,7 +151,10 @@ async def post(self): ) ) - script.current_revision = new_rev.id + # Only set as current if requested + if set_as_current: + script.current_revision = new_rev.id + session.commit() # Create a new compiled version of the script @@ -211,6 +230,16 @@ async def delete(self): ).one() script.current_revision = first_rev.id + # Update children to point to deleted revision's parent + # This prevents breaking the tree when deleting middle nodes + children = session.scalars( + select(ScriptRevision).where( + ScriptRevision.previous_revision_id == rev.id + ) + ).all() + for child in children: + child.previous_revision_id = rev.previous_revision_id + session.delete(rev) # Delete the compiled script file if there is one diff --git a/server/digi_server/settings.py b/server/digi_server/settings.py index 0cf12cf1..5e3e50e1 100644 --- a/server/digi_server/settings.py +++ b/server/digi_server/settings.py @@ -2,7 +2,7 @@ import json import os -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict from tornado.locks import Lock @@ -25,6 +25,7 @@ def __init__( nullable=False, display_name: str = "", help_text: str = "", + hide_from_ui: bool = False, ): if val_type not in [str, bool, int]: raise RuntimeError( @@ -42,6 +43,7 @@ def __init__( self._loaded = False self.display_name = display_name self.help_text = help_text + self.hide_from_ui = hide_from_ui def set_to_default(self): self.value = self.default @@ -83,6 +85,7 @@ def as_json(self): "can_edit": self.can_edit, "display_name": self.display_name, "help_text": self.help_text, + "hide_from_ui": self.hide_from_ui, } @@ -108,7 +111,7 @@ def __init__(self, application: DigiScriptServer, settings_path=None): ) os.makedirs(os.path.dirname(self.settings_path)) - self.settings = {} + self.settings: Dict[str, SettingsObject] = {} db_default = f"sqlite:///{os.path.join(os.path.dirname(__file__), '../conf/digiscript.sqlite')}" self.define( @@ -118,7 +121,7 @@ def __init__(self, application: DigiScriptServer, settings_path=None): False, nullable=False, callback_fn=self._application.validate_has_admin, - display_name="Has Admin User", + hide_from_ui=True, ) self.define( "db_path", @@ -135,7 +138,7 @@ def __init__(self, application: DigiScriptServer, settings_path=None): False, nullable=True, callback_fn=self._application.show_changed, - display_name="Current Show ID", + hide_from_ui=True, ) self.define("debug_mode", bool, False, True, display_name="Enable Debug Mode") self.define( @@ -221,6 +224,7 @@ def define( nullable=False, display_name: str = "", help_text: str = "", + hide_from_ui: bool = False, ): self.settings[key] = SettingsObject( key, @@ -231,6 +235,7 @@ def define( nullable, display_name, help_text, + hide_from_ui, ) def file_deleted(self): diff --git a/server/pyproject.toml b/server/pyproject.toml index 7dfdb83e..d8eb4b45 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta" [project] name = "digiscript-server" -version = "0.20.1" +version = "0.20.2" description = "DigiScript server - Digital script management for theatrical shows" readme = "../README.md" requires-python = ">=3.13" @@ -63,11 +63,6 @@ markers = [ line-length = 88 target-version = "py313" -# Exclude alembic migrations and other generated files -extend-exclude = [ - "alembic_config/versions", -] - [tool.ruff.format] # Use black-compatible formatting quote-style = "double" diff --git a/server/test/controllers/api/show/script/test_revisions.py b/server/test/controllers/api/show/script/test_revisions.py index f66391a6..3b005509 100644 --- a/server/test/controllers/api/show/script/test_revisions.py +++ b/server/test/controllers/api/show/script/test_revisions.py @@ -674,3 +674,557 @@ def test_delete_revision_with_cues(self): self.assertEqual( 1, len(cues), "Cue should still exist (shared with revision 1)" ) + + +class TestScriptRevisionBranching(DigiScriptTestCase): + """Test branching functionality for script revisions (Issue #785). + + Tests the new parent_revision_id and set_as_current parameters that allow + creating revisions from any node in the revision tree, not just the current revision. + """ + + def setUp(self): + super().setUp() + with self._app.get_db().sessionmaker() as session: + from models.user import User + + user = User(username="admin", password="hashed", is_admin=True) + session.add(user) + session.flush() + self.user_id = user.id + + show = Show(name="Test Show") + session.add(show) + session.flush() + self.show_id = show.id + + script = Script(show_id=show.id) + session.add(script) + session.flush() + self.script_id = script.id + + # Create revision 1 + revision1 = ScriptRevision( + script_id=script.id, revision=1, description="Initial" + ) + session.add(revision1) + session.flush() + self.revision1_id = revision1.id + script.current_revision = revision1.id + + # Create revision 2 (child of revision 1) + revision2 = ScriptRevision( + script_id=script.id, + revision=2, + description="Second", + previous_revision_id=revision1.id, + ) + session.add(revision2) + session.flush() + self.revision2_id = revision2.id + + # Create revision 3 (child of revision 2, now current) + revision3 = ScriptRevision( + script_id=script.id, + revision=3, + description="Third", + previous_revision_id=revision2.id, + ) + session.add(revision3) + session.flush() + self.revision3_id = revision3.id + script.current_revision = revision3.id + + session.commit() + + self._app.digi_settings.settings["current_show"].set_value(self.show_id) + self.token = self._app.jwt_service.create_access_token( + data={"user_id": self.user_id} + ) + + def test_create_revision_from_current_revision_default_behavior(self): + """Test creating a revision without specifying parent (backward compatibility). + + When no parent_revision_id is provided, should default to current revision + and set_as_current should default to True. + """ + response = self.fetch( + "/api/v1/show/script/revisions", + method="POST", + body=tornado.escape.json_encode({"description": "Fourth revision"}), + headers={"Authorization": f"Bearer {self.token}"}, + ) + + self.assertEqual(200, response.code) + response_body = tornado.escape.json_decode(response.body) + new_revision_id = response_body["id"] + + with self._app.get_db().sessionmaker() as session: + new_rev = session.get(ScriptRevision, new_revision_id) + script = session.get(Script, self.script_id) + + # Should be child of revision 3 (current) + self.assertEqual(self.revision3_id, new_rev.previous_revision_id) + self.assertEqual(4, new_rev.revision) + # Should be set as current + self.assertEqual(new_revision_id, script.current_revision) + + def test_create_branch_from_non_current_revision(self): + """Test creating a branch from a non-current revision. + + When parent_revision_id points to a non-current revision and + set_as_current is False, should create a branch without changing current. + """ + response = self.fetch( + "/api/v1/show/script/revisions", + method="POST", + body=tornado.escape.json_encode( + { + "description": "Branch from revision 2", + "parent_revision_id": self.revision2_id, + "set_as_current": False, + } + ), + headers={"Authorization": f"Bearer {self.token}"}, + ) + + self.assertEqual(200, response.code) + response_body = tornado.escape.json_decode(response.body) + branch_revision_id = response_body["id"] + + with self._app.get_db().sessionmaker() as session: + branch_rev = session.get(ScriptRevision, branch_revision_id) + script = session.get(Script, self.script_id) + + # Should be child of revision 2 (not current revision 3) + self.assertEqual(self.revision2_id, branch_rev.previous_revision_id) + self.assertEqual(4, branch_rev.revision) + # Current revision should NOT change + self.assertEqual(self.revision3_id, script.current_revision) + + def test_create_branch_from_non_current_with_set_as_current_true(self): + """Test creating a branch from non-current revision and setting it as current. + + When parent_revision_id points to a non-current revision but + set_as_current is True, should create branch AND set it as current. + """ + response = self.fetch( + "/api/v1/show/script/revisions", + method="POST", + body=tornado.escape.json_encode( + { + "description": "Branch from revision 1, set as current", + "parent_revision_id": self.revision1_id, + "set_as_current": True, + } + ), + headers={"Authorization": f"Bearer {self.token}"}, + ) + + self.assertEqual(200, response.code) + response_body = tornado.escape.json_decode(response.body) + branch_revision_id = response_body["id"] + + with self._app.get_db().sessionmaker() as session: + branch_rev = session.get(ScriptRevision, branch_revision_id) + script = session.get(Script, self.script_id) + + # Should be child of revision 1 + self.assertEqual(self.revision1_id, branch_rev.previous_revision_id) + self.assertEqual(4, branch_rev.revision) + # Should be set as current + self.assertEqual(branch_revision_id, script.current_revision) + + def test_invalid_parent_revision_id(self): + """Test that providing an invalid parent_revision_id returns 404.""" + response = self.fetch( + "/api/v1/show/script/revisions", + method="POST", + body=tornado.escape.json_encode( + { + "description": "Branch from invalid parent", + "parent_revision_id": 99999, # Non-existent ID + } + ), + headers={"Authorization": f"Bearer {self.token}"}, + ) + + self.assertEqual(404, response.code) + response_body = tornado.escape.json_decode(response.body) + self.assertEqual("404 parent revision not found", response_body["message"]) + + def test_parent_revision_from_different_script(self): + """Test that parent_revision_id must belong to the same script.""" + # Create a second show with its own script and revision + with self._app.get_db().sessionmaker() as session: + show2 = Show(name="Second Show") + session.add(show2) + session.flush() + show2_id = show2.id + + script2 = Script(show_id=show2.id) + session.add(script2) + session.flush() + + revision_other = ScriptRevision( + script_id=script2.id, revision=1, description="Other script revision" + ) + session.add(revision_other) + session.flush() + other_revision_id = revision_other.id + script2.current_revision = other_revision_id + + session.commit() + + # Try to create a revision in script 1 with parent from script 2 + response = self.fetch( + "/api/v1/show/script/revisions", + method="POST", + body=tornado.escape.json_encode( + { + "description": "Invalid cross-script branch", + "parent_revision_id": other_revision_id, + } + ), + headers={"Authorization": f"Bearer {self.token}"}, + ) + + self.assertEqual(400, response.code) + response_body = tornado.escape.json_decode(response.body) + self.assertEqual( + "Parent revision belongs to different script", response_body["message"] + ) + + +class TestScriptRevisionBranchingWithLines(DigiScriptTestCase): + """Test branching with script lines to ensure associations are copied correctly. + + When branching from a non-current revision, the associations (lines, cues, cuts) + should be copied from the parent revision, not from the current revision. + """ + + def setUp(self): + super().setUp() + with self._app.get_db().sessionmaker() as session: + from models.user import User + + user = User(username="admin", password="hashed", is_admin=True) + session.add(user) + session.flush() + self.user_id = user.id + + show = Show(name="Test Show") + session.add(show) + session.flush() + self.show_id = show.id + + script = Script(show_id=show.id) + session.add(script) + session.flush() + self.script_id = script.id + + revision1 = ScriptRevision( + script_id=script.id, revision=1, description="Initial" + ) + session.add(revision1) + session.flush() + self.revision1_id = revision1.id + script.current_revision = revision1.id + + act = Act(show_id=show.id, name="Act 1") + session.add(act) + session.flush() + self.act_id = act.id + + scene = Scene(show_id=show.id, act_id=act.id, name="Scene 1") + session.add(scene) + session.flush() + self.scene_id = scene.id + + character = Character(show_id=show.id, name="Test Character") + session.add(character) + session.flush() + self.character_id = character.id + + session.commit() + + self._app.digi_settings.settings["current_show"].set_value(self.show_id) + self.token = self._app.jwt_service.create_access_token( + data={"user_id": self.user_id} + ) + + def test_branch_copies_associations_from_parent_not_current(self): + """Test that branching copies associations from parent, not current revision. + + Create revision 1 with 2 lines, then revision 2 with 3 lines (current). + Branch from revision 1 should copy 2 lines, not 3. + """ + # Create 2 lines in revision 1 + lines_rev1 = [ + { + "id": None, + "act_id": self.act_id, + "scene_id": self.scene_id, + "page": 1, + "stage_direction": False, + "line_parts": [ + { + "id": None, + "line_id": None, + "part_index": 0, + "character_id": self.character_id, + "character_group_id": None, + "line_text": f"Rev1 Line {i}", + } + ], + "stage_direction_style_id": None, + } + for i in range(1, 3) + ] + + response = self.fetch( + "/api/v1/show/script?page=1", + method="POST", + body=tornado.escape.json_encode(lines_rev1), + headers={"Authorization": f"Bearer {self.token}"}, + ) + self.assertEqual(200, response.code) + + # Create revision 2 (copies 2 lines from revision 1) + response = self.fetch( + "/api/v1/show/script/revisions", + method="POST", + body=tornado.escape.json_encode({"description": "Second revision"}), + headers={"Authorization": f"Bearer {self.token}"}, + ) + self.assertEqual(200, response.code) + revision2_id = tornado.escape.json_decode(response.body)["id"] + + # Add 1 more line to revision 2 (now current has 3 lines) + lines_rev2_additional = [ + { + "id": None, + "act_id": self.act_id, + "scene_id": self.scene_id, + "page": 1, + "stage_direction": False, + "line_parts": [ + { + "id": None, + "line_id": None, + "part_index": 0, + "character_id": self.character_id, + "character_group_id": None, + "line_text": "Rev2 Additional Line", + } + ], + "stage_direction_style_id": None, + } + ] + + response = self.fetch( + "/api/v1/show/script?page=1", + method="POST", + body=tornado.escape.json_encode(lines_rev2_additional), + headers={"Authorization": f"Bearer {self.token}"}, + ) + self.assertEqual(200, response.code) + + # Verify revision 2 now has 3 line associations + with self._app.get_db().sessionmaker() as session: + rev2_assocs = session.scalars( + select(ScriptLineRevisionAssociation).where( + ScriptLineRevisionAssociation.revision_id == revision2_id + ) + ).all() + self.assertEqual(3, len(rev2_assocs)) + + # Now branch from revision 1 (which has 2 lines) + response = self.fetch( + "/api/v1/show/script/revisions", + method="POST", + body=tornado.escape.json_encode( + { + "description": "Branch from revision 1", + "parent_revision_id": self.revision1_id, + "set_as_current": False, + } + ), + headers={"Authorization": f"Bearer {self.token}"}, + ) + self.assertEqual(200, response.code) + branch_revision_id = tornado.escape.json_decode(response.body)["id"] + + # Verify the branch has 2 line associations (from revision 1, not 3 from revision 2) + with self._app.get_db().sessionmaker() as session: + branch_assocs = session.scalars( + select(ScriptLineRevisionAssociation).where( + ScriptLineRevisionAssociation.revision_id == branch_revision_id + ) + ).all() + self.assertEqual( + 2, + len(branch_assocs), + "Branch should copy 2 lines from revision 1 parent, not 3 from current revision 2", + ) + + # Verify current revision is still revision 2 + script = session.get(Script, self.script_id) + self.assertEqual(revision2_id, script.current_revision) + + +class TestScriptRevisionDeletionTreeIntegrity(DigiScriptTestCase): + """Test that deleting middle nodes maintains tree integrity. + + When deleting a revision in the middle of a chain (A→B→C), the children + of the deleted revision should be updated to point to the deleted revision's + parent, maintaining a connected tree. + """ + + def setUp(self): + super().setUp() + with self._app.get_db().sessionmaker() as session: + from models.user import User + + user = User(username="admin", password="hashed", is_admin=True) + session.add(user) + session.flush() + self.user_id = user.id + + show = Show(name="Test Show") + session.add(show) + session.flush() + self.show_id = show.id + + script = Script(show_id=show.id) + session.add(script) + session.flush() + self.script_id = script.id + + # Create revision 1 (A) + revision1 = ScriptRevision( + script_id=script.id, revision=1, description="Revision A" + ) + session.add(revision1) + session.flush() + self.revision1_id = revision1.id + script.current_revision = revision1.id + + # Create revision 2 (B) - child of A + revision2 = ScriptRevision( + script_id=script.id, + revision=2, + description="Revision B", + previous_revision_id=revision1.id, + ) + session.add(revision2) + session.flush() + self.revision2_id = revision2.id + + # Create revision 3 (C) - child of B + revision3 = ScriptRevision( + script_id=script.id, + revision=3, + description="Revision C", + previous_revision_id=revision2.id, + ) + session.add(revision3) + session.flush() + self.revision3_id = revision3.id + + # Create revision 4 (D) - another child of B (to test multiple children) + revision4 = ScriptRevision( + script_id=script.id, + revision=4, + description="Revision D", + previous_revision_id=revision2.id, + ) + session.add(revision4) + session.flush() + self.revision4_id = revision4.id + + session.commit() + + self._app.digi_settings.settings["current_show"].set_value(self.show_id) + self.token = self._app.jwt_service.create_access_token( + data={"user_id": self.user_id} + ) + + def test_delete_middle_revision_updates_children(self): + """Test deleting B in chain A→B→C updates C to point to A. + + Tree before: A → B → C + └─→ D + + Tree after deleting B: A → C + └─→ D + """ + # Delete revision 2 (B) + response = self.fetch( + f"/api/v1/show/script/revisions?rev_id={self.revision2_id}", + method="DELETE", + headers={"Authorization": f"Bearer {self.token}"}, + ) + + self.assertEqual(200, response.code) + + # Verify the tree structure is maintained + with self._app.get_db().sessionmaker() as session: + # B should be deleted + deleted_rev = session.get(ScriptRevision, self.revision2_id) + self.assertIsNone(deleted_rev, "Revision B should be deleted") + + # C should now point to A (not orphaned) + revision_c = session.get(ScriptRevision, self.revision3_id) + self.assertEqual( + self.revision1_id, + revision_c.previous_revision_id, + "Revision C should now point to A as parent", + ) + + # D should also point to A (multiple children case) + revision_d = session.get(ScriptRevision, self.revision4_id) + self.assertEqual( + self.revision1_id, + revision_d.previous_revision_id, + "Revision D should now point to A as parent", + ) + + # Verify A still exists + revision_a = session.get(ScriptRevision, self.revision1_id) + self.assertIsNotNone(revision_a, "Revision A should still exist") + + def test_delete_middle_revision_in_longer_chain(self): + """Test deleting middle revision in a longer chain A→B→C→D→E. + + After deleting C, D should point to B. + """ + # Create revision 5 (E) - child of C + with self._app.get_db().sessionmaker() as session: + revision5 = ScriptRevision( + script_id=self.script_id, + revision=5, + description="Revision E", + previous_revision_id=self.revision3_id, # Child of C + ) + session.add(revision5) + session.commit() + revision5_id = revision5.id + + # Delete revision 3 (C) from chain A→B→C→E + response = self.fetch( + f"/api/v1/show/script/revisions?rev_id={self.revision3_id}", + method="DELETE", + headers={"Authorization": f"Bearer {self.token}"}, + ) + + self.assertEqual(200, response.code) + + # Verify E now points to B (C's parent) + with self._app.get_db().sessionmaker() as session: + revision_e = session.get(ScriptRevision, revision5_id) + self.assertEqual( + self.revision2_id, + revision_e.previous_revision_id, + "Revision E should now point to B after C is deleted", + ) diff --git a/server/test/utils/show/__init__.py b/server/test/utils/show/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/test/utils/show/test_mic_assignment.py b/server/test/utils/show/test_mic_assignment.py new file mode 100644 index 00000000..79697a5d --- /dev/null +++ b/server/test/utils/show/test_mic_assignment.py @@ -0,0 +1,587 @@ +""" +Unit tests for the auto mic assignment algorithm. + +Tests cover character priority ordering, distance-based costs, existing allocation +preservation, over-capacity handling, and edge cases. +""" + +import pytest +from unittest.mock import MagicMock +from collections import defaultdict + +from utils.show.mic_assignment import ( + SceneMetadata, + swap_cost, + calculate_swap_cost_with_cast, + collect_character_appearances, + find_best_mic, + _mic_already_used_in_scene, + _mic_manually_allocated_to_character, + _mic_used_by_character_in_new_allocations, +) + + +def make_scene_metadata(num_scenes: int, group_idx: int = 0): + """ + Helper function to create mock scene_metadata for tests. + + Creates a single scene group with sequential scene IDs starting from 1. + """ + metadata = {} + for i in range(num_scenes): + scene_id = i + 1 + metadata[scene_id] = SceneMetadata( + scene_id=scene_id, + group_idx=group_idx, + scene_idx=i, + position=i, + ) + return metadata + + +class TestSwapCost: + """Test the distance-based cost function.""" + + def test_same_scene_zero_cost(self): + """Same scene should have zero swap cost.""" + assert swap_cost(0, 0) == 0.0 + assert swap_cost(5, 5) == 0.0 + + def test_adjacent_scenes_high_cost(self): + """Adjacent scenes should have highest cost (100).""" + assert swap_cost(0, 1) == 100.0 + assert swap_cost(5, 6) == 100.0 + assert swap_cost(1, 0) == 100.0 + + def test_distant_scenes_low_cost(self): + """Distant scenes should have lower cost.""" + assert swap_cost(0, 2) == 50.0 + assert swap_cost(0, 5) == 20.0 + assert swap_cost(0, 10) == 10.0 + + def test_cost_symmetry(self): + """Cost should be symmetric (scene order doesn't matter).""" + assert swap_cost(1, 5) == swap_cost(5, 1) + assert swap_cost(0, 10) == swap_cost(10, 0) + + +class TestCollectCharacterAppearances: + """Test character appearance data collection.""" + + @pytest.fixture + def mock_session(self): + """Create a mock database session.""" + return MagicMock() + + @pytest.fixture + def mock_revision(self): + """Create a mock script revision with test data.""" + revision = MagicMock() + + # Create mock line associations + line1 = MagicMock() + line1.scene_id = 1 + line1.stage_direction = False + + line_part1 = MagicMock() + line_part1.character_id = 10 + line_part1.character_group_id = None + line_part1.line_part_cuts = None + + line1.line_parts = [line_part1] + + line_assoc1 = MagicMock() + line_assoc1.line = line1 + + # Line 2 - same character in different scene + line2 = MagicMock() + line2.scene_id = 2 + line2.stage_direction = False + + line_part2 = MagicMock() + line_part2.character_id = 10 + line_part2.character_group_id = None + line_part2.line_part_cuts = None + + line2.line_parts = [line_part2] + + line_assoc2 = MagicMock() + line_assoc2.line = line2 + + revision.line_associations = [line_assoc1, line_assoc2] + return revision + + def test_basic_character_counting(self, mock_session, mock_revision): + """Test basic line counting for characters.""" + existing_allocations = [] + + unallocated, totals = collect_character_appearances( + mock_session, mock_revision, existing_allocations + ) + + # Character 10 should have 2 lines total + assert totals[10] == 2 + # Should have entries for both scenes + assert (1, 10) in unallocated + assert (2, 10) in unallocated + assert unallocated[(1, 10)] == 1 + assert unallocated[(2, 10)] == 1 + + def test_excludes_existing_allocations(self, mock_session, mock_revision): + """Test that existing allocations are excluded.""" + # Create existing allocation for scene 1, character 10 + existing_alloc = MagicMock() + existing_alloc.scene_id = 1 + existing_alloc.character_id = 10 + existing_alloc.mic_id = 1 + + existing_allocations = [existing_alloc] + + unallocated, totals = collect_character_appearances( + mock_session, mock_revision, existing_allocations + ) + + # Scene 1 should be excluded + assert (1, 10) not in unallocated + # Scene 2 should still be there + assert (2, 10) in unallocated + # Total should only count scene 2 + assert totals[10] == 1 + + def test_stage_directions_ignored(self): + """Test that stage directions are not counted.""" + session = MagicMock() + revision = MagicMock() + + # Create stage direction line + line = MagicMock() + line.scene_id = 1 + line.stage_direction = True + + line_part = MagicMock() + line_part.character_id = 10 + line_part.character_group_id = None + line_part.line_part_cuts = None + + line.line_parts = [line_part] + + line_assoc = MagicMock() + line_assoc.line = line + + revision.line_associations = [line_assoc] + + unallocated, totals = collect_character_appearances(session, revision, []) + + # Stage direction should be ignored + assert 10 not in totals + assert (1, 10) not in unallocated + + def test_cut_lines_ignored(self): + """Test that cut lines are not counted.""" + session = MagicMock() + revision = MagicMock() + + # Create cut line + line = MagicMock() + line.scene_id = 1 + line.stage_direction = False + + line_part = MagicMock() + line_part.character_id = 10 + line_part.character_group_id = None + line_part.line_part_cuts = MagicMock() + + line.line_parts = [line_part] + + line_assoc = MagicMock() + line_assoc.line = line + + revision.line_associations = [line_assoc] + + unallocated, totals = collect_character_appearances(session, revision, []) + + # Cut line should be ignored + assert 10 not in totals + assert (1, 10) not in unallocated + + def test_character_groups_expanded(self): + """Test that character groups are expanded into individual characters.""" + session = MagicMock() + revision = MagicMock() + + # Create line with character group + line = MagicMock() + line.scene_id = 1 + line.stage_direction = False + + line_part = MagicMock() + line_part.character_id = None + line_part.character_group_id = 100 + line_part.line_part_cuts = None + + # Mock the character group with 2 members + char1 = MagicMock() + char1.id = 10 + char2 = MagicMock() + char2.id = 11 + + group = MagicMock() + group.characters = [char1, char2] + + session.get.return_value = group + + line.line_parts = [line_part] + + line_assoc = MagicMock() + line_assoc.line = line + + revision.line_associations = [line_assoc] + + unallocated, totals = collect_character_appearances(session, revision, []) + + # Both group members should be counted + assert totals[10] == 1 + assert totals[11] == 1 + assert (1, 10) in unallocated + assert (1, 11) in unallocated + + +class TestFindBestMic: + """Test optimal mic selection logic.""" + + def test_basic_mic_selection(self): + """Test basic mic selection when all mics available.""" + session = MagicMock() + available_mics = [1, 2, 3] + mic_usage_tracker = defaultdict(list) + existing_allocations = [] + new_allocations = [] + scene_metadata = make_scene_metadata(3) + + best_mic = find_best_mic( + session, + character_id=10, + scene_id=1, + available_mics=available_mics, + mic_usage_tracker=mic_usage_tracker, + existing_allocations=existing_allocations, + new_allocations=new_allocations, + scene_metadata=scene_metadata, + ) + + # Should return one of the available mics + assert best_mic in available_mics + + def test_manual_allocation_continuity_bonus(self): + """Test that manually allocated mics get strong preference.""" + session = MagicMock() + available_mics = [1, 2] + mic_usage_tracker = defaultdict(list) + scene_metadata = make_scene_metadata(3) + + # Character 10 already has Mic 1 manually allocated in scene 2 + existing_alloc = MagicMock() + existing_alloc.mic_id = 1 + existing_alloc.scene_id = 2 + existing_alloc.character_id = 10 + + existing_allocations = [existing_alloc] + new_allocations = [] + + best_mic = find_best_mic( + session, + character_id=10, + scene_id=3, + available_mics=available_mics, + mic_usage_tracker=mic_usage_tracker, + existing_allocations=existing_allocations, + new_allocations=new_allocations, + scene_metadata=scene_metadata, + ) + + # Should prefer Mic 1 due to manual allocation bonus (-100 points) + assert best_mic == 1 + + def test_auto_allocation_continuity_bonus(self): + """Test that auto-assigned mics get preference for continuity.""" + session = MagicMock() + available_mics = [1, 2] + mic_usage_tracker = defaultdict(list) + existing_allocations = [] + scene_metadata = make_scene_metadata(3) + + # Character 10 was already assigned Mic 1 in scene 1 by algorithm + new_allocations = [(1, 1, 10)] + + best_mic = find_best_mic( + session, + character_id=10, + scene_id=2, + available_mics=available_mics, + mic_usage_tracker=mic_usage_tracker, + existing_allocations=existing_allocations, + new_allocations=new_allocations, + scene_metadata=scene_metadata, + ) + + # Should prefer Mic 1 due to continuity bonus (-50 points) + assert best_mic == 1 + + def test_swap_cost_penalty(self): + """Test that swap costs influence mic selection.""" + session = MagicMock() + available_mics = [1, 2] + + # Mock characters with different cast members + char10 = MagicMock() + char10.played_by = 100 + + char20 = MagicMock() + char20.played_by = 200 # Different cast member + + session.get.side_effect = lambda model, id: char10 if id == 10 else char20 + + # Mic 1 was used by a different character in scene 1 (scene_idx 0) + # Current scene is scene 2 (scene_idx 1) - adjacent scenes + mic_usage_tracker = {1: [(1, 20)], 2: []} + existing_allocations = [] + new_allocations = [] + scene_metadata = make_scene_metadata(3) + + best_mic = find_best_mic( + session, + character_id=10, + scene_id=2, + available_mics=available_mics, + mic_usage_tracker=mic_usage_tracker, + existing_allocations=existing_allocations, + new_allocations=new_allocations, + scene_metadata=scene_metadata, + ) + + # Should prefer Mic 2 to avoid high swap cost (100) from adjacent scene + assert best_mic == 2 + + def test_no_available_mic(self): + """Test that None is returned when no mics available.""" + session = MagicMock() + available_mics = [1] + mic_usage_tracker = defaultdict(list) + scene_metadata = make_scene_metadata(5) + + # Mic 1 already used by someone else in this scene + existing_alloc = MagicMock() + existing_alloc.mic_id = 1 + existing_alloc.scene_id = 5 + existing_alloc.character_id = 20 + + existing_allocations = [existing_alloc] + new_allocations = [] + + best_mic = find_best_mic( + session, + character_id=10, + scene_id=5, + available_mics=available_mics, + mic_usage_tracker=mic_usage_tracker, + existing_allocations=existing_allocations, + new_allocations=new_allocations, + scene_metadata=scene_metadata, + ) + + # Should return None (no available mic) + assert best_mic is None + + +class TestHelperFunctions: + """Test helper functions for mic availability checks.""" + + def test_mic_already_used_in_scene_existing(self): + """Test detection of mic usage in existing allocations.""" + existing_alloc = MagicMock() + existing_alloc.mic_id = 1 + existing_alloc.scene_id = 5 + existing_alloc.character_id = 10 + + existing_allocations = [existing_alloc] + new_allocations = [] + + # Mic 1 is used in scene 5 + assert _mic_already_used_in_scene(1, 5, existing_allocations, new_allocations) + # Mic 1 is not used in scene 6 + assert not _mic_already_used_in_scene( + 1, 6, existing_allocations, new_allocations + ) + # Mic 2 is not used in scene 5 + assert not _mic_already_used_in_scene( + 2, 5, existing_allocations, new_allocations + ) + + def test_mic_already_used_in_scene_new(self): + """Test detection of mic usage in new allocations.""" + existing_allocations = [] + new_allocations = [(1, 5, 10)] + + # Mic 1 is used in scene 5 + assert _mic_already_used_in_scene(1, 5, existing_allocations, new_allocations) + # Mic 1 is not used in scene 6 + assert not _mic_already_used_in_scene( + 1, 6, existing_allocations, new_allocations + ) + + def test_mic_manually_allocated_to_character(self): + """Test detection of manual allocation to character.""" + existing_alloc = MagicMock() + existing_alloc.mic_id = 1 + existing_alloc.scene_id = 5 + existing_alloc.character_id = 10 + + existing_allocations = [existing_alloc] + + # Mic 1 is manually allocated to character 10 + assert _mic_manually_allocated_to_character(1, 10, existing_allocations) + # Mic 1 is not allocated to character 20 + assert not _mic_manually_allocated_to_character(1, 20, existing_allocations) + # Mic 2 is not allocated to anyone + assert not _mic_manually_allocated_to_character(2, 10, existing_allocations) + + def test_mic_used_by_character_in_new_allocations(self): + """Test detection of mic usage in new allocations.""" + new_allocations = [(1, 5, 10)] + + # Mic 1 is used by character 10 + assert _mic_used_by_character_in_new_allocations(1, 10, new_allocations) + # Mic 1 is not used by character 20 + assert not _mic_used_by_character_in_new_allocations(1, 20, new_allocations) + # Mic 2 is not used by anyone + assert not _mic_used_by_character_in_new_allocations(2, 10, new_allocations) + + +class TestCastMemberSwapCost: + """Test cast member awareness in swap cost calculations.""" + + def test_same_cast_member_zero_cost(self): + """Test that swapping between characters played by same cast member has zero cost.""" + session = MagicMock() + + # Create characters played by same cast member + char1 = MagicMock() + char1.played_by = 100 # Cast member ID 100 + + char2 = MagicMock() + char2.played_by = 100 # Same cast member + + # Mock session.get to return our characters + session.get.side_effect = lambda model, id: char1 if id == 10 else char2 + + # Adjacent scenes in same group + scene_metadata = make_scene_metadata(2) + + # Adjacent scenes would normally cost 100, but same cast member = 0 + cost = calculate_swap_cost_with_cast(session, 10, 20, 1, 2, scene_metadata) + assert cost == 0.0 + + def test_different_cast_members_distance_cost(self): + """Test that swapping between characters played by different cast members uses distance cost.""" + session = MagicMock() + + # Create characters played by different cast members + char1 = MagicMock() + char1.played_by = 100 + + char2 = MagicMock() + char2.played_by = 200 # Different cast member + + session.get.side_effect = lambda model, id: char1 if id == 10 else char2 + + # Create metadata for 6 scenes in same group + scene_metadata = make_scene_metadata(6) + + # Adjacent scenes should cost 100 (distance-based) + cost = calculate_swap_cost_with_cast(session, 10, 20, 1, 2, scene_metadata) + assert cost == 100.0 + + # Distant scenes should cost less (scene 1 to scene 6, distance 5) + cost = calculate_swap_cost_with_cast(session, 10, 20, 1, 6, scene_metadata) + assert cost == 20.0 + + def test_no_cast_assignment_distance_cost(self): + """Test that characters without cast assignments use distance cost.""" + session = MagicMock() + + # Create characters without cast assignments + char1 = MagicMock() + char1.played_by = None + + char2 = MagicMock() + char2.played_by = None + + session.get.side_effect = lambda model, id: char1 if id == 10 else char2 + + # Create metadata for 2 scenes + scene_metadata = make_scene_metadata(2) + + # Should fall back to distance-based cost + cost = calculate_swap_cost_with_cast(session, 10, 20, 1, 2, scene_metadata) + assert cost == 100.0 + + def test_one_cast_assignment_distance_cost(self): + """Test that if only one character has cast assignment, use distance cost.""" + session = MagicMock() + + # One has cast assignment, one doesn't + char1 = MagicMock() + char1.played_by = 100 + + char2 = MagicMock() + char2.played_by = None + + session.get.side_effect = lambda model, id: char1 if id == 10 else char2 + + # Create metadata for 2 scenes + scene_metadata = make_scene_metadata(2) + + # Should fall back to distance-based cost + cost = calculate_swap_cost_with_cast(session, 10, 20, 1, 2, scene_metadata) + assert cost == 100.0 + + def test_cast_aware_mic_selection(self): + """Test that cast member awareness influences mic selection.""" + session = MagicMock() + available_mics = [1, 2] + + # Character 10 and 20 are played by same cast member + char10 = MagicMock() + char10.played_by = 100 + + char20 = MagicMock() + char20.played_by = 100 + + session.get.side_effect = lambda model, id: char10 if id == 10 else char20 + + # Mic 1 was used by character 20 (same cast member as 10) in scene 1 + # Mic 2 is unused + mic_usage_tracker = { + 1: [(1, 20)], # Character 20 in scene 1 + 2: [], + } + existing_allocations = [] + new_allocations = [] + scene_metadata = make_scene_metadata(3) + + best_mic = find_best_mic( + session, + character_id=10, + scene_id=2, + available_mics=available_mics, + mic_usage_tracker=mic_usage_tracker, + existing_allocations=existing_allocations, + new_allocations=new_allocations, + scene_metadata=scene_metadata, + ) + + # Should prefer Mic 1 because same cast member = zero swap cost + # Mic 1 score: 0 (same cast member) + # Mic 2 score: 0 (unused) + # Both have same score, so either is acceptable + assert best_mic in [1, 2] diff --git a/server/test/utils/web/test_jwt.py b/server/test/utils/web/test_jwt.py index fef46df0..6d624485 100644 --- a/server/test/utils/web/test_jwt.py +++ b/server/test/utils/web/test_jwt.py @@ -1,25 +1,32 @@ from datetime import timedelta +from unittest import TestCase -from utils.web.jwt_service import JWTService +import pytest -jwt_service = JWTService(secret="test-secret") +from utils.web.jwt_service import JWTService -def test_encode(): - token = jwt_service.create_access_token({"user_id": 1}) - assert token is not None +class TestJWTService(TestCase): + def setUp(self): + self.jwt_service = JWTService(secret="test-secret") + def test_encode(self): + token = self.jwt_service.create_access_token({"user_id": 1}) + assert token is not None -def test_decode(): - data = {"user_id": 1} - token = jwt_service.create_access_token(data) - decoded = jwt_service.decode_access_token(token) - assert decoded["user_id"] == data["user_id"] + def test_decode(self): + data = {"user_id": 1} + token = self.jwt_service.create_access_token(data) + decoded = self.jwt_service.decode_access_token(token) + assert decoded["user_id"] == data["user_id"] + def test_expired_token(self): + token = self.jwt_service.create_access_token( + {"user_id": 1}, expires_delta=timedelta(minutes=-5) + ) + decoded = self.jwt_service.decode_access_token(token) + assert decoded is None -def test_expired_token(): - token = jwt_service.create_access_token( - {"user_id": 1}, expires_delta=timedelta(minutes=-5) - ) - decoded = jwt_service.decode_access_token(token) - assert decoded is None + def test_invalid_algorithm(self): + with pytest.raises(ValueError, match="Unsupported JWT algorithm"): + JWTService(secret="123", jwt_algorithm="unsupported-algo") diff --git a/server/test_requirements.txt b/server/test_requirements.txt index 1ba2ff7a..e11a65ba 100644 --- a/server/test_requirements.txt +++ b/server/test_requirements.txt @@ -1,3 +1,3 @@ pytest<9.1 pytest-asyncio>=1.3.0 -ruff==0.14.9 \ No newline at end of file +ruff==0.14.10 \ No newline at end of file diff --git a/server/utils/show/__init__.py b/server/utils/show/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/utils/show/mic_assignment.py b/server/utils/show/mic_assignment.py new file mode 100644 index 00000000..e8356b2f --- /dev/null +++ b/server/utils/show/mic_assignment.py @@ -0,0 +1,296 @@ +""" +Auto mic assignment algorithm. + +This module implements a character-first holistic approach to assigning microphones +to characters across scenes, minimizing the cost of mic swaps with a distance-based +cost function. +""" + +from collections import defaultdict +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple + +from sqlalchemy.orm import Session + +from models.mics import MicrophoneAllocation +from models.script import ScriptLine, ScriptRevision +from models.show import Character, CharacterGroup + + +@dataclass(frozen=True) +class SceneMetadata: + """Metadata about a scene's position and grouping.""" + + scene_id: int + group_idx: int + scene_idx: int + position: int + + +def swap_cost(scene_index_1: int, scene_index_2: int) -> float: + """ + Calculate the cost of swapping a mic between two scenes. + + Inverted distance cost: closer scenes = HIGHER cost. + Encourages mic sharing across non-adjacent scenes. + + :param scene_index_1: Index of first scene in the scene group + :param scene_index_2: Index of second scene in the scene group + :return: Cost value (0-100). Adjacent scenes (distance=1) cost 100.0, + distant scenes cost less (e.g., distance=5 costs 20.0). + """ + distance = abs(scene_index_1 - scene_index_2) + if distance == 0: + return 0.0 # Same scene = free + return 100.0 / distance + + +def calculate_swap_cost_with_cast( + session: Session, + character_id_1: int, + character_id_2: int, + scene_id_1: int, + scene_id_2: int, + scene_metadata: Dict[int, SceneMetadata], +) -> float: + """ + Calculate swap cost considering cast members and intervals. + + Returns zero cost if: + - Scenes are in different groups (separated by interval - actors can swap during break) + - Both characters are played by the same cast member (mic stays on same person) + + Otherwise, uses distance-based cost within the same scene group. + + :param session: SQLAlchemy session for querying cast assignments + :param character_id_1: First character ID + :param character_id_2: Second character ID + :param scene_id_1: Scene ID for first character + :param scene_id_2: Scene ID for second character + :param scene_metadata: Dict mapping scene_id to SceneMetadata + :return: Swap cost (0 if different groups/same cast, distance-based otherwise) + """ + # Direct O(1) lookup instead of O(G×S) nested iteration + meta_1 = scene_metadata.get(scene_id_1) + meta_2 = scene_metadata.get(scene_id_2) + + # If scenes not found, return high penalty + if meta_1 is None or meta_2 is None: + return 100.0 + + # Different groups (separated by interval) = zero cost + if meta_1.group_idx != meta_2.group_idx: + return 0.0 + + # Same group - check if same cast member + char1: Character = session.get(Character, character_id_1) + char2: Character = session.get(Character, character_id_2) + + if char1 and char2 and char1.played_by and char2.played_by: + if char1.played_by == char2.played_by: + # Same cast member = zero cost (mic stays on same person) + return 0.0 + + # Different cast members in same group = distance-based cost + return swap_cost(meta_1.scene_idx, meta_2.scene_idx) + + +def collect_character_appearances( + session: Session, + revision: ScriptRevision, + existing_allocations: List[MicrophoneAllocation], +) -> Tuple[Dict[Tuple[int, int], int], Dict[int, int]]: + """ + Query database to build character appearance data for unallocated (character, scene) pairs. + + This function examines all script lines in each scene, counts non-cut lines per character, + and excludes any pairs that already have manual allocations. + + :param session: SQLAlchemy session + :param revision: Current script revision to check for cuts + :param existing_allocations: List of existing MicrophoneAllocation objects + :return: Tuple of (unallocated_appearances, character_total_lines) where unallocated_appearances maps (scene_id, character_id) to line_count and character_total_lines maps character_id to total_line_count + """ + unallocated_appearances: Dict[Tuple[int, int], int] = {} + character_total_lines: Dict[int, int] = defaultdict(int) + + # Build set of already-allocated (scene, character) pairs for quick lookup + allocated_pairs = { + (alloc.scene_id, alloc.character_id) for alloc in existing_allocations + } + + # Process all lines in the revision + for line_assoc in revision.line_associations: + line: ScriptLine = line_assoc.line + + # Skip stage directions + if line.stage_direction: + continue + + # Count lines per character + for line_part in line.line_parts: + # Skip cut lines + if line_part.line_part_cuts is not None: + continue + + # Get character IDs (expand groups) + character_ids = [] + if line_part.character_id: + character_ids.append(line_part.character_id) + elif line_part.character_group_id: + group: CharacterGroup = session.get( + CharacterGroup, line_part.character_group_id + ) + if group: + character_ids.extend([char.id for char in group.characters]) + + # Count lines for each character + for character_id in character_ids: + # Skip if this (scene, character) pair already has a manual allocation + if (line.scene_id, character_id) in allocated_pairs: + continue + + # Track this appearance + key = (line.scene_id, character_id) + unallocated_appearances[key] = unallocated_appearances.get(key, 0) + 1 + + # Track total lines per character + character_total_lines[character_id] += 1 + + return unallocated_appearances, dict(character_total_lines) + + +def find_best_mic( + session: Session, + character_id: int, + scene_id: int, + available_mics: List[int], + mic_usage_tracker: Dict[int, List[Tuple[int, int]]], + existing_allocations: List[MicrophoneAllocation], + new_allocations: List[Tuple[int, int, int]], + scene_metadata: Dict[int, SceneMetadata], +) -> Optional[int]: + """ + Find the best microphone for a character in a specific scene. + + Uses scoring criteria to select the optimal mic, preferring continuity and + minimizing swap costs. Considers cast member assignments and intervals to + eliminate swap costs when the same person plays multiple characters or when + scenes are separated by intervals. + + :param session: SQLAlchemy session for querying cast assignments + :param character_id: ID of the character needing a mic + :param scene_id: ID of the scene + :param available_mics: List of available microphone IDs + :param mic_usage_tracker: Dict mapping mic_id to list of (scene_id, character_id) tuples + :param existing_allocations: List of existing manual allocations + :param new_allocations: List of (mic_id, scene_id, character_id) tuples for new allocations + :param scene_metadata: Dict mapping scene_id to SceneMetadata + :return: Best microphone ID, or None if no mic available + """ + best_mic: Optional[int] = None + best_score = float("inf") + + for mic_id in available_mics: + # Skip if mic already assigned to someone else in THIS scene + if _mic_already_used_in_scene( + mic_id, scene_id, existing_allocations, new_allocations + ): + continue + + # Calculate score for this mic + score = 0.0 + + # STRONG BONUS: Existing manual allocation uses this mic for this character + if _mic_manually_allocated_to_character( + mic_id, character_id, existing_allocations + ): + score -= 100.0 + + # BONUS: Algorithm already assigned this mic to this character in earlier scene + if _mic_used_by_character_in_new_allocations( + mic_id, character_id, new_allocations + ): + score -= 50.0 + + # PENALTY: Swap costs from existing and new allocations (considering cast assignments) + if mic_id in mic_usage_tracker: + for prev_scene_id, prev_char_id in mic_usage_tracker[mic_id]: + if prev_char_id != character_id: + # Different character used this mic - calculate swap cost with cast and interval awareness + score += calculate_swap_cost_with_cast( + session, + prev_char_id, + character_id, + prev_scene_id, + scene_id, + scene_metadata, + ) + + if score < best_score: + best_score = score + best_mic = mic_id + + return best_mic + + +def _mic_already_used_in_scene( + mic_id: int, + scene_id: int, + existing_allocations: List[MicrophoneAllocation], + new_allocations: List[Tuple[int, int, int]], +) -> bool: + """ + Check if a mic is already assigned to someone in a specific scene. + + :param mic_id: Microphone ID + :param scene_id: Scene ID + :param existing_allocations: List of existing manual allocations + :param new_allocations: List of (mic_id, scene_id, character_id) tuples + :return: True if mic is already used in this scene + """ + # Check existing allocations + for alloc in existing_allocations: + if alloc.mic_id == mic_id and alloc.scene_id == scene_id: + return True + + # Check new allocations + for new_mic_id, new_scene_id, _ in new_allocations: + if new_mic_id == mic_id and new_scene_id == scene_id: + return True + + return False + + +def _mic_manually_allocated_to_character( + mic_id: int, character_id: int, existing_allocations: List[MicrophoneAllocation] +) -> bool: + """ + Check if a mic is manually allocated to a character in any scene. + + :param mic_id: Microphone ID + :param character_id: Character ID + :param existing_allocations: List of existing manual allocations + :return: True if mic is manually assigned to this character anywhere + """ + for alloc in existing_allocations: + if alloc.mic_id == mic_id and alloc.character_id == character_id: + return True + return False + + +def _mic_used_by_character_in_new_allocations( + mic_id: int, character_id: int, new_allocations: List[Tuple[int, int, int]] +) -> bool: + """ + Check if a mic has been assigned to a character in new allocations. + + :param mic_id: Microphone ID + :param character_id: Character ID + :param new_allocations: List of (mic_id, scene_id, character_id) tuples + :return: True if mic is assigned to this character in new allocations + """ + for new_mic_id, _, new_char_id in new_allocations: + if new_mic_id == mic_id and new_char_id == character_id: + return True + return False diff --git a/server/utils/web/jwt_service.py b/server/utils/web/jwt_service.py index efeb486d..169e510f 100644 --- a/server/utils/web/jwt_service.py +++ b/server/utils/web/jwt_service.py @@ -1,8 +1,8 @@ from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional -import jwt import tornado.locks +from jwt import PyJWS, PyJWT, PyJWTError from sqlalchemy import select from models.settings import SystemSettings @@ -20,6 +20,12 @@ def __init__( Initialize JWT service with either application instance or direct secret """ self.application = application + self._jwt = PyJWT() + self._jws = PyJWS() + if jwt_algorithm not in self._jws.get_algorithms(): + raise ValueError( + f"Unsupported JWT algorithm: {jwt_algorithm}. Supported algorithms: {self._jws.get_algorithms()}" + ) self._secret = secret self._jwt_algorithm = jwt_algorithm self._default_expiry = default_expiry @@ -27,7 +33,7 @@ def __init__( self._revoked_token_lock = tornado.locks.Lock() async def revoke_token(self, token: str): - headers = jwt.get_unverified_header(token) + headers = self._jws.get_unverified_header(token) expiry = headers.get("exp", -1) async with self._revoked_token_lock: self._revoked_tokens[token] = expiry @@ -78,7 +84,7 @@ def create_access_token( expire = datetime.now(tz=timezone.utc) + self._default_expiry to_encode.update({"exp": expire, "iat": datetime.now(tz=timezone.utc)}) - encoded_jwt = jwt.encode( + encoded_jwt = self._jwt.encode( to_encode, self.get_secret(), algorithm=self._jwt_algorithm ) @@ -89,14 +95,14 @@ def decode_access_token(self, token: str) -> Optional[Dict[str, Any]]: Decode and validate a JWT token """ try: - payload = jwt.decode( + payload = self._jwt.decode( token, self.get_secret(), options={"require": ["exp"]}, algorithms=[self._jwt_algorithm], ) return payload - except jwt.PyJWTError: + except PyJWTError: return None @staticmethod