diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 7136e6e..53433e3 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -2,4 +2,5 @@ name: "CodeQL config" paths-ignore: - "node_modules" - - "examples" \ No newline at end of file + - "examples" + - "tests" \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..49ea0f4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,41 @@ +name: Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm test + + - name: Install Playwright browsers + run: npx playwright install --with-deps webkit + + - name: Run E2E tests + run: npm run test:e2e + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index f06235c..0be3857 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ node_modules dist + +# Tests +.last-run.json +test-results/ +playwright-report/ +coverage/ diff --git a/package-lock.json b/package-lock.json index 9c005b1..19ddd9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,15 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.57.0", "@stylistic/eslint-plugin": "^5.6.1", "@three.ez/main": "^0.5.11", "@types/three": "^0.181.0", + "@vitest/coverage-v8": "^4.0.15", "eslint": "^9.39.1", + "happy-dom": "^20.0.11", "meshoptimizer": "^0.25.0", + "playwright": "^1.57.0", "simplex-noise": "^4.0.3", "three-hex-tiling": "^0.1.5", "typescript": "^5.9.3", @@ -25,397 +29,70 @@ "vite": "^7.2.6", "vite-plugin-externalize-deps": "^0.10.0", "vite-plugin-glsl": "^1.5.5", - "vite-plugin-static-copy": "^3.1.4" + "vite-plugin-static-copy": "^3.1.4", + "vitest": "^4.0.15" }, "peerDependencies": { "three": ">=0.159.0" } }, - "node_modules/@dimforge/rapier3d-compat": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", - "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", - "cpu": [ - "s390x" - ], + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/parser": { + "version": "7.28.5", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/types": { + "version": "7.28.5", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", - "cpu": [ - "x64" - ], + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", - "cpu": [ - "x64" - ], + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } + "license": "Apache-2.0" }, - "node_modules/@esbuild/win32-arm64": { + "node_modules/@esbuild/darwin-arm64": { "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", "cpu": [ "arm64" ], @@ -423,41 +100,7 @@ "license": "MIT", "optional": true, "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" + "darwin" ], "engines": { "node": ">=18" @@ -468,7 +111,6 @@ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, - "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -484,8 +126,6 @@ }, "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { @@ -497,8 +137,6 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, "license": "MIT", "engines": { @@ -510,7 +148,6 @@ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", @@ -525,7 +162,6 @@ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/core": "^0.17.0" }, @@ -538,7 +174,6 @@ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -548,8 +183,6 @@ }, "node_modules/@eslint/eslintrc": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -575,7 +208,6 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -588,7 +220,6 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -598,7 +229,6 @@ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" @@ -609,8 +239,6 @@ }, "node_modules/@humanfs/core": { "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -619,8 +247,6 @@ }, "node_modules/@humanfs/node": { "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -633,8 +259,6 @@ }, "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -647,8 +271,6 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -661,8 +283,6 @@ }, "node_modules/@humanwhocodes/retry": { "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -673,10 +293,45 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/pluginutils": { "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", "dev": true, "license": "MIT", "optional": true, @@ -706,7 +361,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -720,7 +374,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -734,7 +387,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -748,7 +400,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -762,7 +413,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -776,7 +426,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -790,7 +439,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -804,7 +452,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -818,7 +465,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -832,7 +478,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -846,7 +491,6 @@ "loong64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -860,7 +504,6 @@ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -874,7 +517,6 @@ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -888,7 +530,6 @@ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -902,7 +543,6 @@ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -916,7 +556,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -930,7 +569,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -944,7 +582,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openharmony" @@ -958,7 +595,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -972,7 +608,6 @@ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -986,7 +621,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -1000,18 +634,21 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, "node_modules/@stylistic/eslint-plugin": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.6.1.tgz", "integrity": "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.47.0", @@ -1032,7 +669,6 @@ "resolved": "https://registry.npmjs.org/@three.ez/asset-manager/-/asset-manager-0.0.1.tgz", "integrity": "sha512-29t73BMg9AsH7FIUyJZAP8PjticjK/t3g+NW1TV50vh1/LlVD2qKBQvbD5mGPYNi7QK4lH+xgukJXGNa6kfj0w==", "dev": true, - "license": "MIT", "peerDependencies": { "three": ">=0.159.0" } @@ -1042,7 +678,6 @@ "resolved": "https://registry.npmjs.org/@three.ez/main/-/main-0.5.11.tgz", "integrity": "sha512-aLOPLSTX3v9kPJgrD9NbmEdSAYdR2SssU3OXGChRo6q4eP188IOD61QHztjP922/r0oZ5W6c2chawyE1neLf6A==", "dev": true, - "license": "MIT", "dependencies": { "@three.ez/asset-manager": "^0.0.1" }, @@ -1052,8 +687,20 @@ }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", - "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", "dev": true, "license": "MIT" }, @@ -1061,20 +708,24 @@ "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" + "dev": true }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.19.25", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } }, "node_modules/@types/stats.js": { "version": "0.17.3", - "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz", - "integrity": "sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==", "dev": true, "license": "MIT" }, @@ -1083,7 +734,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.181.0.tgz", "integrity": "sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==", "dev": true, - "license": "MIT", "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -1098,13 +748,15 @@ "version": "0.22.0", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/webxr": { "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.21.tgz", - "integrity": "sha512-geZIAtLzjGmgY2JUi6VxXdCrTb99A7yP49lxLr2Nm/uIK0PkkxcEi4OGhoGDO4pxCf3JwGz2GiJL2Ej4K2bKaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", "dev": true, "license": "MIT" }, @@ -1113,7 +765,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.1", @@ -1143,7 +794,6 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } @@ -1153,7 +803,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -1178,7 +827,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.48.1", "@typescript-eslint/types": "^8.48.1", @@ -1200,7 +848,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1" @@ -1218,7 +865,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1235,7 +881,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", @@ -1260,7 +905,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1274,7 +918,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/project-service": "8.48.1", "@typescript-eslint/tsconfig-utils": "8.48.1", @@ -1302,7 +945,6 @@ "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" } @@ -1312,7 +954,6 @@ "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" }, @@ -1328,7 +969,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.48.1", @@ -1347,28 +987,161 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", - "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.15.tgz", + "integrity": "sha512-FUJ+1RkpTFW7rQITdgTi93qOCWJobWhBirEPCeXh2SW2wsTlFxy51apDz5gzG+ZEYt/THvWeNmhdAoS9DTwpCw==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.15", + "ast-v8-to-istanbul": "^0.3.8", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.15", + "vitest": "4.0.15" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.15", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.15", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.15", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.15", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.15", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@vitest/pretty-format": "4.0.15", + "tinyrainbow": "^3.0.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" } }, "node_modules/@webgpu/types": { "version": "0.1.54", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.54.tgz", - "integrity": "sha512-81oaalC8LFrXjhsczomEQ0u3jG+TqE6V9QHLA8GNZq/Rnot0KDugu3LhSYSlie8tSdooAN1Hov05asrUUp9qgg==", "dev": true, "license": "BSD-3-Clause" }, @@ -1377,7 +1150,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -1387,8 +1159,6 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1397,8 +1167,6 @@ }, "node_modules/ajv": { "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", "dependencies": { @@ -1414,8 +1182,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -1430,8 +1196,6 @@ }, "node_modules/anymatch": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, "license": "ISC", "dependencies": { @@ -1444,8 +1208,6 @@ }, "node_modules/anymatch/node_modules/picomatch": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { @@ -1457,22 +1219,43 @@ }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/binary-extensions": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, "license": "MIT", "engines": { @@ -1487,7 +1270,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1495,8 +1277,6 @@ }, "node_modules/braces": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", "dependencies": { @@ -1508,24 +1288,26 @@ }, "node_modules/bvh.js": { "version": "0.0.13", - "resolved": "https://registry.npmjs.org/bvh.js/-/bvh.js-0.0.13.tgz", - "integrity": "sha512-7jVxKGyyATOwEoqFvghXoStJXkkmqf7JIXYEG44eMtMXQoeCwZL2n1z5/kZogFKvCaN7XweS2TKnqdyDrd0DJA==", "license": "MIT" }, "node_modules/callsites": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.1", + "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", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { @@ -1541,8 +1323,6 @@ }, "node_modules/chokidar": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -1566,8 +1346,6 @@ }, "node_modules/chokidar/node_modules/glob-parent": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", "dependencies": { @@ -1579,8 +1357,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1592,8 +1368,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, @@ -1601,13 +1375,10 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -1621,8 +1392,6 @@ }, "node_modules/debug": { "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, "license": "MIT", "dependencies": { @@ -1639,15 +1408,16 @@ }, "node_modules/deep-is": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", "dev": true, "license": "MIT" }, "node_modules/esbuild": { "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1687,8 +1457,6 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { @@ -1703,7 +1471,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -1763,7 +1530,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -1780,7 +1546,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1793,7 +1558,6 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -1808,8 +1572,6 @@ }, "node_modules/esquery": { "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1824,7 +1586,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -1834,8 +1595,6 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -1844,8 +1603,6 @@ }, "node_modules/estree-walker": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, "license": "MIT", "optional": true, @@ -1853,39 +1610,37 @@ }, "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.2.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "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": { @@ -1902,15 +1657,11 @@ }, "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": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1922,8 +1673,6 @@ }, "node_modules/fill-range": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", "dependencies": { @@ -1935,8 +1684,6 @@ }, "node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -1952,8 +1699,6 @@ }, "node_modules/flat-cache": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { @@ -1966,17 +1711,12 @@ }, "node_modules/flatted": { "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" }, "node_modules/fsevents": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -1988,8 +1728,6 @@ }, "node_modules/glob-parent": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -2001,8 +1739,6 @@ }, "node_modules/globals": { "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", "engines": { @@ -2016,23 +1752,37 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/happy-dom": { + "version": "20.0.11", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.11.tgz", + "integrity": "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==", "dev": true, - "license": "MIT" + "dependencies": { + "@types/node": "^20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + } }, "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -2041,8 +1791,6 @@ }, "node_modules/import-fresh": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2058,8 +1806,6 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -2068,8 +1814,6 @@ }, "node_modules/is-binary-path": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, "license": "MIT", "dependencies": { @@ -2081,8 +1825,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -2091,8 +1833,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -2104,8 +1844,6 @@ }, "node_modules/is-number": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "license": "MIT", "engines": { @@ -2114,15 +1852,62 @@ }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, "license": "MIT", "dependencies": { @@ -2134,29 +1919,21 @@ }, "node_modules/json-buffer": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/keyv": { "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -2165,8 +1942,6 @@ }, "node_modules/levn": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2179,8 +1954,6 @@ }, "node_modules/locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -2195,24 +1968,52 @@ }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/meshoptimizer": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.25.0.tgz", "integrity": "sha512-ewwuAo3ujPZ7T3Y2oTkEoLlXvNOqnr0cjyAxfv5djXJqwD9QlxDDO0qGtsqB4Z9QUVvhruKXg9q/xfK9I5S1xQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2222,8 +2023,6 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, @@ -2238,7 +2037,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -2248,25 +2046,28 @@ }, "node_modules/natural-compare": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, "node_modules/normalize-path": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -2283,8 +2084,6 @@ }, "node_modules/p-limit": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2299,8 +2098,6 @@ }, "node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -2315,8 +2112,6 @@ }, "node_modules/p-map": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", - "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", "dev": true, "license": "MIT", "engines": { @@ -2328,8 +2123,6 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2341,8 +2134,6 @@ }, "node_modules/path-exists": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", "engines": { @@ -2351,25 +2142,24 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "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": { @@ -2379,6 +2169,49 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2398,7 +2231,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2410,8 +2242,6 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -2420,8 +2250,6 @@ }, "node_modules/punycode": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { @@ -2430,8 +2258,6 @@ }, "node_modules/readdirp": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "license": "MIT", "dependencies": { @@ -2443,8 +2269,6 @@ }, "node_modules/readdirp/node_modules/picomatch": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { @@ -2456,8 +2280,6 @@ }, "node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { @@ -2469,7 +2291,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, - "license": "MIT", "dependencies": { "@types/estree": "1.0.8" }, @@ -2507,9 +2328,7 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.2", "dev": true, "license": "ISC", "bin": { @@ -2521,8 +2340,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { @@ -2534,35 +2351,42 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, "node_modules/simplex-noise": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz", - "integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==", "dev": true, "license": "MIT" }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { @@ -2574,8 +2398,6 @@ }, "node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -2586,26 +2408,33 @@ } }, "node_modules/three": { - "version": "0.181.2", - "resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz", - "integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==", + "version": "0.177.0", "license": "MIT", "peer": true }, "node_modules/three-hex-tiling": { "version": "0.1.5", - "resolved": "https://registry.npmjs.org/three-hex-tiling/-/three-hex-tiling-0.1.5.tgz", - "integrity": "sha512-c38BKK3IV/AplEoO0DlUof3e5ikHEKW3XnKkuOt9PqLz7QRoc4ZuYnBqfoaXe3rctLmFztnEiiPRRGSR34mPgA==", "dev": true, "license": "MIT", "peerDependencies": { "three": ">=0.151" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "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": { @@ -2619,10 +2448,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2634,8 +2469,6 @@ }, "node_modules/ts-api-utils": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, "license": "MIT", "engines": { @@ -2647,8 +2480,6 @@ }, "node_modules/type-check": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -2663,7 +2494,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2677,7 +2507,6 @@ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/eslint-plugin": "8.48.1", "@typescript-eslint/parser": "8.48.1", @@ -2696,10 +2525,13 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, "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": { @@ -2711,7 +2543,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, - "license": "MIT", "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -2786,7 +2617,6 @@ "resolved": "https://registry.npmjs.org/vite-plugin-externalize-deps/-/vite-plugin-externalize-deps-0.10.0.tgz", "integrity": "sha512-eQrtpT/Do7AvDn76l1yL6ZHyXJ+UWH2LaHVqhAes9go54qaAnPZuMbgxcroQ/7WY3ZyetZzYW2quQnDF0DV5qg==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/voracious" }, @@ -2799,7 +2629,6 @@ "resolved": "https://registry.npmjs.org/vite-plugin-glsl/-/vite-plugin-glsl-1.5.5.tgz", "integrity": "sha512-6NM2P4JkM+1hNSqMhM4eagX03bmhEoTyrOrk68y3Q6KXfdF73QIuCb6BmRZvwLPgXTCOBM3Zc8gL1WxctYnrUQ==", "dev": true, - "license": "MIT", "engines": { "node": ">= 20.17.0", "npm": ">= 10.8.3" @@ -2823,7 +2652,6 @@ "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz", "integrity": "sha512-iCmr4GSw4eSnaB+G8zc2f4dxSuDjbkjwpuBLLGvQYR9IW7rnDzftnUjOH5p4RYR+d4GsiBqXRvzuFhs5bnzVyw==", "dev": true, - "license": "MIT", "dependencies": { "chokidar": "^3.6.0", "p-map": "^7.0.3", @@ -2837,10 +2665,93 @@ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/vitest": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", + "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", + "dev": true, + "dependencies": { + "@vitest/expect": "4.0.15", + "@vitest/mocker": "4.0.15", + "@vitest/pretty-format": "4.0.15", + "@vitest/runner": "4.0.15", + "@vitest/snapshot": "4.0.15", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "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.15", + "@vitest/browser-preview": "4.0.15", + "@vitest/browser-webdriverio": "4.0.15", + "@vitest/ui": "4.0.15", + "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/whatwg-mimetype": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -2853,10 +2764,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -2865,8 +2789,6 @@ }, "node_modules/yocto-queue": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index f19037a..77a3d1b 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,11 @@ "start": "vite", "build": "vite build && tsc --build tsconfig.build.json", "lint": "eslint --fix", - "test": "echo todo add tests", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:all": "vitest run && playwright test", "publish-alpha": "npm version prerelease --preid=alpha --git-tag-version false && npm run build && cd dist && npm publish --access public", "publish-patch": "npm version patch --git-tag-version false && npm run build && cd dist && npm publish --access public", "publish-minor": "npm version minor --git-tag-version false && npm run build && cd dist && npm publish --access public", @@ -52,11 +56,15 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.57.0", "@stylistic/eslint-plugin": "^5.6.1", "@three.ez/main": "^0.5.11", "@types/three": "^0.181.0", + "@vitest/coverage-v8": "^4.0.15", "eslint": "^9.39.1", + "happy-dom": "^20.0.11", "meshoptimizer": "^0.25.0", + "playwright": "^1.57.0", "simplex-noise": "^4.0.3", "three-hex-tiling": "^0.1.5", "typescript": "^5.9.3", @@ -64,7 +72,8 @@ "vite": "^7.2.6", "vite-plugin-externalize-deps": "^0.10.0", "vite-plugin-glsl": "^1.5.5", - "vite-plugin-static-copy": "^3.1.4" + "vite-plugin-static-copy": "^3.1.4", + "vitest": "^4.0.15" }, "peerDependencies": { "three": ">=0.159.0" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b18c6a4 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + webServer: { + command: 'npm run start', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); + diff --git a/tests/e2e/frustum-culling.spec.ts b/tests/e2e/frustum-culling.spec.ts new file mode 100644 index 0000000..9fa6c0c --- /dev/null +++ b/tests/e2e/frustum-culling.spec.ts @@ -0,0 +1,402 @@ +/** + * E2E tests for Frustum Culling + * + * Tests actual WebGL rendering with real camera frustum calculations. + */ + +import { test, expect, type Page } from '@playwright/test'; +import { initBrowserHelpers } from './test-utils.js'; + +// Shared beforeEach setup +const setupScene = async (page: Page) => { + await page.goto('/tests/fixtures/test-scene.html'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await page.waitForFunction(() => (window as any).sceneReady === true); + await initBrowserHelpers(page); +}; + +test.describe('Frustum Culling E2E', () => { + test.beforeEach(async ({ page }) => { + await setupScene(page); + }); + + test('should render instances within camera frustum', async ({ page }) => { + // Create mesh with instances at origin (visible to camera) + await page.evaluate(() => { + window.createTestMesh({ count: 100, spread: 10 }); + }); + + // Wait for render + await page.waitForTimeout(100); + + // Check that some instances are rendered + const renderCount = await page.evaluate(() => window.testMesh.count); + expect(renderCount).toBeGreaterThan(0); + }); + + test('should cull instances outside frustum', async ({ page }) => { + // Create mesh with instances far from camera view + await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 100, spread: 0 }); + + // Move all instances behind the camera + mesh.updateInstances((obj) => { + obj.position.set(0, 0, 200); // Behind camera at z=100 + }); + + // Trigger frustum culling + mesh.performFrustumCulling(window.camera); + }); + + await page.waitForTimeout(100); + + // Instances behind camera should be culled + const renderCount = await page.evaluate(() => window.testMesh.count); + expect(renderCount).toBe(0); + }); + + test('should respect perObjectFrustumCulled setting', async ({ page }) => { + const result = await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 50, spread: 1000 }); + + // Disable auto update to prevent render loop from re-running culling + mesh.autoUpdate = false; + mesh.perObjectFrustumCulled = false; + mesh.performFrustumCulling(window.camera); + + // Get values immediately after performFrustumCulling + return { + renderCount: mesh.count, + totalCount: mesh.instancesCount + }; + }); + + // With perObjectFrustumCulled disabled, all instances should be rendered + expect(result.renderCount).toBe(result.totalCount); + }); + + test('should update culling when camera moves', async ({ page }) => { + await page.evaluate(() => { + window.createTestMesh({ count: 100, spread: 50 }); + }); + + // Get initial count + const initialCount = await page.evaluate(() => { + window.testMesh.performFrustumCulling(window.camera); + return window.testMesh.count; + }); + + // Move camera far away + await page.evaluate(() => { + window.testHelpers.setupCamera({ x: 0, y: 0, z: 10000 }); + window.testMesh.performFrustumCulling(window.camera); + }); + + await page.waitForTimeout(100); + + const farCount = await page.evaluate(() => window.testMesh.count); + + // When camera is far, fewer instances should be visible + expect(farCount).toBeLessThan(initialCount); + }); + + test('should handle hidden instances during culling', async ({ page }) => { + await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 10, spread: 5 }); + + // Hide some instances + mesh.setVisibilityAt(0, false); + mesh.setVisibilityAt(1, false); + mesh.setVisibilityAt(2, false); + + mesh.performFrustumCulling(window.camera); + }); + + await page.waitForTimeout(100); + + const renderCount = await page.evaluate(() => window.testMesh.count); + + // Should have at most 7 rendered (10 - 3 hidden) + expect(renderCount).toBeLessThanOrEqual(7); + }); + + test('should use BVH for culling when available', async ({ page }) => { + await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 100, spread: 50 }); + mesh.computeBVH(); + + // BVH should be created + return mesh.bvh !== null; + }); + + const hasBVH = await page.evaluate(() => window.testMesh.bvh !== null); + expect(hasBVH).toBe(true); + + // Perform culling with BVH + await page.evaluate(() => { + window.testMesh.performFrustumCulling(window.camera); + }); + + const renderCount = await page.evaluate(() => window.testMesh.count); + expect(renderCount).toBeGreaterThan(0); + }); +}); + +/** + * High-confidence deterministic frustum culling tests + * + * These tests verify exact instance IDs are culled/rendered + * by placing instances at known positions and checking internal state. + */ +test.describe('Deterministic Frustum Culling', () => { + test.beforeEach(async ({ page }) => { + await setupScene(page); + }); + + test('should cull specific instances outside frustum and keep specific instances inside', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + const mesh = window.testHelpers.createMesh(geometry, material); + + // Camera at (0, 0, 50) looking at origin + window.testHelpers.setupCamera( + { x: 0, y: 0, z: 50 }, + { x: 0, y: 0, z: 0 }, + { near: 1, far: 200 } + ); + + // Create instances at known positions relative to camera + // Camera is at z=50 looking toward origin (negative Z direction) + mesh.addInstances(6, (obj, index) => { + switch (index) { + case 0: obj.position.set(0, 0, 0); break; // In front of camera - VISIBLE + case 1: obj.position.set(0, 0, 200); break; // Behind camera - CULLED + case 2: obj.position.set(5, 0, 10); break; // In front, slight offset - VISIBLE + case 3: obj.position.set(1000, 0, 0); break; // Far to the side - CULLED + case 4: obj.position.set(0, 0, 30); break; // In front, closer - VISIBLE + case 5: obj.position.set(0, 1000, 0); break; // Far above - CULLED + } + }); + + window.testHelpers.addToScene(mesh); + mesh.performFrustumCulling(window.camera); + + return window.testHelpers.getRenderedInfo(mesh); + }); + + // Verify exact count + expect(result.count).toBe(3); + + // Verify specific instances are rendered (0, 2, 4 are in view) + expect(result.ids).toContain(0); + expect(result.ids).toContain(2); + expect(result.ids).toContain(4); + + // Verify specific instances are culled (1, 3, 5 are out of view) + expect(result.ids).not.toContain(1); + expect(result.ids).not.toContain(3); + expect(result.ids).not.toContain(5); + }); + + test('should correctly handle instances at frustum near/far boundaries', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + const mesh = window.testHelpers.createMesh(geometry, material); + + // Camera at origin looking at -Z with specific near/far + window.testHelpers.setupCamera( + { x: 0, y: 0, z: 0 }, + { x: 0, y: 0, z: -1 }, + { near: 10, far: 100 } + ); + + // Create instances at boundary positions + mesh.addInstances(6, (obj, index) => { + switch (index) { + case 0: obj.position.set(0, 0, -5); break; // Before near plane (z=-5) - CULLED + case 1: obj.position.set(0, 0, -10); break; // At near plane - VISIBLE + case 2: obj.position.set(0, 0, -50); break; // Middle of frustum - VISIBLE + case 3: obj.position.set(0, 0, -100); break; // At far plane - VISIBLE + case 4: obj.position.set(0, 0, -150); break; // Beyond far plane - CULLED + case 5: obj.position.set(0, 0, -75); break; // Middle - VISIBLE + } + }); + + window.testHelpers.addToScene(mesh); + mesh.performFrustumCulling(window.camera); + + return window.testHelpers.getRenderedInfo(mesh); + }); + + // Instances 1, 2, 3, 5 should be visible (within near-far range) + expect(result.ids).toContain(1); + expect(result.ids).toContain(2); + expect(result.ids).toContain(3); + expect(result.ids).toContain(5); + + // Instances 0 and 4 should be culled (outside near-far range) + expect(result.ids).not.toContain(0); + expect(result.ids).not.toContain(4); + + expect(result.count).toBe(4); + }); + + test('should correctly handle instances at frustum left/right/top/bottom boundaries', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + const mesh = window.testHelpers.createMesh(geometry, material); + + // Camera with known FOV to calculate exact frustum boundaries + window.testHelpers.setupCamera( + { x: 0, y: 0, z: 0 }, + { x: 0, y: 0, z: -1 }, + { fov: 90, near: 1, far: 100 } + ); + window.camera.aspect = 1; // Square aspect ratio + window.camera.updateProjectionMatrix(); + + // At 90 degree FOV and aspect 1, at distance Z, frustum width = 2*Z + // At z=-20, frustum extends from x=-20 to x=+20 + mesh.addInstances(8, (obj, index) => { + switch (index) { + case 0: obj.position.set(0, 0, -20); break; // Center - VISIBLE + case 1: obj.position.set(15, 0, -20); break; // Right, in view - VISIBLE + case 2: obj.position.set(-15, 0, -20); break; // Left, in view - VISIBLE + case 3: obj.position.set(0, 15, -20); break; // Top, in view - VISIBLE + case 4: obj.position.set(0, -15, -20); break; // Bottom, in view - VISIBLE + case 5: obj.position.set(30, 0, -20); break; // Far right - CULLED + case 6: obj.position.set(-30, 0, -20); break; // Far left - CULLED + case 7: obj.position.set(0, 30, -20); break; // Far top - CULLED + } + }); + + window.testHelpers.addToScene(mesh); + mesh.performFrustumCulling(window.camera); + + return window.testHelpers.getRenderedInfo(mesh); + }); + + // Instances 0-4 should be visible (inside frustum) + expect(result.ids).toContain(0); + expect(result.ids).toContain(1); + expect(result.ids).toContain(2); + expect(result.ids).toContain(3); + expect(result.ids).toContain(4); + + // Instances 5-7 should be culled (outside frustum) + expect(result.ids).not.toContain(5); + expect(result.ids).not.toContain(6); + expect(result.ids).not.toContain(7); + + expect(result.count).toBe(5); + }); + + test('should produce same culling results with and without BVH', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + const mesh = window.testHelpers.createMesh(geometry, material, 200); + + // Camera setup + window.testHelpers.setupCamera({ x: 0, y: 0, z: 50 }); + + // Create instances at various positions + mesh.addInstances(50, (obj, index) => { + // Create a spread of instances, some visible, some not + const angle = (index / 50) * Math.PI * 2; + const radius = index % 2 === 0 ? 20 : 200; // Alternating near/far + obj.position.set( + Math.cos(angle) * radius, + Math.sin(angle) * radius * 0.5, + Math.sin(angle) * radius + ); + }); + + window.testHelpers.addToScene(mesh); + + // Culling WITHOUT BVH + mesh.performFrustumCulling(window.camera); + const withoutBVH = window.testHelpers.getRenderedInfo(mesh); + const withoutBVHIds = [...withoutBVH.ids].sort((a, b) => a - b); + + // Create BVH + mesh.computeBVH(); + + // Culling WITH BVH + mesh.performFrustumCulling(window.camera); + const withBVH = window.testHelpers.getRenderedInfo(mesh); + const withBVHIds = [...withBVH.ids].sort((a, b) => a - b); + + return { + withoutBVH: { count: withoutBVH.count, ids: withoutBVHIds }, + withBVH: { count: withBVH.count, ids: withBVHIds }, + hasBVH: mesh.bvh !== null + }; + }); + + // Verify BVH was created + expect(result.hasBVH).toBe(true); + + // Verify same counts + expect(result.withBVH.count).toBe(result.withoutBVH.count); + + // Verify same instance IDs are rendered + expect(result.withBVH.ids).toEqual(result.withoutBVH.ids); + }); + + test('should track correct instance IDs when instances are hidden', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + const mesh = window.testHelpers.createMesh(geometry, material); + + // Camera looking at origin + window.testHelpers.setupCamera({ x: 0, y: 0, z: 50 }); + + // Create 10 instances all in view + mesh.addInstances(10, (obj, index) => { + obj.position.set((index - 5) * 2, 0, 0); // Spread along X axis + }); + + // Hide specific instances + mesh.setVisibilityAt(2, false); + mesh.setVisibilityAt(5, false); + mesh.setVisibilityAt(8, false); + + window.testHelpers.addToScene(mesh); + mesh.performFrustumCulling(window.camera); + + return window.testHelpers.getRenderedInfo(mesh); + }); + + // 10 instances - 3 hidden = 7 visible + expect(result.count).toBe(7); + + // Hidden instances should not be in render list + expect(result.ids).not.toContain(2); + expect(result.ids).not.toContain(5); + expect(result.ids).not.toContain(8); + + // Visible instances should be in render list + expect(result.ids).toContain(0); + expect(result.ids).toContain(1); + expect(result.ids).toContain(3); + expect(result.ids).toContain(4); + expect(result.ids).toContain(6); + expect(result.ids).toContain(7); + expect(result.ids).toContain(9); + }); +}); diff --git a/tests/e2e/lod-switching.spec.ts b/tests/e2e/lod-switching.spec.ts new file mode 100644 index 0000000..45dc925 --- /dev/null +++ b/tests/e2e/lod-switching.spec.ts @@ -0,0 +1,331 @@ +/** + * E2E tests for LOD (Level of Detail) switching + * + * Tests actual distance-based LOD switching with real rendering. + */ + +import { test, expect, type Page } from '@playwright/test'; +import { initBrowserHelpers } from './test-utils.js'; + +// Shared beforeEach setup +const setupScene = async (page: Page) => { + await page.goto('/tests/fixtures/test-scene.html'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await page.waitForFunction(() => (window as any).sceneReady === true); + await initBrowserHelpers(page); +}; + +test.describe('LOD Switching E2E', () => { + test.beforeEach(async ({ page }) => { + await setupScene(page); + }); + + test('should create LOD levels', async ({ page }) => { + const hasLOD = await page.evaluate(() => { + const mesh = window.testHelpers.createMultiLODMesh(50, 100); + + mesh.addInstances(50, (obj) => { + // NOSONAR – test-only randomness is fine here + obj.position.set( + (Math.random() - 0.5) * 100, + (Math.random() - 0.5) * 100, + (Math.random() - 0.5) * 100 + ); + }); + + window.testHelpers.addToScene(mesh); + + return mesh.LODinfo !== null && mesh.LODinfo.render.levels.length === 3; + }); + + expect(hasLOD).toBe(true); + }); + + test('should render different LOD levels based on distance', async ({ page }) => { + await page.evaluate(() => { + const mesh = window.testHelpers.createLODMesh(50); + + // Create instances at known distances + mesh.addInstances(10, (obj, index) => { + // Near instances (within 50 units) + if (index < 5) { + obj.position.set(0, 0, 20); + } else { + // Far instances (beyond 50 units) + obj.position.set(0, 0, -100); + } + }); + + window.testHelpers.addToScene(mesh); + + // Trigger culling + mesh.performFrustumCulling(window.camera); + }); + + await page.waitForTimeout(100); + + // Check that LOD objects have different counts + const lodInfo = await page.evaluate(() => { + const mesh = window.testMesh; + return { + level0Count: mesh.LODinfo.objects[0].count, + level1Count: mesh.LODinfo.objects[1].count, + totalLevels: mesh.LODinfo.render.levels.length + }; + }); + + expect(lodInfo.totalLevels).toBe(2); + // Near and far instances should be distributed across LOD levels + }); + + test('should update LOD when camera moves', async ({ page }) => { + await page.evaluate(() => { + const mesh = window.testHelpers.createLODMesh(30); + + // All instances at origin + mesh.addInstances(20, (obj) => { + obj.position.set(0, 0, 0); + }); + + window.testHelpers.addToScene(mesh); + }); + + // Camera close - should use high LOD + await page.evaluate(() => { + window.testHelpers.setupCamera({ x: 0, y: 0, z: 20 }); + window.testMesh.performFrustumCulling(window.camera); + }); + + const closeHighCount = await page.evaluate(() => + window.testMesh.LODinfo.objects[0].count + ); + + // Camera far - should use low LOD + await page.evaluate(() => { + window.testHelpers.setupCamera({ x: 0, y: 0, z: 100 }); + window.testMesh.performFrustumCulling(window.camera); + }); + + const farHighCount = await page.evaluate(() => + window.testMesh.LODinfo.objects[0].count + ); + const farLowCount = await page.evaluate(() => + window.testMesh.LODinfo.objects[1].count + ); + + // When camera is close, high LOD should have more instances + // When camera is far, low LOD should have more instances + expect(closeHighCount).toBeGreaterThan(0); + expect(farLowCount).toBeGreaterThan(farHighCount); + }); + + test('should support shadow LOD', async ({ page }) => { + const hasShadowLOD = await page.evaluate(() => { + const { BoxGeometry, SphereGeometry, MeshBasicMaterial } = window.THREE; + + const highGeometry = new SphereGeometry(0.5, 32, 32); + const shadowGeometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = window.testHelpers.createMesh(highGeometry, material); + + mesh.addShadowLOD(shadowGeometry, 0); + + window.testMesh = mesh; + + return mesh.LODinfo.shadowRender !== null && + mesh.LODinfo.shadowRender.levels.length > 0 && + mesh.castShadow === true; + }); + + expect(hasShadowLOD).toBe(true); + }); +}); + +/** + * High-confidence deterministic LOD tests + * + * These tests verify exact instance ID assignments to LOD levels + * by placing instances at known distances and checking internal state. + */ +test.describe('Deterministic LOD Assignment', () => { + test.beforeEach(async ({ page }) => { + await setupScene(page); + }); + + test('should assign instances to correct LOD based on exact distance', async ({ page }) => { + const result = await page.evaluate(() => { + const mesh = window.testHelpers.createLODMesh(50); + + // Position camera at origin looking at -Z with wide FOV + window.testHelpers.setupCamera( + { x: 0, y: 0, z: 0 }, + { x: 0, y: 0, z: -1 }, + { fov: 90, near: 1, far: 500 } + ); + + // Create 4 instances at known distances from camera (at origin) + // All instances placed IN FRONT of camera (in -Z direction) to stay in frustum + mesh.addInstances(4, (obj, index) => { + if (index === 0) obj.position.set(0, 0, -30); // 30 units in front - LOD 0 + if (index === 1) obj.position.set(5, 0, -29.6); // ~30 units in front - LOD 0 + if (index === 2) obj.position.set(0, 0, -80); // 80 units in front - LOD 1 + if (index === 3) obj.position.set(10, 0, -79.4); // ~80 units in front - LOD 1 + }); + + window.testHelpers.addToScene(mesh); + + // Perform frustum culling (which also does LOD assignment) + mesh.performFrustumCulling(window.camera); + + // Extract LOD assignment data + const lodInfo = window.testHelpers.getLODInfo(mesh, 2); + + return { + lod0Count: lodInfo.counts[0], + lod1Count: lodInfo.counts[1], + lod0Ids: lodInfo.ids[0], + lod1Ids: lodInfo.ids[1] + }; + }); + + // Verify exact counts + expect(result.lod0Count).toBe(2); + expect(result.lod1Count).toBe(2); + + // Verify exact instance IDs in each LOD level + expect(result.lod0Ids.sort((a, b) => a - b)).toEqual([0, 1]); + expect(result.lod1Ids.sort((a, b) => a - b)).toEqual([2, 3]); + }); + + test('should reassign LOD levels when camera distance changes', async ({ page }) => { + // Setup: Create instances all at origin + await page.evaluate(() => { + const mesh = window.testHelpers.createLODMesh(50); + + // All 5 instances at origin + mesh.addInstances(5, (obj) => { + obj.position.set(0, 0, 0); + }); + + window.testHelpers.addToScene(mesh); + }); + + // Phase 1: Camera close (20 units) - all should be LOD 0 + const closeResult = await page.evaluate(() => { + window.testHelpers.setupCamera({ x: 0, y: 0, z: 20 }); + window.testMesh.performFrustumCulling(window.camera); + + const lodInfo = window.testHelpers.getLODInfo(window.testMesh, 2); + return { lod0Count: lodInfo.counts[0], lod1Count: lodInfo.counts[1] }; + }); + + expect(closeResult.lod0Count).toBe(5); + expect(closeResult.lod1Count).toBe(0); + + // Phase 2: Camera far (100 units) - all should be LOD 1 + const farResult = await page.evaluate(() => { + window.testHelpers.setupCamera({ x: 0, y: 0, z: 100 }); + window.testMesh.performFrustumCulling(window.camera); + + const lodInfo = window.testHelpers.getLODInfo(window.testMesh, 2); + return { lod0Count: lodInfo.counts[0], lod1Count: lodInfo.counts[1] }; + }); + + expect(farResult.lod0Count).toBe(0); + expect(farResult.lod1Count).toBe(5); + }); + + test('should handle instance at exact LOD boundary distance', async ({ page }) => { + const result = await page.evaluate(() => { + const mesh = window.testHelpers.createLODMesh(50); + + // Camera at origin + window.testHelpers.setupCamera( + { x: 0, y: 0, z: 0 }, + { x: 0, y: 0, z: -1 } + ); + + // Create instances at and around boundary + mesh.addInstances(4, (obj, index) => { + if (index === 0) obj.position.set(0, 0, -49.9); // Just inside LOD 0 + if (index === 1) obj.position.set(0, 0, -50.0); // Exactly at boundary + if (index === 2) obj.position.set(0, 0, -50.1); // Just outside to LOD 1 + if (index === 3) obj.position.set(0, 0, -60); // Clearly LOD 1 + }); + + window.testHelpers.addToScene(mesh); + mesh.performFrustumCulling(window.camera); + + const lodInfo = window.testHelpers.getLODInfo(mesh, 2); + + return { + lod0Count: lodInfo.counts[0], + lod1Count: lodInfo.counts[1], + lod0Ids: lodInfo.ids[0], + lod1Ids: lodInfo.ids[1] + }; + }); + + // Instance 0 (49.9) should be in LOD 0 + // Instance 1 (50.0 - exact boundary) - verify it's assigned consistently + // Instances 2,3 should be in LOD 1 + + // At boundary, the comparison is typically < threshold, so exactly 50 goes to LOD 1 + expect(result.lod0Ids).toContain(0); + expect(result.lod1Ids).toContain(2); + expect(result.lod1Ids).toContain(3); + + // Boundary instance (1) should be consistently assigned to one level + // It will go to LOD 1 since comparison is typically < (not <=) + expect(result.lod0Count + result.lod1Count).toBe(4); + }); + + test('should correctly assign multiple LOD levels (3+ levels)', async ({ page }) => { + const result = await page.evaluate(() => { + const mesh = window.testHelpers.createMultiLODMesh(30, 60); + + // Camera at origin looking at -Z with wide FOV + window.testHelpers.setupCamera( + { x: 0, y: 0, z: 0 }, + { x: 0, y: 0, z: -1 }, + { fov: 90, near: 1, far: 500 } + ); + + // Create 6 instances: 2 for each LOD level + // All instances in front of camera to stay in frustum + mesh.addInstances(6, (obj, index) => { + if (index === 0) obj.position.set(0, 0, -15); // 15 units -> LOD 0 + if (index === 1) obj.position.set(3, 0, -14.7); // ~15 units -> LOD 0 + if (index === 2) obj.position.set(0, 0, -45); // 45 units -> LOD 1 + if (index === 3) obj.position.set(8, 0, -44.3); // ~45 units -> LOD 1 + if (index === 4) obj.position.set(0, 0, -80); // 80 units -> LOD 2 + if (index === 5) obj.position.set(15, 0, -78.6); // ~80 units -> LOD 2 + }); + + window.testHelpers.addToScene(mesh); + mesh.performFrustumCulling(window.camera); + + const lodInfo = window.testHelpers.getLODInfo(mesh, 3); + + return { + lod0Count: lodInfo.counts[0], + lod1Count: lodInfo.counts[1], + lod2Count: lodInfo.counts[2], + lod0Ids: lodInfo.ids[0], + lod1Ids: lodInfo.ids[1], + lod2Ids: lodInfo.ids[2], + totalLevels: mesh.LODinfo.render.levels.length + }; + }); + + expect(result.totalLevels).toBe(3); + expect(result.lod0Count).toBe(2); + expect(result.lod1Count).toBe(2); + expect(result.lod2Count).toBe(2); + + expect(result.lod0Ids.sort((a, b) => a - b)).toEqual([0, 1]); + expect(result.lod1Ids.sort((a, b) => a - b)).toEqual([2, 3]); + expect(result.lod2Ids.sort((a, b) => a - b)).toEqual([4, 5]); + }); +}); diff --git a/tests/e2e/raycasting.spec.ts b/tests/e2e/raycasting.spec.ts new file mode 100644 index 0000000..404f115 --- /dev/null +++ b/tests/e2e/raycasting.spec.ts @@ -0,0 +1,138 @@ +/** + * E2E tests for Raycasting + * + * Tests actual raycasting with BVH optimization. + */ + +import { test, expect } from '@playwright/test'; + +test.describe('Raycasting E2E', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/fixtures/test-scene.html'); + await page.waitForFunction(() => window.sceneReady === true); + }); + + test('should raycast and find instances', async ({ page }) => { + const hitFound = await page.evaluate(() => { + const { Raycaster, Vector2 } = window.THREE; + + // Create mesh with instance at known position + const mesh = window.createTestMesh({ count: 1, spread: 0 }); + + // Position instance at origin + mesh.updateInstances((obj) => { + obj.position.set(0, 0, 0); + }); + + // Create raycaster pointing at origin + const raycaster = new Raycaster(); + raycaster.setFromCamera(new Vector2(0, 0), window.camera); + + const intersects = raycaster.intersectObject(mesh); + + return intersects.length > 0; + }); + + expect(hitFound).toBe(true); + }); + + test('should return instanceId in raycast result', async ({ page }) => { + const instanceId = await page.evaluate(() => { + const { Raycaster, Vector2 } = window.THREE; + + const mesh = window.createTestMesh({ count: 5, spread: 0 }); + + // Position all instances at origin + mesh.updateInstances((obj, index) => { + obj.position.set(0, 0, index * 2); + }); + + const raycaster = new Raycaster(); + raycaster.setFromCamera(new Vector2(0, 0), window.camera); + + const intersects = raycaster.intersectObject(mesh); + + if (intersects.length > 0) { + return intersects[0].instanceId; + } + return -1; + }); + + expect(instanceId).toBeGreaterThanOrEqual(0); + }); + + test('should use BVH for optimized raycasting', async ({ page }) => { + const result = await page.evaluate(() => { + const { Raycaster, Vector2 } = window.THREE; + + // Create mesh with many instances + const mesh = window.createTestMesh({ count: 1000, spread: 100 }); + mesh.computeBVH(); + + const raycaster = new Raycaster(); + raycaster.setFromCamera(new Vector2(0, 0), window.camera); + + const start = performance.now(); + const intersects = raycaster.intersectObject(mesh); + const duration = performance.now() - start; + + return { + hasBVH: mesh.bvh !== null, + hitCount: intersects.length, + duration + }; + }); + + expect(result.hasBVH).toBe(true); + // With BVH, raycasting should be fast even with many instances + expect(result.duration).toBeLessThan(100); // Should be much faster than 100ms + }); + + test('should only raycast visible instances', async ({ page }) => { + const result = await page.evaluate(() => { + const { Raycaster, Vector2 } = window.THREE; + + const mesh = window.createTestMesh({ count: 1, spread: 0 }); + + // Position instance at origin + mesh.updateInstances((obj) => { + obj.position.set(0, 0, 0); + }); + + // First raycast - should hit + const raycaster = new Raycaster(); + raycaster.setFromCamera(new Vector2(0, 0), window.camera); + const hitsBeforeHide = raycaster.intersectObject(mesh).length; + + // Hide the instance + mesh.setVisibilityAt(0, false); + + // Second raycast - should not hit hidden instance + const hitsAfterHide = raycaster.intersectObject(mesh).length; + + return { + hitsBeforeHide, + hitsAfterHide + }; + }); + + expect(result.hitsBeforeHide).toBeGreaterThan(0); + // Note: visibility filtering during raycast depends on implementation + }); + + test('should raycast with raycastOnlyFrustum option', async ({ page }) => { + await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 100, spread: 50 }); + mesh.raycastOnlyFrustum = true; + + // Perform frustum culling first + mesh.performFrustumCulling(window.camera); + + window.testMesh = mesh; + }); + + const setting = await page.evaluate(() => window.testMesh.raycastOnlyFrustum); + expect(setting).toBe(true); + }); +}); + diff --git a/tests/e2e/rendering.spec.ts b/tests/e2e/rendering.spec.ts new file mode 100644 index 0000000..d896fcf --- /dev/null +++ b/tests/e2e/rendering.spec.ts @@ -0,0 +1,663 @@ +/** + * E2E tests for basic rendering functionality + * + * Tests that InstancedMesh2 renders correctly in a real WebGL context. + */ + +import { test, expect, type Page } from '@playwright/test'; +import { initBrowserHelpers } from './test-utils.js'; + +// Shared beforeEach setup +const setupScene = async (page: Page) => { + await page.goto('/tests/fixtures/test-scene.html'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await page.waitForFunction(() => (window as any).sceneReady === true); + await initBrowserHelpers(page); +}; + +test.describe('Rendering E2E', () => { + test.beforeEach(async ({ page }) => { + await setupScene(page); + }); + + test('should render instances on screen', async ({ page }) => { + await page.evaluate(() => { + window.createTestMesh({ count: 100, spread: 30 }); + }); + + // Wait for a few frames + await page.waitForTimeout(200); + + // Take screenshot to verify rendering + const screenshot = await page.screenshot(); + expect(screenshot).toBeTruthy(); + + // Check render count is greater than 0 + const renderCount = await page.evaluate(() => window.testMesh.count); + expect(renderCount).toBeGreaterThan(0); + }); + + test('should update display when instances added', async ({ page }) => { + // Start with empty mesh + const initialCount = await page.evaluate(() => { + window.createTestMesh({ count: 0 }); + return window.testMesh.instancesCount; + }); + + expect(initialCount).toBe(0); + + // Add instances + const newCount = await page.evaluate(() => { + window.testMesh.addInstances(50, (obj, index) => { + obj.position.set(index, 0, 0); + }); + return window.testMesh.instancesCount; + }); + + expect(newCount).toBe(50); + }); + + test('should handle instance removal', async ({ page }) => { + await page.evaluate(() => { + window.createTestMesh({ count: 10, spread: 10 }); + }); + + const initialCount = await page.evaluate(() => window.testMesh.instancesCount); + expect(initialCount).toBe(10); + + // Remove some instances + const afterRemoval = await page.evaluate(() => { + window.testMesh.removeInstances(0, 1, 2); + return window.testMesh.instancesCount; + }); + + expect(afterRemoval).toBe(7); + }); + + test('should handle clearInstances', async ({ page }) => { + await page.evaluate(() => { + window.createTestMesh({ count: 100, spread: 50 }); + }); + + const beforeClear = await page.evaluate(() => window.testMesh.instancesCount); + expect(beforeClear).toBe(100); + + const afterClear = await page.evaluate(() => { + window.testMesh.clearInstances(); + return window.testMesh.instancesCount; + }); + + expect(afterClear).toBe(0); + }); + + test('should apply instance colors', async ({ page }) => { + const result = await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 5, spread: 10 }); + + // colorsTexture is lazily initialized - should be null before setting any colors + const hasTextureBeforeSet = mesh.colorsTexture !== null; + + // Set different colors using Color objects (should not throw) + let setColorSuccess = false; + try { + const { Color } = window.THREE; + mesh.setColorAt(0, new Color(1, 0, 0)); // Red + mesh.setColorAt(1, new Color(0, 1, 0)); // Green + mesh.setColorAt(2, new Color(0, 0, 1)); // Blue + setColorSuccess = true; + } catch { + setColorSuccess = false; + } + + // After setting colors, colorsTexture should exist + const hasTextureAfterSet = mesh.colorsTexture !== null; + const textureHasData = hasTextureAfterSet && mesh.colorsTexture._data.length > 0; + + // Verify getColorAt doesn't throw + let getColorSuccess = false; + try { + mesh.getColorAt(0); + mesh.getColorAt(1); + mesh.getColorAt(2); + getColorSuccess = true; + } catch { + getColorSuccess = false; + } + + return { + hasTextureBeforeSet, + hasTextureAfterSet, + textureHasData, + setColorSuccess, + getColorSuccess + }; + }); + + // colorsTexture is lazily initialized - only created when setColorAt is called + expect(result.hasTextureBeforeSet).toBe(false); + expect(result.hasTextureAfterSet).toBe(true); + expect(result.textureHasData).toBe(true); + expect(result.setColorSuccess).toBe(true); + expect(result.getColorSuccess).toBe(true); + }); + + test('should handle instance transformations', async ({ page }) => { + const transformApplied = await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 1, spread: 0, createEntities: true }); + + const instance = mesh.instances[0]; + instance.position.set(10, 20, 30); + instance.scale.set(2, 2, 2); + instance.updateMatrix(); + + const pos = mesh.getPositionAt(0); + + return { + x: Math.abs(pos.x - 10) < 0.001, + y: Math.abs(pos.y - 20) < 0.001, + z: Math.abs(pos.z - 30) < 0.001 + }; + }); + + expect(transformApplied.x).toBe(true); + expect(transformApplied.y).toBe(true); + expect(transformApplied.z).toBe(true); + }); + + test('should support capacity expansion', async ({ page }) => { + const expanded = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = window.testHelpers.createMesh(geometry, material, 10); + window.testHelpers.addToScene(mesh); + + const initialCapacity = mesh.capacity; + + // Add more than capacity + mesh.addInstances(50, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + const finalCapacity = mesh.capacity; + + return { + initialCapacity, + finalCapacity, + instanceCount: mesh.instancesCount + }; + }); + + expect(expanded.initialCapacity).toBe(10); + expect(expanded.finalCapacity).toBeGreaterThan(10); + expect(expanded.instanceCount).toBe(50); + }); + + test('should render with WebGL context', async ({ page }) => { + const hasWebGL = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + if (!canvas) return false; + + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + return gl !== null; + }); + + expect(hasWebGL).toBe(true); + }); +}); + +/** + * Rendering Pipeline Verification Tests + * + * These tests verify the pipeline from internal state to GPU rendering, + * ensuring data is correctly uploaded and shaders are properly configured. + */ +test.describe('Rendering Pipeline Verification', () => { + test.beforeEach(async ({ page }) => { + await setupScene(page); + }); + + test('should have matricesTexture available for shader', async ({ page }) => { + const result = await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 10, spread: 10 }); + + // Force a render + window.renderer.render(window.scene, window.camera); + + // Verify the matricesTexture exists and has data for the shader + const hasMatricesTexture = mesh.matricesTexture !== null; + const matricesTextureHasData = hasMatricesTexture && mesh.matricesTexture._data.length > 0; + + // colorsTexture is lazily initialized - only exists after setColorAt is called + // So we don't expect it to exist without setting colors + const hasColorsTextureBeforeSet = mesh.colorsTexture !== null; + + // Set a color to initialize the colorsTexture + mesh.setColorAt(0, 0xff0000); + const hasColorsTextureAfterSet = mesh.colorsTexture !== null; + const colorsTextureHasData = hasColorsTextureAfterSet && mesh.colorsTexture._data.length > 0; + + // Verify the texture has the correct structure for shader binding + const textureWidth = mesh.matricesTexture?.image?.width ?? 0; + const textureHeight = mesh.matricesTexture?.image?.height ?? 0; + + return { + hasMatricesTexture, + matricesTextureHasData, + hasColorsTextureBeforeSet, + hasColorsTextureAfterSet, + colorsTextureHasData, + textureWidth, + textureHeight, + hasValidDimensions: textureWidth > 0 && textureHeight > 0 + }; + }); + + expect(result.hasMatricesTexture).toBe(true); + expect(result.matricesTextureHasData).toBe(true); + // colorsTexture is lazily initialized + expect(result.hasColorsTextureBeforeSet).toBe(false); + expect(result.hasColorsTextureAfterSet).toBe(true); + expect(result.colorsTextureHasData).toBe(true); + expect(result.hasValidDimensions).toBe(true); + }); + + test('should have instance index buffer after culling', async ({ page }) => { + const result = await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 5, spread: 10 }); + mesh.performFrustumCulling(window.camera); + window.renderer.render(window.scene, window.camera); + + // Verify buffer exists and was created + const hasInstanceIndex = mesh.instanceIndex !== null; + const hasBuffer = hasInstanceIndex && mesh.instanceIndex.buffer !== null; + const bufferCount = mesh.count; + const cpuIndices = hasInstanceIndex + ? Array.from(mesh.instanceIndex.array.slice(0, bufferCount)) + : []; + + return { hasInstanceIndex, hasBuffer, bufferCount, cpuIndices }; + }); + + expect(result.hasInstanceIndex).toBe(true); + expect(result.hasBuffer).toBe(true); + expect(result.bufferCount).toBeGreaterThan(0); + expect(result.cpuIndices.length).toBe(result.bufferCount); + }); + + test('should store correct transforms in matricesTexture', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + const mesh = window.testHelpers.createMesh(geometry, material, 10); + + // Add instances with known positions + mesh.addInstances(3, (obj, index) => { + if (index === 0) obj.position.set(10, 0, 0); + if (index === 1) obj.position.set(0, 20, 0); + if (index === 2) obj.position.set(0, 0, 30); + }); + + window.testHelpers.addToScene(mesh); + window.renderer.render(window.scene, window.camera); + + // Read back matrix data from texture + // Matrix is stored as 4 vec4s (16 floats) per instance + // Position is in the 4th column: indices 12, 13, 14 (x, y, z) + const data = mesh.matricesTexture._data; + const stride = 16; // 4x4 matrix = 16 floats + + const pos0 = [data[0 * stride + 12], data[0 * stride + 13], data[0 * stride + 14]]; + const pos1 = [data[1 * stride + 12], data[1 * stride + 13], data[1 * stride + 14]]; + const pos2 = [data[2 * stride + 12], data[2 * stride + 13], data[2 * stride + 14]]; + + return { + hasTexture: mesh.matricesTexture !== null, + pos0, + pos1, + pos2 + }; + }); + + expect(result.hasTexture).toBe(true); + expect(result.pos0).toEqual([10, 0, 0]); + expect(result.pos1).toEqual([0, 20, 0]); + expect(result.pos2).toEqual([0, 0, 30]); + }); + + test('should use correct geometry for each LOD level', async ({ page }) => { + const result = await page.evaluate(() => { + const { SphereGeometry, BoxGeometry, MeshBasicMaterial } = window.THREE; + + const highGeo = new SphereGeometry(1, 32, 32); + const lowGeo = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = window.testHelpers.createMesh(highGeo, material); + mesh.addLOD(lowGeo, material, 50); + + mesh.addInstances(5, (obj) => { + obj.position.set(0, 0, -20); // All instances in front + }); + + window.testHelpers.addToScene(mesh); + + // Get geometry vertex counts for verification + const lod0Geometry = mesh.LODinfo.objects[0].geometry; + const lod1Geometry = mesh.LODinfo.objects[1].geometry; + + const lod0VertexCount = lod0Geometry.attributes.position.count; + const lod1VertexCount = lod1Geometry.attributes.position.count; + + // Verify geometries are different objects + const geometriesAreDifferent = lod0Geometry !== lod1Geometry; + + return { + lod0VertexCount, + lod1VertexCount, + geometriesAreDifferent, + lod0HasMoreVerts: lod0VertexCount > lod1VertexCount + }; + }); + + expect(result.geometriesAreDifferent).toBe(true); + expect(result.lod0HasMoreVerts).toBe(true); + // Sphere (32x32) has ~500+ vertices, Box has 24 + expect(result.lod0VertexCount).toBeGreaterThan(100); + expect(result.lod1VertexCount).toBeLessThan(50); + }); + + test('should store correct colors in colorsTexture', async ({ page }) => { + const result = await page.evaluate(() => { + const { Color } = window.THREE; + const mesh = window.createTestMesh({ count: 3, spread: 10 }); + + // Set distinct colors + mesh.setColorAt(0, new Color(1, 0, 0)); // Red + mesh.setColorAt(1, new Color(0, 1, 0)); // Green + mesh.setColorAt(2, new Color(0, 0, 1)); // Blue + + window.renderer.render(window.scene, window.camera); + + // Read back color data from texture + // Colors are stored as 4 floats per instance (RGBA) + const data = mesh.colorsTexture._data; + const stride = 4; // RGBA + + return { + hasTexture: mesh.colorsTexture !== null, + // Check R channel for red, G for green, B for blue + color0R: data[0 * stride + 0], + color1G: data[1 * stride + 1], + color2B: data[2 * stride + 2] + }; + }); + + expect(result.hasTexture).toBe(true); + // Colors should be close to 1.0 (may have slight precision differences) + expect(result.color0R).toBeGreaterThan(0.9); + expect(result.color1G).toBeGreaterThan(0.9); + expect(result.color2B).toBeGreaterThan(0.9); + }); + + test('should update texture data when instances change', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial, Matrix4 } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + const mesh = window.testHelpers.createMesh(geometry, material, 10); + + // Add instance with initial position + mesh.addInstances(1, (obj) => { + obj.position.set(5, 5, 5); + }); + + window.testHelpers.addToScene(mesh); + window.renderer.render(window.scene, window.camera); + + const data = mesh.matricesTexture._data; + const initialPos = [data[12], data[13], data[14]]; + + // Update position using setMatrixAt + const newMatrix = new Matrix4(); + newMatrix.setPosition(100, 200, 300); + mesh.setMatrixAt(0, newMatrix); + window.renderer.render(window.scene, window.camera); + + const updatedPos = [data[12], data[13], data[14]]; + + return { + initialPos, + updatedPos, + positionChanged: initialPos[0] !== updatedPos[0] + }; + }); + + expect(result.initialPos).toEqual([5, 5, 5]); + expect(result.updatedPos).toEqual([100, 200, 300]); + expect(result.positionChanged).toBe(true); + }); + + test('should have correct instance indices after frustum culling', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + const mesh = window.testHelpers.createMesh(geometry, material); + + // Camera at z=50 looking at origin + window.testHelpers.setupCamera({ x: 0, y: 0, z: 50 }); + + // Create instances: some in view, some out + mesh.addInstances(6, (obj, index) => { + switch (index) { + case 0: obj.position.set(0, 0, 0); break; // In view + case 1: obj.position.set(5, 0, 10); break; // In view + case 2: obj.position.set(0, 0, 200); break; // Behind camera - culled + case 3: obj.position.set(0, 0, 20); break; // In view + case 4: obj.position.set(500, 0, 0); break; // Far side - culled + case 5: obj.position.set(-5, 5, 0); break; // In view + } + }); + + window.testHelpers.addToScene(mesh); + + mesh.performFrustumCulling(window.camera); + window.renderer.render(window.scene, window.camera); + + const info = window.testHelpers.getRenderedInfo(mesh); + + return { + totalInstances: mesh.instancesCount, + renderedCount: info.count, + renderedIndices: [...info.ids].sort((a, b) => a - b) + }; + }); + + expect(result.totalInstances).toBe(6); + expect(result.renderedCount).toBe(4); + // Instances 0, 1, 3, 5 should be visible + expect(result.renderedIndices).toEqual([0, 1, 3, 5]); + }); +}); + +/** + * Rendering Output Verification Tests + * + * These tests verify that rendering ACTUALLY produces visible output, + * not just that CPU-side state is correct. Uses draw call verification + * and GL error checks to ensure rendering works end-to-end. + * + * Note: Pixel readback tests are limited because the test fixture + * renderer doesn't use preserveDrawingBuffer. Instead, we verify + * rendering via draw call counts and triangles rendered. + */ +test.describe('Rendering Output Verification', () => { + test.beforeEach(async ({ page }) => { + await setupScene(page); + }); + + test('should execute draw calls when rendering', async ({ page }) => { + const result = await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 10, spread: 10 }); + window.renderer.info.reset(); + + window.renderer.render(window.scene, window.camera); + + return { + drawCalls: window.renderer.info.render.calls, + triangles: window.renderer.info.render.triangles, + meshCount: mesh.count + }; + }); + + expect(result.drawCalls).toBeGreaterThan(0); + expect(result.triangles).toBeGreaterThan(0); + expect(result.meshCount).toBeGreaterThan(0); + }); + + test('should render more triangles with more instances', async ({ page }) => { + const result = await page.evaluate(() => { + // First render with few instances + const mesh1 = window.createTestMesh({ count: 5, spread: 10 }); + window.renderer.info.reset(); + window.renderer.render(window.scene, window.camera); + const triangles5 = window.renderer.info.render.triangles; + + // Clean up + window.scene.remove(mesh1); + + // Second render with more instances + const mesh2 = window.createTestMesh({ count: 50, spread: 10 }); + window.renderer.info.reset(); + window.renderer.render(window.scene, window.camera); + const triangles50 = window.renderer.info.render.triangles; + + return { triangles5, triangles50 }; + }); + + // More instances = more triangles rendered + expect(result.triangles50).toBeGreaterThan(result.triangles5); + }); + + test('should render zero triangles when all instances culled', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial, PerspectiveCamera } = window.THREE; + + // Clear scene + while (window.scene.children.length > 0) { + window.scene.remove(window.scene.children[0]); + } + + // Camera at origin looking at -Z + const camera = new PerspectiveCamera(75, 1, 0.1, 100); + camera.position.set(0, 0, 0); + camera.lookAt(0, 0, -1); + camera.updateMatrixWorld(); + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0xff0000 }); + const mesh = window.testHelpers.createMesh(geometry, material, 10); + + // All instances BEHIND camera (positive Z) + mesh.addInstances(10, (obj) => { + obj.position.set(0, 0, 100); // Behind camera + }); + + window.testHelpers.addToScene(mesh); + + mesh.performFrustumCulling(camera); + window.renderer.info.reset(); + window.renderer.render(window.scene, camera); + + return { + triangles: window.renderer.info.render.triangles, + meshCount: mesh.count, + instancesCount: mesh.instancesCount + }; + }); + + // All instances are behind camera, should be culled + expect(result.meshCount).toBe(0); + expect(result.triangles).toBe(0); + }); + + test('should render triangles when instances in view', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial, PerspectiveCamera } = window.THREE; + + // Clear scene + while (window.scene.children.length > 0) { + window.scene.remove(window.scene.children[0]); + } + + // Camera at origin looking at -Z + const camera = new PerspectiveCamera(75, 1, 0.1, 100); + camera.position.set(0, 0, 10); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(); + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0xff0000 }); + const mesh = window.testHelpers.createMesh(geometry, material, 10); + + // All instances IN FRONT of camera + mesh.addInstances(10, (obj, index) => { + obj.position.set((index - 5) * 2, 0, 0); // Spread in view + }); + + window.testHelpers.addToScene(mesh); + + mesh.performFrustumCulling(camera); + window.renderer.info.reset(); + window.renderer.render(window.scene, camera); + + return { + triangles: window.renderer.info.render.triangles, + meshCount: mesh.count, + instancesCount: mesh.instancesCount + }; + }); + + // Instances in view should render triangles + expect(result.meshCount).toBeGreaterThan(0); + expect(result.triangles).toBeGreaterThan(0); + // Box has 12 triangles, so 10 boxes = 120 triangles + expect(result.triangles).toBeGreaterThanOrEqual(result.meshCount * 12); + }); + + test('should render successfully without exceptions', async ({ page }) => { + const result = await page.evaluate(() => { + try { + // Use the standard createTestMesh which we know works + const mesh = window.createTestMesh({ count: 10, spread: 10 }); + window.renderer.info.reset(); + window.renderer.render(window.scene, window.camera); + + return { + success: true, + meshCount: mesh.count, + triangles: window.renderer.info.render.triangles, + error: null + }; + } catch (e) { + return { + success: false, + meshCount: 0, + triangles: 0, + error: (e as Error).message + }; + } + }); + + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + expect(result.meshCount).toBeGreaterThan(0); + expect(result.triangles).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e/test-utils.ts b/tests/e2e/test-utils.ts new file mode 100644 index 0000000..c7fb51e --- /dev/null +++ b/tests/e2e/test-utils.ts @@ -0,0 +1,132 @@ +/** + * Shared E2E test utilities + * + * Reduces code duplication across test files by providing + * common setup and helper functions. + */ + +import type { Page } from '@playwright/test'; + +/** + * Helper functions to be used inside page.evaluate() + * These are defined as strings to be injected into the browser context + */ + +// Standard geometries and materials setup +export const BROWSER_HELPERS = ` + window.testHelpers = { + // Create standard geometries + createGeometries: () => { + const { BoxGeometry, SphereGeometry } = window.THREE; + return { + box: new BoxGeometry(1, 1, 1), + highSphere: new SphereGeometry(0.5, 32, 32), + midSphere: new SphereGeometry(0.5, 16, 16) + }; + }, + + // Create standard material + createMaterial: (color = 0x00ff00) => { + const { MeshBasicMaterial } = window.THREE; + return new MeshBasicMaterial({ color }); + }, + + // Create InstancedMesh2 with standard options + createMesh: (geometry, material, capacity = 100) => { + return new window.InstancedMesh2(geometry, material, { + capacity, + renderer: window.renderer + }); + }, + + // Create a LOD-enabled mesh + createLODMesh: (lodDistance = 50, capacity = 100) => { + const { BoxGeometry, SphereGeometry, MeshBasicMaterial } = window.THREE; + + const highGeometry = new SphereGeometry(0.5, 32, 32); + const lowGeometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(highGeometry, material, { + capacity, + renderer: window.renderer + }); + + mesh.addLOD(lowGeometry, material, lodDistance); + return mesh; + }, + + // Create a 3-level LOD mesh + createMultiLODMesh: (midDistance = 30, farDistance = 60, capacity = 100) => { + const { BoxGeometry, SphereGeometry, MeshBasicMaterial } = window.THREE; + + const highGeometry = new SphereGeometry(0.5, 32, 32); + const midGeometry = new SphereGeometry(0.5, 16, 16); + const lowGeometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(highGeometry, material, { + capacity, + renderer: window.renderer + }); + + mesh.addLOD(midGeometry, material, midDistance); + mesh.addLOD(lowGeometry, material, farDistance); + return mesh; + }, + + // Setup camera at position looking at target + setupCamera: (position, target = { x: 0, y: 0, z: 0 }, options = {}) => { + const { fov = 75, near = 0.1, far = 1000 } = options; + window.camera.position.set(position.x, position.y, position.z); + window.camera.lookAt(target.x, target.y, target.z); + if (fov !== undefined) window.camera.fov = fov; + if (near !== undefined) window.camera.near = near; + if (far !== undefined) window.camera.far = far; + window.camera.updateProjectionMatrix(); + window.camera.updateMatrixWorld(); + }, + + // Extract LOD info from mesh + getLODInfo: (mesh, levelCount = 2) => { + const result = { counts: [], ids: [] }; + for (let i = 0; i < levelCount; i++) { + const count = mesh.LODinfo.objects[i].count; + const ids = Array.from(mesh.LODinfo.objects[i].instanceIndex.array.slice(0, count)); + result.counts.push(count); + result.ids.push(ids); + } + return result; + }, + + // Extract rendered instance info from mesh + getRenderedInfo: (mesh) => { + const count = mesh.count; + const ids = Array.from(mesh.instanceIndex.array.slice(0, count)); + return { count, ids }; + }, + + // Add mesh to scene and store reference + addToScene: (mesh) => { + window.scene.add(mesh); + window.testMesh = mesh; + return mesh; + } + }; +`; + +/** + * Initialize browser helpers in the page + */ +export async function initBrowserHelpers(page: Page): Promise { + await page.evaluate(BROWSER_HELPERS); +} + +/** + * Common test setup that injects helpers and waits for scene ready + */ +export async function setupTestScene(page: Page): Promise { + await page.goto('/tests/fixtures/test-scene.html'); + await page.waitForFunction(() => window.sceneReady === true); + await initBrowserHelpers(page); +} diff --git a/tests/features/bvh.test.ts b/tests/features/bvh.test.ts new file mode 100644 index 0000000..4d3c1b3 --- /dev/null +++ b/tests/features/bvh.test.ts @@ -0,0 +1,306 @@ +/** + * Tests for BVH Spatial Indexing feature + * + * Validates: + * - computeBVH() creates valid structure + * - insert/move/delete operations update BVH correctly + * - intersectBox() finds correct instances + * - BVH integration with frustum culling + * - disposeBVH() cleanup + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { Box3, Matrix4, Vector3 } from 'three'; +import { createTestInstancedMesh } from '../setup'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2'; + +describe('BVH Spatial Indexing', () => { + let mesh: InstancedMesh2; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + }); + + describe('computeBVH', () => { + it('should create BVH structure', () => { + mesh.addInstances(10, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + + mesh.computeBVH(); + + expect(mesh.bvh).not.toBeNull(); + }); + + it('should create BVH with margin option', () => { + mesh.addInstances(10, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + + mesh.computeBVH({ margin: 1 }); + + expect(mesh.bvh).not.toBeNull(); + expect(mesh.bvh['_margin']).toBe(1); + }); + + it('should create BVH with getBBoxFromBSphere option', () => { + mesh.addInstances(10, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + + mesh.computeBVH({ getBBoxFromBSphere: true }); + + expect(mesh.bvh).not.toBeNull(); + }); + + it('should rebuild BVH when called multiple times', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + mesh.computeBVH(); + const firstBVH = mesh.bvh; + + mesh.addInstances(5, (obj, index) => { + obj.position.set(index + 100, 0, 0); + }); + + mesh.computeBVH(); + + expect(mesh.bvh).toBe(firstBVH); // Same BVH instance, rebuilt + }); + + it('should populate nodesMap with instance nodes', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + + mesh.computeBVH(); + + expect(mesh.bvh.nodesMap.size).toBe(5); + }); + }); + + describe('disposeBVH', () => { + it('should set bvh to null', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + mesh.computeBVH(); + + mesh.disposeBVH(); + + expect(mesh.bvh).toBeNull(); + }); + }); + + describe('BVH insert', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + mesh.computeBVH(); + }); + + it('should insert new instances into BVH', () => { + const initialSize = mesh.bvh.nodesMap.size; + + mesh.addInstances(3, (obj, index) => { + obj.position.set(index * 10 + 100, 0, 0); + }); + + expect(mesh.bvh.nodesMap.size).toBe(initialSize + 3); + }); + + it('should have node for each active instance', () => { + mesh.addInstances(2, (obj, index) => { + obj.position.set(200, 0, 0); + }); + + // Check that all active instances have nodes + for (let i = 0; i < mesh.instancesCount; i++) { + if (mesh.getActiveAt(i)) { + expect(mesh.bvh.nodesMap.has(i)).toBe(true); + } + } + }); + }); + + describe('BVH move', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + mesh.computeBVH(); + }); + + it('should update BVH when instance matrix changes', () => { + const node = mesh.bvh.nodesMap.get(0); + const originalBox = [...node.box]; + + // Move instance to new position + mesh.setMatrixAt(0, new Matrix4().setPosition(100, 100, 100)); + + // BVH node box should be updated + expect(node.box).not.toEqual(originalBox); + }); + + it('should handle move for non-existent node gracefully', () => { + expect(() => mesh.bvh.move(999)).not.toThrow(); + }); + }); + + describe('BVH delete', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + mesh.computeBVH(); + }); + + it('should remove node from BVH when instance removed', () => { + expect(mesh.bvh.nodesMap.has(2)).toBe(true); + + mesh.removeInstances(2); + + expect(mesh.bvh.nodesMap.has(2)).toBe(false); + }); + + it('should decrease nodesMap size when instance removed', () => { + const initialSize = mesh.bvh.nodesMap.size; + + mesh.removeInstances(0, 1); + + expect(mesh.bvh.nodesMap.size).toBe(initialSize - 2); + }); + + it('should handle delete for non-existent node gracefully', () => { + expect(() => mesh.bvh.delete(999)).not.toThrow(); + }); + }); + + describe('BVH clear', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + mesh.computeBVH(); + }); + + it('should clear BVH when clearInstances called', () => { + mesh.clearInstances(); + + expect(mesh.bvh.nodesMap.size).toBe(0); + }); + }); + + describe('intersectBox', () => { + beforeEach(() => { + // Create instances spread out in space + mesh.addInstances(9, (obj, index) => { + const x = (index % 3) * 20; + const y = Math.floor(index / 3) * 20; + obj.position.set(x, y, 0); + }); + mesh.computeBVH(); + }); + + it('should find instances within box', () => { + const box = new Box3( + new Vector3(-5, -5, -5), + new Vector3(25, 5, 5) + ); + + const found: number[] = []; + mesh.bvh.intersectBox(box, (index) => { + found.push(index); + return false; // Continue searching + }); + + // Should find instances at x=0, x=20 (y=0 row) + expect(found.length).toBeGreaterThan(0); + }); + + it('should return true when intersection found and callback returns true', () => { + const box = new Box3( + new Vector3(-5, -5, -5), + new Vector3(5, 5, 5) + ); + + const result = mesh.bvh.intersectBox(box, () => true); + + expect(result).toBe(true); + }); + + it('should return false when no intersection found', () => { + const box = new Box3( + new Vector3(1000, 1000, 1000), + new Vector3(1100, 1100, 1100) + ); + + const found: number[] = []; + const result = mesh.bvh.intersectBox(box, (index) => { + found.push(index); + return false; + }); + + expect(found.length).toBe(0); + expect(result).toBe(false); + }); + + it('should stop early when callback returns true', () => { + const box = new Box3( + new Vector3(-100, -100, -100), + new Vector3(100, 100, 100) + ); + + let callCount = 0; + mesh.bvh.intersectBox(box, () => { + callCount++; + return true; // Stop after first hit + }); + + expect(callCount).toBe(1); + }); + }); + + describe('BVH with frustum culling', () => { + beforeEach(() => { + mesh.addInstances(10, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + mesh.computeBVH(); + }); + + it('should use BVH for culling when available', () => { + expect(mesh.bvh).not.toBeNull(); + // The BVHCulling method should be used when bvh exists + // This is more thoroughly tested in e2e tests + }); + + it('should have accurateCulling enabled by default', () => { + expect(mesh.bvh.accurateCulling).toBe(true); + }); + + it('should allow disabling accurateCulling', () => { + mesh.disposeBVH(); + mesh.computeBVH({ accurateCulling: false }); + + expect(mesh.bvh.accurateCulling).toBe(false); + }); + }); + + describe('geoBoundingBox', () => { + it('should store geometry bounding box', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + mesh.computeBVH(); + + expect(mesh.bvh.geoBoundingBox).not.toBeNull(); + expect(mesh.bvh.geoBoundingBox.min).toBeDefined(); + expect(mesh.bvh.geoBoundingBox.max).toBeDefined(); + }); + }); +}); + diff --git a/tests/features/dynamic-capacity.test.ts b/tests/features/dynamic-capacity.test.ts new file mode 100644 index 0000000..d8ae33c --- /dev/null +++ b/tests/features/dynamic-capacity.test.ts @@ -0,0 +1,260 @@ +/** + * Tests for Dynamic Capacity feature + * + * Validates: + * - Adding instances up to and beyond capacity + * - Auto-expanding buffers when capacity exceeded + * - Removing instances by ID + * - Clearing all instances + * - Reusing freed instance slots + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestInstancedMesh, createTestInstancedMeshWithEntities } from '../setup'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2'; + +describe('Dynamic Capacity', () => { + let mesh: InstancedMesh2; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 10 }); + }); + + describe('addInstances', () => { + it('should add instances within capacity', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + expect(mesh.instancesCount).toBe(5); + expect(mesh.capacity).toBe(10); + }); + + it('should add instances up to exact capacity', () => { + mesh.addInstances(10, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + expect(mesh.instancesCount).toBe(10); + expect(mesh.capacity).toBe(10); + }); + + it('should auto-expand buffer when exceeding capacity', () => { + mesh.addInstances(15, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + expect(mesh.instancesCount).toBe(15); + expect(mesh.capacity).toBeGreaterThan(10); + }); + + it('should set identity matrix when no callback provided', () => { + mesh.addInstances(3); + + expect(mesh.instancesCount).toBe(3); + + // Check that matrices are identity + const matrix = mesh.getMatrixAt(0); + expect(matrix.elements[0]).toBe(1); + expect(matrix.elements[5]).toBe(1); + expect(matrix.elements[10]).toBe(1); + expect(matrix.elements[15]).toBe(1); + }); + + it('should call onCreation callback with correct index', () => { + const indices: number[] = []; + + mesh.addInstances(5, (obj, index) => { + indices.push(index); + }); + + expect(indices).toEqual([0, 1, 2, 3, 4]); + }); + + it('should allow setting position in callback', () => { + mesh.addInstances(3, (obj, index) => { + obj.position.set(index * 10, index * 20, index * 30); + }); + + const pos0 = mesh.getPositionAt(0); + expect(pos0.x).toBe(0); + expect(pos0.y).toBe(0); + expect(pos0.z).toBe(0); + + const pos2 = mesh.getPositionAt(2); + expect(pos2.x).toBe(20); + expect(pos2.y).toBe(40); + expect(pos2.z).toBe(60); + }); + }); + + describe('removeInstances', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + it('should remove a single instance by ID', () => { + mesh.removeInstances(2); + + expect(mesh.instancesCount).toBe(4); + expect(mesh.getActiveAt(2)).toBe(false); + }); + + it('should remove multiple instances', () => { + mesh.removeInstances(1, 3); + + expect(mesh.instancesCount).toBe(3); + expect(mesh.getActiveAt(1)).toBe(false); + expect(mesh.getActiveAt(3)).toBe(false); + }); + + it('should not throw when removing non-existent instance', () => { + expect(() => mesh.removeInstances(99)).not.toThrow(); + expect(mesh.instancesCount).toBe(5); + }); + + it('should not remove already removed instance', () => { + mesh.removeInstances(2); + mesh.removeInstances(2); + + expect(mesh.instancesCount).toBe(4); + }); + + it('should keep other instances active after removal', () => { + mesh.removeInstances(2); + + expect(mesh.getActiveAt(0)).toBe(true); + expect(mesh.getActiveAt(1)).toBe(true); + expect(mesh.getActiveAt(3)).toBe(true); + expect(mesh.getActiveAt(4)).toBe(true); + }); + }); + + describe('clearInstances', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + it('should remove all instances', () => { + mesh.clearInstances(); + + expect(mesh.instancesCount).toBe(0); + }); + + it('should allow adding new instances after clearing', () => { + mesh.clearInstances(); + mesh.addInstances(3, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + expect(mesh.instancesCount).toBe(3); + }); + }); + + describe('slot reuse', () => { + it('should reuse freed slots when adding new instances', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 100, 0, 0); + }); + + // Remove instance at index 2 + mesh.removeInstances(2); + expect(mesh.instancesCount).toBe(4); + + // Add a new instance - should reuse slot 2 + mesh.addInstances(1, (obj, index) => { + obj.position.set(999, 0, 0); + }); + + expect(mesh.instancesCount).toBe(5); + + // The new instance should be at the freed slot + const pos = mesh.getPositionAt(2); + expect(pos.x).toBe(999); + }); + + it('should reuse multiple freed slots', () => { + mesh.addInstances(5); + mesh.removeInstances(1, 3); + expect(mesh.instancesCount).toBe(3); + + mesh.addInstances(2); + expect(mesh.instancesCount).toBe(5); + }); + }); + + describe('resizeBuffers', () => { + it('should increase capacity', () => { + mesh.resizeBuffers(50); + + expect(mesh.capacity).toBe(50); + }); + + it('should preserve existing instances when expanding', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + + mesh.resizeBuffers(50); + + expect(mesh.instancesCount).toBe(5); + + const pos2 = mesh.getPositionAt(2); + expect(pos2.x).toBe(20); + }); + + it('should allow reducing capacity', () => { + mesh.addInstances(3); + mesh.resizeBuffers(5); + + expect(mesh.capacity).toBe(5); + expect(mesh.instancesCount).toBe(3); + }); + }); + + describe('with entities enabled', () => { + let meshWithEntities: InstancedMesh2; + + beforeEach(() => { + meshWithEntities = createTestInstancedMeshWithEntities(10); + }); + + it('should create entity objects when adding instances', () => { + meshWithEntities.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + expect(meshWithEntities.instances).not.toBeNull(); + expect(meshWithEntities.instances.length).toBeGreaterThanOrEqual(5); + }); + + it('should allow accessing instances array', () => { + meshWithEntities.addInstances(3, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + + const instance = meshWithEntities.instances[1]; + expect(instance.position.x).toBe(10); + }); + + it('should update entity when using instances array', () => { + meshWithEntities.addInstances(3, (obj, index) => { + obj.position.set(0, 0, 0); + }); + + const instance = meshWithEntities.instances[0]; + instance.position.set(100, 200, 300); + instance.updateMatrix(); + + const pos = meshWithEntities.getPositionAt(0); + expect(pos.x).toBe(100); + expect(pos.y).toBe(200); + expect(pos.z).toBe(300); + }); + }); +}); + diff --git a/tests/features/frustum-culling.test.ts b/tests/features/frustum-culling.test.ts new file mode 100644 index 0000000..2785e36 --- /dev/null +++ b/tests/features/frustum-culling.test.ts @@ -0,0 +1,231 @@ +/** + * Tests for Per-instance Frustum Culling feature + * + * Validates: + * - perObjectFrustumCulled property toggle + * - Index array updates based on visibility + * - onFrustumEnter callback behavior + * - Integration with visibility state + * + * Note: Full frustum intersection tests are in e2e/frustum-culling.spec.ts + * since they require a real camera and WebGL context. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { PerspectiveCamera } from 'three'; +import { createTestInstancedMesh } from '../setup'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2'; + +describe('Frustum Culling', () => { + let mesh: InstancedMesh2; + let camera: PerspectiveCamera; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + camera = new PerspectiveCamera(75, 1, 0.1, 1000); + camera.position.set(0, 0, 10); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(); + }); + + describe('perObjectFrustumCulled property', () => { + it('should default to true', () => { + expect(mesh.perObjectFrustumCulled).toBe(true); + }); + + it('should be settable to false', () => { + mesh.perObjectFrustumCulled = false; + expect(mesh.perObjectFrustumCulled).toBe(false); + }); + + it('should mark index array for update when changed', () => { + mesh.addInstances(5); + mesh.perObjectFrustumCulled = false; + expect(mesh['_indexArrayNeedsUpdate']).toBe(true); + }); + }); + + describe('updateIndexArray', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + it('should include all visible and active instances', () => { + mesh['_indexArrayNeedsUpdate'] = true; + mesh.updateIndexArray(); + + expect(mesh.count).toBe(5); + }); + + it('should exclude hidden instances', () => { + mesh.setVisibilityAt(2, false); + mesh['_indexArrayNeedsUpdate'] = true; + mesh.updateIndexArray(); + + expect(mesh.count).toBe(4); + }); + + it('should exclude removed instances', () => { + mesh.removeInstances(1, 3); + mesh['_indexArrayNeedsUpdate'] = true; + mesh.updateIndexArray(); + + expect(mesh.count).toBe(3); + }); + + it('should not update if flag is false', () => { + mesh['_indexArrayNeedsUpdate'] = false; + const originalCount = mesh.count; + mesh.setVisibilityAt(0, false); + mesh['_indexArrayNeedsUpdate'] = false; // Reset flag + mesh.updateIndexArray(); + + // Count should not change since flag was false + expect(mesh.count).toBe(originalCount); + }); + }); + + describe('performFrustumCulling', () => { + it('should set count to 0 when no instances exist', () => { + mesh.performFrustumCulling(camera); + expect(mesh.count).toBe(0); + }); + + it('should process instances when they exist', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(0, 0, 0); // All at origin, in front of camera + }); + + mesh.perObjectFrustumCulled = false; + mesh.performFrustumCulling(camera); + + expect(mesh.count).toBe(5); + }); + + it('should respect visibility when culling is disabled', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(0, 0, 0); + }); + mesh.setVisibilityAt(2, false); + + mesh.perObjectFrustumCulled = false; + mesh.performFrustumCulling(camera); + + expect(mesh.count).toBe(4); + }); + }); + + describe('onFrustumEnter callback', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(0, 0, 0); + }); + }); + + it('should allow setting onFrustumEnter callback', () => { + const callback = vi.fn(() => true); + mesh.onFrustumEnter = callback; + + expect(mesh.onFrustumEnter).toBe(callback); + }); + + it('should be called during linear culling when set', () => { + const callback = vi.fn(() => true); + mesh.onFrustumEnter = callback; + + // Trigger linear culling (no BVH) + mesh.linearCulling(camera); + + // Callback should be invoked for each visible instance in frustum + expect(callback).toHaveBeenCalled(); + }); + + it('should filter instances when callback returns false', () => { + // Only allow even indices + mesh.onFrustumEnter = (index) => index % 2 === 0; + + mesh.linearCulling(camera); + + // Only indices 0, 2, 4 should pass (3 instances) + expect(mesh.count).toBe(3); + }); + + it('should receive correct parameters', () => { + const callback = vi.fn(() => true); + mesh.onFrustumEnter = callback; + + mesh.linearCulling(camera); + + // First call should have index and camera + expect(callback).toHaveBeenCalledWith( + expect.any(Number), + camera + ); + }); + }); + + describe('autoUpdate property', () => { + it('should default to true', () => { + expect(mesh.autoUpdate).toBe(true); + }); + + it('should be settable', () => { + mesh.autoUpdate = false; + expect(mesh.autoUpdate).toBe(false); + }); + }); + + describe('raycastOnlyFrustum property', () => { + it('should default to false', () => { + expect(mesh.raycastOnlyFrustum).toBe(false); + }); + + it('should be settable', () => { + mesh.raycastOnlyFrustum = true; + expect(mesh.raycastOnlyFrustum).toBe(true); + }); + }); + + describe('integration with visibility', () => { + beforeEach(() => { + mesh.addInstances(10, (obj, index) => { + obj.position.set(0, 0, 0); + }); + }); + + it('should skip culling for hidden instances', () => { + // Hide half the instances + for (let i = 0; i < 5; i++) { + mesh.setVisibilityAt(i, false); + } + + mesh.perObjectFrustumCulled = false; + mesh.performFrustumCulling(camera); + + expect(mesh.count).toBe(5); + }); + + it('should skip culling for removed instances', () => { + mesh.removeInstances(0, 1, 2); + + mesh.perObjectFrustumCulled = false; + mesh.performFrustumCulling(camera); + + expect(mesh.count).toBe(7); + }); + + it('should handle mixed visibility and removal', () => { + mesh.removeInstances(0, 1); + mesh.setVisibilityAt(5, false); + mesh.setVisibilityAt(6, false); + + mesh.perObjectFrustumCulled = false; + mesh.performFrustumCulling(camera); + + expect(mesh.count).toBe(6); + }); + }); +}); + diff --git a/tests/features/lod.test.ts b/tests/features/lod.test.ts new file mode 100644 index 0000000..af09562 --- /dev/null +++ b/tests/features/lod.test.ts @@ -0,0 +1,286 @@ +/** + * Tests for Level of Detail (LOD) feature + * + * Validates: + * - addLOD() registers levels correctly + * - setFirstLODDistance() configuration + * - addShadowLOD() for shadow-specific LODs + * - getObjectLODIndexForDistance() returns correct level + * - LOD level sorting and distance thresholds + * + * Note: Actual distance-based rendering tests are in e2e/lod-switching.spec.ts + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { BoxGeometry, MeshBasicMaterial, SphereGeometry } from 'three'; +import { createTestInstancedMesh } from '../setup'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2'; + +describe('Level of Detail (LOD)', () => { + let mesh: InstancedMesh2; + let lowPolyGeometry: BoxGeometry; + let midPolyGeometry: SphereGeometry; + let material: MeshBasicMaterial; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + lowPolyGeometry = new BoxGeometry(1, 1, 1, 1, 1, 1); + midPolyGeometry = new SphereGeometry(0.5, 8, 8); + material = new MeshBasicMaterial({ color: 0x00ff00 }); + }); + + describe('setFirstLODDistance', () => { + it('should initialize LODinfo structure', () => { + mesh.setFirstLODDistance(0, 0); + + expect(mesh.LODinfo).not.toBeNull(); + expect(mesh.LODinfo.render).not.toBeNull(); + expect(mesh.LODinfo.render.levels).toHaveLength(1); + }); + + it('should set first LOD with default values', () => { + mesh.setFirstLODDistance(0); + + const firstLevel = mesh.LODinfo.render.levels[0]; + expect(firstLevel.distance).toBe(0); + expect(firstLevel.hysteresis).toBe(0); + expect(firstLevel.object).toBe(mesh); + }); + + it('should set first LOD with custom distance', () => { + mesh.setFirstLODDistance(100); + + const firstLevel = mesh.LODinfo.render.levels[0]; + expect(firstLevel.distance).toBe(100); + // Note: hysteresis is always 0 at first level, as per implementation + expect(firstLevel.hysteresis).toBe(0); + }); + + it('should be chainable', () => { + const result = mesh.setFirstLODDistance(); + expect(result).toBe(mesh); + }); + + it('should include mesh in objects list', () => { + mesh.setFirstLODDistance(); + + expect(mesh.LODinfo.objects).toContain(mesh); + }); + }); + + describe('addLOD', () => { + it('should add LOD level with specified distance', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + + expect(mesh.LODinfo.render.levels).toHaveLength(2); + }); + + it('should throw when adding LOD at distance 0 without setFirstLODDistance', () => { + expect(() => mesh.addLOD(lowPolyGeometry, material, 0)).toThrow(); + }); + + it('should store squared distance internally', () => { + mesh.addLOD(lowPolyGeometry, material, 10); + + // Distance is squared: 10^2 = 100 + const lodLevel = mesh.LODinfo.render.levels[1]; + expect(lodLevel.distance).toBe(100); + }); + + it('should add multiple LOD levels in correct order', () => { + mesh.addLOD(midPolyGeometry, material, 50); + mesh.addLOD(lowPolyGeometry, material, 100); + + const levels = mesh.LODinfo.render.levels; + expect(levels).toHaveLength(3); + + // Levels should be sorted by distance (ascending, squared) + expect(levels[0].distance).toBe(0); + expect(levels[1].distance).toBe(2500); // 50^2 + expect(levels[2].distance).toBe(10000); // 100^2 + }); + + it('should create new InstancedMesh2 for each LOD geometry', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + mesh.addLOD(midPolyGeometry, material, 100); + + expect(mesh.LODinfo.objects).toHaveLength(3); + }); + + it('should reuse existing InstancedMesh2 for same geometry', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + mesh.addLOD(lowPolyGeometry, material, 100); + + // Should only create one additional object for lowPolyGeometry + expect(mesh.LODinfo.objects).toHaveLength(2); + }); + + it('should set hysteresis value', () => { + mesh.addLOD(lowPolyGeometry, material, 50, 0.2); + + const lodLevel = mesh.LODinfo.render.levels[1]; + expect(lodLevel.hysteresis).toBe(0.2); + }); + + it('should be chainable', () => { + const result = mesh.addLOD(lowPolyGeometry, material, 50); + expect(result).toBe(mesh); + }); + + it('should add LOD objects as children', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + + expect(mesh.children.length).toBeGreaterThan(0); + }); + }); + + describe('addShadowLOD', () => { + it('should create shadow render list', () => { + mesh.addShadowLOD(lowPolyGeometry, 0); + + expect(mesh.LODinfo.shadowRender).not.toBeNull(); + expect(mesh.LODinfo.shadowRender.levels).toHaveLength(1); + }); + + it('should enable castShadow on mesh', () => { + mesh.addShadowLOD(lowPolyGeometry, 0); + + expect(mesh.castShadow).toBe(true); + }); + + it('should add multiple shadow LOD levels', () => { + mesh.addShadowLOD(midPolyGeometry, 0); + mesh.addShadowLOD(lowPolyGeometry, 100); + + expect(mesh.LODinfo.shadowRender.levels).toHaveLength(2); + }); + + it('should be chainable', () => { + const result = mesh.addShadowLOD(lowPolyGeometry, 50); + expect(result).toBe(mesh); + }); + }); + + describe('getObjectLODIndexForDistance', () => { + beforeEach(() => { + mesh.setFirstLODDistance(0); + mesh.addLOD(midPolyGeometry, material, 50); + mesh.addLOD(lowPolyGeometry, material, 100); + }); + + it('should return 0 for distance below first threshold', () => { + const levels = mesh.LODinfo.render.levels; + const index = mesh.getObjectLODIndexForDistance(levels, 100); // sqrt(100) = 10 + + expect(index).toBe(0); + }); + + it('should return correct index for mid-range distance', () => { + const levels = mesh.LODinfo.render.levels; + // 50^2 = 2500, need distance >= 2500 for index 1 + const index = mesh.getObjectLODIndexForDistance(levels, 3000); + + expect(index).toBe(1); + }); + + it('should return last index for distance beyond all thresholds', () => { + const levels = mesh.LODinfo.render.levels; + // 100^2 = 10000, need distance >= 10000 for index 2 + const index = mesh.getObjectLODIndexForDistance(levels, 15000); + + expect(index).toBe(2); + }); + + it('should handle exact threshold distance', () => { + const levels = mesh.LODinfo.render.levels; + // Exactly at threshold (2500 = 50^2) + const index = mesh.getObjectLODIndexForDistance(levels, 2500); + + expect(index).toBe(1); + }); + + it('should account for hysteresis in distance calculation', () => { + // Create new mesh with hysteresis + const meshWithHysteresis = createTestInstancedMesh({ capacity: 100 }); + meshWithHysteresis.setFirstLODDistance(0); + meshWithHysteresis.addLOD(lowPolyGeometry, material, 100, 0.1); // 10% hysteresis + + const levels = meshWithHysteresis.LODinfo.render.levels; + // Distance threshold is 100^2 = 10000 + // With 10% hysteresis: 10000 - (10000 * 0.1) = 9000 + + // At 9000, should still be level 1 (hysteresis reduces threshold) + const indexAtHysteresis = meshWithHysteresis.getObjectLODIndexForDistance(levels, 9000); + expect(indexAtHysteresis).toBe(1); + + // Below hysteresis threshold, should be level 0 + const indexBelowHysteresis = meshWithHysteresis.getObjectLODIndexForDistance(levels, 8000); + expect(indexBelowHysteresis).toBe(0); + }); + }); + + describe('LOD count tracking', () => { + beforeEach(() => { + mesh.setFirstLODDistance(0); + mesh.addLOD(lowPolyGeometry, material, 50); + }); + + it('should initialize count array', () => { + expect(mesh.LODinfo.render.count).toHaveLength(2); + }); + + it('should have count array match levels array length', () => { + mesh.addLOD(midPolyGeometry, material, 100); + + expect(mesh.LODinfo.render.count.length).toBe( + mesh.LODinfo.render.levels.length + ); + }); + }); + + describe('LOD texture sharing', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + mesh.addLOD(lowPolyGeometry, material, 50); + }); + + it('should share matricesTexture with child LOD objects', () => { + const lodObject = mesh.LODinfo.objects[1]; + + expect(lodObject.matricesTexture).toBe(mesh.matricesTexture); + }); + + it('should share colorsTexture with child LOD objects', () => { + // Initialize colors texture + mesh.setColorAt(0, 0xff0000); + + const lodObject = mesh.LODinfo.objects[1]; + expect(lodObject.colorsTexture).toBe(mesh.colorsTexture); + }); + }); + + describe('error handling', () => { + it('should throw when creating LOD on child LOD object', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + const lodChild = mesh.LODinfo.objects[1]; + + expect(() => lodChild.addLOD(midPolyGeometry, material, 100)).toThrow(); + }); + + it('should throw when setting first LOD distance on child', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + const lodChild = mesh.LODinfo.objects[1]; + + expect(() => lodChild.setFirstLODDistance(0)).toThrow(); + }); + + it('should throw when adding shadow LOD on child', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + const lodChild = mesh.LODinfo.objects[1]; + + expect(() => lodChild.addShadowLOD(midPolyGeometry)).toThrow(); + }); + }); +}); diff --git a/tests/features/sorting.test.ts b/tests/features/sorting.test.ts new file mode 100644 index 0000000..ebf9f54 --- /dev/null +++ b/tests/features/sorting.test.ts @@ -0,0 +1,251 @@ +/** + * Tests for Sorting feature + * + * Validates: + * - sortObjects property toggle + * - sortOpaque comparator (front-to-back) + * - sortTransparent comparator (back-to-front) + * - createRadixSort optimization + * - customSort callback integration + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { MeshBasicMaterial } from 'three'; +import { createTestInstancedMesh } from '../setup'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2'; +import { createRadixSort, sortOpaque, sortTransparent } from '../../src/utils/SortingUtils'; +import { InstancedRenderItem } from '../../src/core/utils/InstancedRenderList'; + +describe('Sorting', () => { + let mesh: InstancedMesh2; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + }); + + describe('sortObjects property', () => { + it('should default to false', () => { + expect(mesh.sortObjects).toBe(false); + }); + + it('should be settable to true', () => { + mesh.sortObjects = true; + expect(mesh.sortObjects).toBe(true); + }); + + it('should mark index array for update when changed', () => { + mesh.addInstances(5); + mesh.sortObjects = true; + expect(mesh['_indexArrayNeedsUpdate']).toBe(true); + }); + }); + + describe('sortOpaque', () => { + it('should sort front-to-back (smaller depth first)', () => { + const items: InstancedRenderItem[] = [ + { depth: 100, depthSort: 0, index: 0 }, + { depth: 50, depthSort: 0, index: 1 }, + { depth: 200, depthSort: 0, index: 2 }, + { depth: 10, depthSort: 0, index: 3 }, + ]; + + items.sort(sortOpaque); + + expect(items.map(i => i.depth)).toEqual([10, 50, 100, 200]); + }); + + it('should return negative when a.depth < b.depth', () => { + const a: InstancedRenderItem = { depth: 10, depthSort: 0, index: 0 }; + const b: InstancedRenderItem = { depth: 20, depthSort: 0, index: 1 }; + + expect(sortOpaque(a, b)).toBeLessThan(0); + }); + + it('should return positive when a.depth > b.depth', () => { + const a: InstancedRenderItem = { depth: 30, depthSort: 0, index: 0 }; + const b: InstancedRenderItem = { depth: 20, depthSort: 0, index: 1 }; + + expect(sortOpaque(a, b)).toBeGreaterThan(0); + }); + + it('should return 0 when depths are equal', () => { + const a: InstancedRenderItem = { depth: 50, depthSort: 0, index: 0 }; + const b: InstancedRenderItem = { depth: 50, depthSort: 0, index: 1 }; + + expect(sortOpaque(a, b)).toBe(0); + }); + }); + + describe('sortTransparent', () => { + it('should sort back-to-front (larger depth first)', () => { + const items: InstancedRenderItem[] = [ + { depth: 100, depthSort: 0, index: 0 }, + { depth: 50, depthSort: 0, index: 1 }, + { depth: 200, depthSort: 0, index: 2 }, + { depth: 10, depthSort: 0, index: 3 }, + ]; + + items.sort(sortTransparent); + + expect(items.map(i => i.depth)).toEqual([200, 100, 50, 10]); + }); + + it('should return positive when a.depth < b.depth', () => { + const a: InstancedRenderItem = { depth: 10, depthSort: 0, index: 0 }; + const b: InstancedRenderItem = { depth: 20, depthSort: 0, index: 1 }; + + expect(sortTransparent(a, b)).toBeGreaterThan(0); + }); + + it('should return negative when a.depth > b.depth', () => { + const a: InstancedRenderItem = { depth: 30, depthSort: 0, index: 0 }; + const b: InstancedRenderItem = { depth: 20, depthSort: 0, index: 1 }; + + expect(sortTransparent(a, b)).toBeLessThan(0); + }); + }); + + describe('createRadixSort', () => { + it('should create a radix sort function', () => { + const sortFn = createRadixSort(mesh); + + expect(typeof sortFn).toBe('function'); + }); + + it('should sort items by depthSort value', () => { + const sortFn = createRadixSort(mesh); + + const items: InstancedRenderItem[] = [ + { depth: 100, depthSort: 0, index: 0 }, + { depth: 50, depthSort: 0, index: 1 }, + { depth: 200, depthSort: 0, index: 2 }, + ]; + + sortFn(items); + + // After sorting, items should be ordered by depth (which determines depthSort) + const depths = items.map(i => i.depth); + expect(depths[0]).toBeLessThanOrEqual(depths[1]); + expect(depths[1]).toBeLessThanOrEqual(depths[2]); + }); + + it('should handle empty array', () => { + const sortFn = createRadixSort(mesh); + const items: InstancedRenderItem[] = []; + + expect(() => sortFn(items)).not.toThrow(); + }); + + it('should handle single item array', () => { + const sortFn = createRadixSort(mesh); + const items: InstancedRenderItem[] = [ + { depth: 100, depthSort: 0, index: 0 }, + ]; + + expect(() => sortFn(items)).not.toThrow(); + expect(items).toHaveLength(1); + }); + + it('should reverse order for transparent materials', () => { + const transparentMaterial = new MeshBasicMaterial({ + transparent: true, + opacity: 0.5 + }); + mesh.material = transparentMaterial; + + const sortFn = createRadixSort(mesh); + + const items: InstancedRenderItem[] = [ + { depth: 50, depthSort: 0, index: 0 }, + { depth: 100, depthSort: 0, index: 1 }, + { depth: 25, depthSort: 0, index: 2 }, + ]; + + sortFn(items); + + // For transparent, should be back-to-front (largest depth first) + const depths = items.map(i => i.depth); + expect(depths[0]).toBeGreaterThanOrEqual(depths[1]); + expect(depths[1]).toBeGreaterThanOrEqual(depths[2]); + }); + }); + + describe('customSort property', () => { + it('should default to null', () => { + expect(mesh.customSort).toBeNull(); + }); + + it('should accept custom sort function', () => { + const customFn = vi.fn(); + mesh.customSort = customFn; + + expect(mesh.customSort).toBe(customFn); + }); + + it('should accept radix sort function', () => { + const radixSort = createRadixSort(mesh); + mesh.customSort = radixSort; + + expect(mesh.customSort).toBe(radixSort); + }); + }); + + describe('sorting with visibility', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + }); + + it('should only sort visible instances', () => { + mesh.setVisibilityAt(2, false); + mesh.sortObjects = true; + + // The hidden instance should not be included in sorting + // This is tested more thoroughly in e2e tests + expect(mesh.getVisibilityAt(2)).toBe(false); + }); + }); + + describe('depthSort calculation', () => { + it('should compute depthSort based on depth range', () => { + const sortFn = createRadixSort(mesh); + + const items: InstancedRenderItem[] = [ + { depth: 100, depthSort: 0, index: 0 }, + { depth: 200, depthSort: 0, index: 1 }, + { depth: 300, depthSort: 0, index: 2 }, + ]; + + sortFn(items); + + // After sorting, depthSort should be computed (non-zero for normalized values) + // The actual values depend on the depth range normalization + expect(items[0].depthSort).toBeDefined(); + }); + }); + + describe('integration example', () => { + it('should demonstrate typical sorting setup', () => { + // Create mesh with transparent material + const transparentMaterial = new MeshBasicMaterial({ + transparent: true, + opacity: 0.5, + }); + mesh.material = transparentMaterial; + + // Add instances at various depths + mesh.addInstances(10, (obj, index) => { + obj.position.set(0, 0, index * 10); // Different z positions + }); + + // Enable sorting with radix sort optimization + mesh.sortObjects = true; + mesh.customSort = createRadixSort(mesh); + + expect(mesh.sortObjects).toBe(true); + expect(mesh.customSort).not.toBeNull(); + }); + }); +}); + diff --git a/tests/features/visibility.test.ts b/tests/features/visibility.test.ts new file mode 100644 index 0000000..54ca550 --- /dev/null +++ b/tests/features/visibility.test.ts @@ -0,0 +1,349 @@ +/** + * Tests for Per-instance Visibility and Opacity features + * + * Validates: + * - setVisibilityAt/getVisibilityAt methods + * - instances[i].visible property (with entities) + * - setOpacityAt/getOpacityAt methods + * - instances[i].opacity property (with entities) + * - setActiveAt/getActiveAt methods + * - Hidden instances excluded from render count + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { Color } from 'three'; +import { createTestInstancedMesh, createTestInstancedMeshWithEntities } from '../setup'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2'; + +describe('Per-instance Visibility', () => { + let mesh: InstancedMesh2; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + mesh.addInstances(10, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + describe('setVisibilityAt / getVisibilityAt', () => { + it('should default to true for new instances', () => { + expect(mesh.getVisibilityAt(0)).toBe(true); + expect(mesh.getVisibilityAt(5)).toBe(true); + }); + + it('should set visibility to false', () => { + mesh.setVisibilityAt(3, false); + + expect(mesh.getVisibilityAt(3)).toBe(false); + }); + + it('should set visibility back to true', () => { + mesh.setVisibilityAt(3, false); + mesh.setVisibilityAt(3, true); + + expect(mesh.getVisibilityAt(3)).toBe(true); + }); + + it('should not affect other instances', () => { + mesh.setVisibilityAt(5, false); + + expect(mesh.getVisibilityAt(4)).toBe(true); + expect(mesh.getVisibilityAt(6)).toBe(true); + }); + + it('should mark index array for update', () => { + mesh['_indexArrayNeedsUpdate'] = false; + mesh.setVisibilityAt(0, false); + + expect(mesh['_indexArrayNeedsUpdate']).toBe(true); + }); + }); + + describe('setActiveAt / getActiveAt', () => { + it('should default to true for new instances', () => { + expect(mesh.getActiveAt(0)).toBe(true); + expect(mesh.getActiveAt(5)).toBe(true); + }); + + it('should set active to false', () => { + mesh.setActiveAt(3, false); + + expect(mesh.getActiveAt(3)).toBe(false); + }); + + it('should mark index array for update', () => { + mesh['_indexArrayNeedsUpdate'] = false; + mesh.setActiveAt(0, false); + + expect(mesh['_indexArrayNeedsUpdate']).toBe(true); + }); + }); + + describe('getActiveAndVisibilityAt', () => { + it('should return true when both active and visible', () => { + expect(mesh.getActiveAndVisibilityAt(0)).toBe(true); + }); + + it('should return false when not visible', () => { + mesh.setVisibilityAt(0, false); + + expect(mesh.getActiveAndVisibilityAt(0)).toBe(false); + }); + + it('should return false when not active', () => { + mesh.setActiveAt(0, false); + + expect(mesh.getActiveAndVisibilityAt(0)).toBe(false); + }); + + it('should return false when neither visible nor active', () => { + mesh.setVisibilityAt(0, false); + mesh.setActiveAt(0, false); + + expect(mesh.getActiveAndVisibilityAt(0)).toBe(false); + }); + }); + + describe('setActiveAndVisibilityAt', () => { + it('should set both active and visible to true', () => { + mesh.setVisibilityAt(0, false); + mesh.setActiveAt(0, false); + + mesh.setActiveAndVisibilityAt(0, true); + + expect(mesh.getVisibilityAt(0)).toBe(true); + expect(mesh.getActiveAt(0)).toBe(true); + }); + + it('should set both active and visible to false', () => { + mesh.setActiveAndVisibilityAt(0, false); + + expect(mesh.getVisibilityAt(0)).toBe(false); + expect(mesh.getActiveAt(0)).toBe(false); + }); + }); + + describe('with entities enabled', () => { + let meshWithEntities: InstancedMesh2; + + beforeEach(() => { + meshWithEntities = createTestInstancedMeshWithEntities(100); + meshWithEntities.addInstances(10, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + it('should get visibility via instances[i].visible', () => { + expect(meshWithEntities.instances[0].visible).toBe(true); + }); + + it('should set visibility via instances[i].visible', () => { + meshWithEntities.instances[3].visible = false; + + expect(meshWithEntities.getVisibilityAt(3)).toBe(false); + }); + + it('should sync visibility between property and method', () => { + meshWithEntities.setVisibilityAt(5, false); + + expect(meshWithEntities.instances[5].visible).toBe(false); + }); + + it('should get active state via instances[i].active', () => { + expect(meshWithEntities.instances[0].active).toBe(true); + }); + + it('should set active state via instances[i].active', () => { + meshWithEntities.instances[3].active = false; + + expect(meshWithEntities.getActiveAt(3)).toBe(false); + }); + }); +}); + +describe('Per-instance Opacity', () => { + let mesh: InstancedMesh2; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + mesh.addInstances(10, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + describe('setOpacityAt / getOpacityAt', () => { + it('should default to 1 when opacity not initialized', () => { + expect(mesh.getOpacityAt(0)).toBe(1); + }); + + it('should set opacity value', () => { + mesh.setOpacityAt(3, 0.5); + + expect(mesh.getOpacityAt(3)).toBe(0.5); + }); + + it('should set opacity to 0', () => { + mesh.setOpacityAt(0, 0); + + expect(mesh.getOpacityAt(0)).toBe(0); + }); + + it('should set opacity to 1', () => { + mesh.setOpacityAt(0, 0.5); + mesh.setOpacityAt(0, 1); + + expect(mesh.getOpacityAt(0)).toBe(1); + }); + + it('should not affect other instances', () => { + mesh.setOpacityAt(5, 0.3); + + expect(mesh.getOpacityAt(4)).toBe(1); + expect(mesh.getOpacityAt(6)).toBe(1); + }); + + it('should initialize colorsTexture when first opacity is set', () => { + // Colors texture may or may not be initialized depending on setup + const hadColorsTexture = mesh.colorsTexture !== null; + + mesh.setOpacityAt(0, 0.5); + + expect(mesh.colorsTexture).not.toBeNull(); + + // If we didn't have it before, it should be created now + if (!hadColorsTexture) { + expect(mesh['_useOpacity']).toBe(true); + } + }); + }); + + describe('with entities enabled', () => { + let meshWithEntities: InstancedMesh2; + + beforeEach(() => { + meshWithEntities = createTestInstancedMeshWithEntities(100); + meshWithEntities.addInstances(10, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + it('should get opacity via instances[i].opacity', () => { + expect(meshWithEntities.instances[0].opacity).toBe(1); + }); + + it('should set opacity via instances[i].opacity', () => { + meshWithEntities.instances[3].opacity = 0.7; + + expect(meshWithEntities.getOpacityAt(3)).toBeCloseTo(0.7, 5); + }); + + it('should sync opacity between property and method', () => { + meshWithEntities.setOpacityAt(5, 0.4); + + expect(meshWithEntities.instances[5].opacity).toBeCloseTo(0.4, 5); + }); + }); + + describe('opacity edge cases', () => { + it('should handle very small opacity values', () => { + mesh.setOpacityAt(0, 0.001); + + expect(mesh.getOpacityAt(0)).toBeCloseTo(0.001, 5); + }); + + it('should handle multiple opacity changes', () => { + mesh.setOpacityAt(0, 0.2); + mesh.setOpacityAt(0, 0.8); + mesh.setOpacityAt(0, 0.5); + + expect(mesh.getOpacityAt(0)).toBe(0.5); + }); + + it('should set opacity on multiple instances', () => { + for (let i = 0; i < 5; i++) { + mesh.setOpacityAt(i, i * 0.2); + } + + expect(mesh.getOpacityAt(0)).toBe(0); + expect(mesh.getOpacityAt(1)).toBeCloseTo(0.2, 5); + expect(mesh.getOpacityAt(2)).toBeCloseTo(0.4, 5); + expect(mesh.getOpacityAt(3)).toBeCloseTo(0.6, 5); + expect(mesh.getOpacityAt(4)).toBeCloseTo(0.8, 5); + }); + }); +}); + +describe('Per-instance Color', () => { + let mesh: InstancedMesh2; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + describe('setColorAt / getColorAt', () => { + it('should set color using hex value', () => { + mesh.setColorAt(0, 0xff0000); + + const color = mesh.getColorAt(0); + expect(color.r).toBeCloseTo(1, 5); + expect(color.g).toBeCloseTo(0, 5); + expect(color.b).toBeCloseTo(0, 5); + }); + + it('should set color using Color object', () => { + mesh.setColorAt(0, new Color(0, 1, 0)); + + const color = mesh.getColorAt(0); + expect(color.r).toBeCloseTo(0, 5); + expect(color.g).toBeCloseTo(1, 5); + expect(color.b).toBeCloseTo(0, 5); + }); + + it('should set different colors on different instances', () => { + mesh.setColorAt(0, 0xff0000); + mesh.setColorAt(1, 0x00ff00); + mesh.setColorAt(2, 0x0000ff); + + expect(mesh.getColorAt(0).r).toBeCloseTo(1, 5); + expect(mesh.getColorAt(1).g).toBeCloseTo(1, 5); + expect(mesh.getColorAt(2).b).toBeCloseTo(1, 5); + }); + + it('should initialize colorsTexture when first color is set', () => { + const hadColorsTexture = mesh.colorsTexture !== null; + + mesh.setColorAt(0, 0xff0000); + + expect(mesh.colorsTexture).not.toBeNull(); + }); + }); + + describe('with entities enabled', () => { + let meshWithEntities: InstancedMesh2; + + beforeEach(() => { + meshWithEntities = createTestInstancedMeshWithEntities(100); + meshWithEntities.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + it('should set color via instances[i].color', () => { + meshWithEntities.instances[0].color = 0xff0000; + + const color = meshWithEntities.getColorAt(0); + expect(color.r).toBeCloseTo(1, 5); + }); + + it('should get color via instances[i].color', () => { + meshWithEntities.setColorAt(0, 0x00ff00); + + const color = meshWithEntities.instances[0].color; + expect(color.g).toBeCloseTo(1, 5); + }); + }); +}); + diff --git a/tests/fixtures/test-scene.html b/tests/fixtures/test-scene.html new file mode 100644 index 0000000..90ea5e3 --- /dev/null +++ b/tests/fixtures/test-scene.html @@ -0,0 +1,133 @@ + + + + + + InstancedMesh2 Test Scene + + + +
+
Instances: 0
+
Rendered: 0
+
FPS: 0
+
+ + + + + diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..b97b149 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,109 @@ +/** + * Test setup utilities for @three.ez/instanced-mesh + * + * Provides helpers to create InstancedMesh2 instances for testing + * without requiring a full WebGL context. + */ + +import { BoxGeometry, ColorManagement, MeshBasicMaterial, WebGLRenderer } from 'three'; +import { InstancedMesh2 } from '../src/core/InstancedMesh2'; +import { SquareDataTexture } from '../src/core/utils/SquareDataTexture'; + +// Import feature modules to ensure prototype extensions are applied +import '../src/core/feature/Capacity'; +import '../src/core/feature/Instances'; +import '../src/core/feature/FrustumCulling'; +import '../src/core/feature/LOD'; +import '../src/core/feature/Raycasting'; + +/** + * Creates a mock WebGL2 rendering context for testing + */ +export function createMockGL(): WebGL2RenderingContext { + return { + UNSIGNED_INT: 5125, + TEXTURE_2D: 3553, + ARRAY_BUFFER: 34962, + STATIC_DRAW: 35044, + DYNAMIC_DRAW: 35048, + NONE: 0, + BROWSER_DEFAULT_WEBGL: 37444, + UNPACK_FLIP_Y_WEBGL: 37440, + UNPACK_PREMULTIPLY_ALPHA_WEBGL: 37441, + UNPACK_ALIGNMENT: 3317, + UNPACK_COLORSPACE_CONVERSION_WEBGL: 37443, + createBuffer: () => ({}), + bindBuffer: () => {}, + bufferData: () => {}, + bufferSubData: () => {}, + deleteBuffer: () => {}, + pixelStorei: () => {}, + bindTexture: () => {}, + texSubImage2D: () => {}, + } as unknown as WebGL2RenderingContext; +} + +/** + * Creates a mock WebGLRenderer for testing + */ +export function createMockRenderer(): WebGLRenderer { + const gl = createMockGL(); + + return { + getContext: () => gl, + properties: { + get: () => ({ __webglTexture: {} }), + }, + state: { + bindTexture: () => {}, + }, + extensions: {}, + capabilities: {}, + } as unknown as WebGLRenderer; +} + +/** + * Creates an InstancedMesh2 configured for testing + */ +export function createTestInstancedMesh(options: { + capacity?: number; + createEntities?: boolean; + allowsEuler?: boolean; +} = {}): InstancedMesh2 { + const { capacity = 100, createEntities = false, allowsEuler = false } = options; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0xff0000 }); + const renderer = createMockRenderer(); + + const mesh = new InstancedMesh2(geometry, material, { + capacity, + createEntities, + allowsEuler, + renderer, + }); + + // Initialize matrices texture for testing + if (!mesh.matricesTexture) { + mesh.matricesTexture = new SquareDataTexture(Float32Array, 4, 4, capacity); + } + + // Setup initColorsTexture method for testing + mesh.initColorsTexture = function() { + if (!this.colorsTexture) { + this.colorsTexture = new SquareDataTexture(Float32Array, 4, 1, this._capacity); + this.colorsTexture.colorSpace = ColorManagement.workingColorSpace; + this.colorsTexture._data.fill(1); + } + }; + + return mesh; +} + +/** + * Creates an InstancedMesh2 with entities enabled for testing + */ +export function createTestInstancedMeshWithEntities(capacity = 100): InstancedMesh2 { + return createTestInstancedMesh({ capacity, createEntities: true }); +} + diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..c1e2c62 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "noImplicitAny": false, + "strictNullChecks": false, + "types": [ + "vitest/globals", + "vite-plugin-glsl/ext" + ], + "baseUrl": ".", + "paths": { + "@three.ez/instanced-mesh": ["./src/index.ts"] + } + }, + "include": [ + "tests/**/*.ts", + "src/**/*.ts" + ] +} + diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ee05f9e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; +import glsl from 'vite-plugin-glsl'; + +export default defineConfig({ + plugins: [glsl()], + test: { + environment: 'happy-dom', + include: ['tests/**/*.test.ts'], + exclude: ['tests/e2e/**'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.d.ts', 'src/shaders/**'] + }, + globals: true, + typecheck: { + tsconfig: './tsconfig.test.json' + } + }, + resolve: { + alias: { + '@three.ez/instanced-mesh': resolve(__dirname, 'src/index.ts') + } + } +}); +