From 8ac5b3f1f9d2b4f2135cabbbb220b91cb62d819c Mon Sep 17 00:00:00 2001 From: m Date: Sat, 7 Feb 2026 16:34:36 +0100 Subject: [PATCH] Add test suite with 132 unit tests across all modules Covers DesignState (40 tests), HouseRenderer (19), InteractionManager (24), ThemeManager (8), ExportManager (11), and CatalogPanel (30). Uses vitest with THREE.js mocks for browser-free testing. --- .gitignore | 1 + bun.lock | 213 ++++++++++++++++++ package.json | 12 + tests/__mocks__/OrbitControls.js | 10 + tests/__mocks__/three.js | 199 +++++++++++++++++ tests/catalog.test.js | 372 +++++++++++++++++++++++++++++++ tests/export.test.js | 190 ++++++++++++++++ tests/interaction.test.js | 311 ++++++++++++++++++++++++++ tests/renderer.test.js | 260 +++++++++++++++++++++ tests/setup.js | 10 + tests/state.test.js | 347 ++++++++++++++++++++++++++++ tests/themes.test.js | 117 ++++++++++ vitest.config.js | 16 ++ 13 files changed, 2058 insertions(+) create mode 100644 .gitignore create mode 100644 bun.lock create mode 100644 package.json create mode 100644 tests/__mocks__/OrbitControls.js create mode 100644 tests/__mocks__/three.js create mode 100644 tests/catalog.test.js create mode 100644 tests/export.test.js create mode 100644 tests/interaction.test.js create mode 100644 tests/renderer.test.js create mode 100644 tests/setup.js create mode 100644 tests/state.test.js create mode 100644 tests/themes.test.js create mode 100644 vitest.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..879f0e3 --- /dev/null +++ b/bun.lock @@ -0,0 +1,213 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "house-design", + "devDependencies": { + "vitest": "^3.0.0", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + + "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + + "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], + + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + + "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + + "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd204d8 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "house-design", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "vitest": "^3.0.0" + } +} diff --git a/tests/__mocks__/OrbitControls.js b/tests/__mocks__/OrbitControls.js new file mode 100644 index 0000000..875027f --- /dev/null +++ b/tests/__mocks__/OrbitControls.js @@ -0,0 +1,10 @@ +export class OrbitControls { + constructor() { + this.target = { set: () => {} }; + this.enableDamping = false; + this.dampingFactor = 0; + this.enabled = true; + } + update() {} + dispose() {} +} diff --git a/tests/__mocks__/three.js b/tests/__mocks__/three.js new file mode 100644 index 0000000..14e7297 --- /dev/null +++ b/tests/__mocks__/three.js @@ -0,0 +1,199 @@ +// Minimal THREE.js mock for unit testing + +export class Vector2 { + constructor(x = 0, y = 0) { this.x = x; this.y = y; } + set(x, y) { this.x = x; this.y = y; return this; } + copy(v) { this.x = v.x; this.y = v.y; return this; } + clone() { return new Vector2(this.x, this.y); } +} + +export class Vector3 { + constructor(x = 0, y = 0, z = 0) { this.x = x; this.y = y; this.z = z; } + set(x, y, z) { this.x = x; this.y = y; this.z = z; return this; } + copy(v) { this.x = v.x; this.y = v.y; this.z = v.z; return this; } + clone() { return new Vector3(this.x, this.y, this.z); } + sub(v) { this.x -= v.x; this.y -= v.y; this.z -= v.z; return this; } + add(v) { this.x += v.x; this.y += v.y; this.z += v.z; return this; } + multiplyScalar(s) { this.x *= s; this.y *= s; this.z *= s; return this; } + equals(v) { return this.x === v.x && this.y === v.y && this.z === v.z; } +} + +export class Euler { + constructor(x = 0, y = 0, z = 0) { this.x = x; this.y = y; this.z = z; } + copy(e) { this.x = e.x; this.y = e.y; this.z = e.z; return this; } +} + +export class Color { + constructor(c) { this._hex = typeof c === 'number' ? c : 0; } + setHex(h) { this._hex = h; return this; } + clone() { return new Color(this._hex); } +} + +export class Object3D { + constructor() { + this.children = []; + this.parent = null; + this.position = new Vector3(); + this.rotation = new Euler(); + this.scale = new Vector3(1, 1, 1); + this.userData = {}; + } + add(child) { this.children.push(child); child.parent = this; } + remove(child) { + const i = this.children.indexOf(child); + if (i >= 0) this.children.splice(i, 1); + child.parent = null; + } + traverse(fn) { + fn(this); + for (const child of this.children) child.traverse(fn); + } +} + +export class Group extends Object3D {} + +export class Scene extends Object3D { + constructor() { + super(); + this.background = new Color(0); + } +} + +export class Mesh extends Object3D { + constructor(geometry, material) { + super(); + this.geometry = geometry; + this.material = material; + this.isMesh = true; + this.castShadow = false; + this.receiveShadow = false; + } +} + +export class Sprite extends Object3D { + constructor(material) { + super(); + this.material = material; + } +} + +export class LineSegments extends Object3D { + constructor(geometry, material) { + super(); + this.geometry = geometry; + this.material = material; + } +} + +export class BoxGeometry { + constructor(w, h, d) { this.parameters = { width: w, height: h, depth: d }; } + dispose() {} +} + +export class PlaneGeometry { + constructor(w, h) { this.parameters = { width: w, height: h }; } + dispose() {} +} + +export class CylinderGeometry { + constructor(rT, rB, h, s) { this.parameters = { radiusTop: rT, radiusBottom: rB, height: h, radialSegments: s }; } + dispose() {} +} + +export class EdgesGeometry { + constructor(geo) { this._source = geo; } + dispose() {} +} + +export class MeshStandardMaterial { + constructor(opts = {}) { + Object.assign(this, opts); + this.emissive = new Color(0); + } + clone() { const m = new MeshStandardMaterial(); Object.assign(m, this); m.emissive = this.emissive.clone(); return m; } + dispose() {} +} + +export class MeshBasicMaterial { + constructor(opts = {}) { Object.assign(this, opts); } + dispose() {} +} + +export class SpriteMaterial { + constructor(opts = {}) { Object.assign(this, opts); } + dispose() {} +} + +export class LineBasicMaterial { + constructor(opts = {}) { Object.assign(this, opts); } + dispose() {} +} + +export class CanvasTexture { + constructor() {} + dispose() {} +} + +export class Plane { + constructor(normal, constant) { this.normal = normal; this.constant = constant; } +} + +export class Raycaster { + constructor() { this.ray = { intersectPlane: () => new Vector3() }; } + setFromCamera() {} + intersectObject() { return []; } + intersectObjects() { return []; } +} + +export class PerspectiveCamera extends Object3D { + constructor(fov, aspect, near, far) { + super(); + this.fov = fov; + this.aspect = aspect; + this.near = near; + this.far = far; + } + lookAt() {} + updateProjectionMatrix() {} +} + +export class AmbientLight extends Object3D { + constructor(color, intensity) { + super(); + this.color = new Color(color); + this.intensity = intensity; + this.isAmbientLight = true; + } +} + +export class DirectionalLight extends Object3D { + constructor(color, intensity) { + super(); + this.color = new Color(color); + this.intensity = intensity; + this.isDirectionalLight = true; + this.castShadow = false; + this.shadow = { + mapSize: { width: 0, height: 0 }, + camera: { left: 0, right: 0, top: 0, bottom: 0, near: 0, far: 0 } + }; + } +} + +export class GridHelper extends Object3D { + constructor() { super(); } +} + +export class WebGLRenderer { + constructor() { + this.domElement = { addEventListener: () => {}, removeEventListener: () => {}, getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), toDataURL: () => 'data:image/png;base64,fake' }; + this.shadowMap = { enabled: false }; + } + setSize() {} + setPixelRatio() {} + getPixelRatio() { return 1; } + getSize(v) { v.set(800, 600); } + render() {} +} + +export const DoubleSide = 2; diff --git a/tests/catalog.test.js b/tests/catalog.test.js new file mode 100644 index 0000000..0cc869e --- /dev/null +++ b/tests/catalog.test.js @@ -0,0 +1,372 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CatalogPanel } from '../src/catalog.js'; + +function makeCatalogData() { + return { + categories: ['seating', 'tables', 'storage'], + items: [ + { + id: 'chair-1', name: 'Dining Chair', category: 'seating', + dimensions: { width: 0.45, depth: 0.5, height: 0.9 }, + rooms: ['wohnzimmer'], + mesh: { type: 'group', parts: [{ color: '#8b4513' }] } + }, + { + id: 'table-1', name: 'Kitchen Table', category: 'tables', + dimensions: { width: 1.4, depth: 0.8, height: 0.75 }, + rooms: ['küche'], + mesh: { type: 'group', parts: [{ color: '#d2b48c' }] } + }, + { + id: 'ikea-shelf-1', name: 'KALLAX Shelf', category: 'storage', + dimensions: { width: 0.77, depth: 0.39, height: 1.47 }, + rooms: [], + ikeaSeries: 'KALLAX', + mesh: { type: 'group', parts: [{ color: '#ffffff' }] } + }, + { + id: 'ikea-chair-1', name: 'POÄNG Chair', category: 'seating', + dimensions: { width: 0.68, depth: 0.82, height: 1.0 }, + rooms: ['wohnzimmer'], + ikeaSeries: 'POÄNG', + mesh: { type: 'group', parts: [{ color: '#b5651d' }] } + } + ] + }; +} + +function makeMockRenderer(catalogData) { + const listeners = {}; + return { + catalogData: catalogData || makeCatalogData(), + _catalogIndex: new Map((catalogData || makeCatalogData()).items.map(i => [i.id, i])), + container: { + addEventListener: (t, fn) => { listeners[t] = listeners[t] || []; listeners[t].push(fn); }, + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + _listeners: listeners + }, + houseData: { + floors: [{ + id: 'eg', + rooms: [{ + id: 'eg-wohnzimmer', name: 'Wohnzimmer', type: 'living', + position: { x: 0, y: 0 }, + dimensions: { width: 5, length: 4 } + }] + }] + }, + currentFloor: 0, + getRooms: () => [ + { id: 'eg-wohnzimmer', name: 'Wohnzimmer', nameEN: 'Living Room', type: 'living', area: 20 } + ] + }; +} + +function makeMockState() { + return { + addFurniture: vi.fn(() => 0), + onChange: vi.fn(() => () => {}) + }; +} + +function makeMockInteraction() { + return { + selectedRoomId: null, + onChange: vi.fn(() => () => {}) + }; +} + +function makeContainer() { + // Minimal DOM element mock + const el = { + innerHTML: '', + className: '', + style: {}, + children: [], + querySelectorAll: () => [], + querySelector: (sel) => { + if (sel === '.catalog-count') return { textContent: '' }; + return null; + }, + appendChild: vi.fn(function(child) { this.children.push(child); return child; }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dataset: {} + }; + return el; +} + +// Create a DOM element factory for jsdom-free testing +function setupDomMock() { + const elements = []; + + globalThis.document = { + createElement: (tag) => { + const el = { + tagName: tag.toUpperCase(), + className: '', + innerHTML: '', + textContent: '', + style: {}, + dataset: {}, + value: '', + type: '', + placeholder: '', + step: '', + min: '', + max: '', + children: [], + _listeners: {}, + appendChild: vi.fn(function(child) { this.children.push(child); return child; }), + addEventListener: vi.fn(function(type, fn) { + this._listeners[type] = this._listeners[type] || []; + this._listeners[type].push(fn); + }), + removeEventListener: vi.fn(), + querySelector: function(sel) { + if (sel === '.catalog-count') return { textContent: '' }; + if (sel === '.catalog-item-add') return { addEventListener: vi.fn(), removeEventListener: vi.fn() }; + return null; + }, + querySelectorAll: () => [], + focus: vi.fn(), + click: vi.fn() + }; + elements.push(el); + return el; + } + }; + + return elements; +} + +describe('CatalogPanel', () => { + let panel, container, mockRenderer, mockState, mockInteraction; + + beforeEach(() => { + setupDomMock(); + container = makeContainer(); + mockRenderer = makeMockRenderer(); + mockState = makeMockState(); + mockInteraction = makeMockInteraction(); + panel = new CatalogPanel(container, { + renderer: mockRenderer, + state: mockState, + interaction: mockInteraction + }); + }); + + describe('initialization', () => { + it('sets default filter state', () => { + expect(panel.selectedSource).toBe('all'); + expect(panel.selectedCategory).toBe('all'); + expect(panel.selectedSeries).toBe('all'); + expect(panel.searchQuery).toBe(''); + }); + + it('stores renderer, state, and interaction references', () => { + expect(panel.renderer).toBe(mockRenderer); + expect(panel.state).toBe(mockState); + expect(panel.interaction).toBe(mockInteraction); + }); + }); + + describe('_hasIkeaItems', () => { + it('returns true when IKEA items exist', () => { + expect(panel._hasIkeaItems()).toBe(true); + }); + + it('returns false with no IKEA items', () => { + mockRenderer.catalogData.items = mockRenderer.catalogData.items.filter(i => !i.id.startsWith('ikea-')); + expect(panel._hasIkeaItems()).toBe(false); + }); + + it('returns false with no catalog', () => { + mockRenderer.catalogData = null; + expect(panel._hasIkeaItems()).toBe(false); + }); + }); + + describe('_getSourceFilteredItems', () => { + it('returns all items for "all" source', () => { + panel.selectedSource = 'all'; + expect(panel._getSourceFilteredItems()).toHaveLength(4); + }); + + it('returns only IKEA items for "ikea" source', () => { + panel.selectedSource = 'ikea'; + const items = panel._getSourceFilteredItems(); + expect(items).toHaveLength(2); + expect(items.every(i => i.id.startsWith('ikea-'))).toBe(true); + }); + + it('returns only standard items for "standard" source', () => { + panel.selectedSource = 'standard'; + const items = panel._getSourceFilteredItems(); + expect(items).toHaveLength(2); + expect(items.every(i => !i.id.startsWith('ikea-'))).toBe(true); + }); + + it('returns empty array with no catalog', () => { + mockRenderer.catalogData = null; + expect(panel._getSourceFilteredItems()).toEqual([]); + }); + }); + + describe('_getFilteredItems', () => { + it('filters by category', () => { + panel.selectedCategory = 'seating'; + const items = panel._getFilteredItems(); + expect(items).toHaveLength(2); // chair-1 + ikea-chair-1 + expect(items.every(i => i.category === 'seating')).toBe(true); + }); + + it('filters by source + category', () => { + panel.selectedSource = 'ikea'; + panel.selectedCategory = 'seating'; + const items = panel._getFilteredItems(); + expect(items).toHaveLength(1); + expect(items[0].id).toBe('ikea-chair-1'); + }); + + it('filters by series', () => { + panel.selectedSource = 'ikea'; + panel.selectedSeries = 'KALLAX'; + const items = panel._getFilteredItems(); + expect(items).toHaveLength(1); + expect(items[0].id).toBe('ikea-shelf-1'); + }); + + it('filters by search query', () => { + panel.searchQuery = 'kallax'; + const items = panel._getFilteredItems(); + expect(items).toHaveLength(1); + expect(items[0].id).toBe('ikea-shelf-1'); + }); + + it('search matches on name, id, and category', () => { + panel.searchQuery = 'chair'; + const items = panel._getFilteredItems(); + expect(items.length).toBeGreaterThanOrEqual(2); // chair-1, ikea-chair-1 + }); + + it('search is case-insensitive', () => { + panel.searchQuery = 'DINING'; + const items = panel._getFilteredItems(); + expect(items).toHaveLength(1); + expect(items[0].id).toBe('chair-1'); + }); + + it('combined filters narrow results', () => { + panel.selectedSource = 'standard'; + panel.selectedCategory = 'tables'; + panel.searchQuery = 'kitchen'; + const items = panel._getFilteredItems(); + expect(items).toHaveLength(1); + expect(items[0].id).toBe('table-1'); + }); + + it('no matches returns empty array', () => { + panel.searchQuery = 'nonexistent-item-xyz'; + expect(panel._getFilteredItems()).toHaveLength(0); + }); + }); + + describe('_getTargetRoom', () => { + it('uses selectedRoomId if set', () => { + panel.selectedRoomId = 'custom-room'; + expect(panel._getTargetRoom({})).toBe('custom-room'); + }); + + it('falls back to interaction selectedRoomId', () => { + mockInteraction.selectedRoomId = 'interaction-room'; + expect(panel._getTargetRoom({})).toBe('interaction-room'); + }); + + it('matches catalog item room hints to floor rooms', () => { + const item = { rooms: ['wohnzimmer'] }; + expect(panel._getTargetRoom(item)).toBe('eg-wohnzimmer'); + }); + + it('falls back to first room when no hint matches', () => { + const item = { rooms: ['bathroom'] }; + expect(panel._getTargetRoom(item)).toBe('eg-wohnzimmer'); + }); + + it('falls back to first room with no hints', () => { + expect(panel._getTargetRoom({ rooms: [] })).toBe('eg-wohnzimmer'); + }); + + it('returns null with no rooms available', () => { + mockRenderer.getRooms = () => []; + expect(panel._getTargetRoom({ rooms: [] })).toBeNull(); + }); + }); + + describe('_placeItem', () => { + it('calls state.addFurniture with correct placement', () => { + const item = { id: 'chair-1', rooms: ['wohnzimmer'] }; + panel._placeItem(item); + + expect(mockState.addFurniture).toHaveBeenCalledTimes(1); + const [roomId, placement] = mockState.addFurniture.mock.calls[0]; + expect(roomId).toBe('eg-wohnzimmer'); + expect(placement.catalogId).toBe('chair-1'); + expect(placement.position.x).toBe(2.5); // center of 5m wide room + expect(placement.position.z).toBe(2); // center of 4m long room + expect(placement.rotation).toBe(0); + }); + + it('does nothing when no target room found', () => { + mockRenderer.getRooms = () => []; + panel._placeItem({ id: 'x', rooms: [] }); + expect(mockState.addFurniture).not.toHaveBeenCalled(); + }); + }); + + describe('setSelectedRoom', () => { + it('sets selectedRoomId', () => { + panel.setSelectedRoom('room-x'); + expect(panel.selectedRoomId).toBe('room-x'); + }); + + it('clears with null', () => { + panel.setSelectedRoom('room-x'); + panel.setSelectedRoom(null); + expect(panel.selectedRoomId).toBeNull(); + }); + }); + + describe('_addCustomItem', () => { + it('adds item to catalog', () => { + const countBefore = mockRenderer.catalogData.items.length; + panel._addCustomItem({ + name: 'My Shelf', width: 1, depth: 0.5, height: 1.5, + color: '#aabbcc', category: 'storage' + }); + + expect(mockRenderer.catalogData.items.length).toBe(countBefore + 1); + const added = mockRenderer.catalogData.items[countBefore]; + expect(added.id).toBe('custom-my-shelf'); + expect(added.name).toBe('My Shelf'); + expect(added.category).toBe('storage'); + expect(added.dimensions).toEqual({ width: 1, depth: 0.5, height: 1.5 }); + }); + + it('generates unique id on collision', () => { + panel._addCustomItem({ name: 'Shelf', width: 1, depth: 0.5, height: 1, color: '#000', category: 'storage' }); + panel._addCustomItem({ name: 'Shelf', width: 1, depth: 0.5, height: 1, color: '#000', category: 'storage' }); + + const customItems = mockRenderer.catalogData.items.filter(i => i.id.startsWith('custom-shelf')); + expect(customItems).toHaveLength(2); + expect(customItems[0].id).not.toBe(customItems[1].id); + }); + + it('does nothing with no catalog', () => { + mockRenderer.catalogData = null; + panel._addCustomItem({ name: 'X', width: 1, depth: 1, height: 1, color: '#000', category: 'storage' }); + // Should not throw + }); + }); +}); diff --git a/tests/export.test.js b/tests/export.test.js new file mode 100644 index 0000000..fa22372 --- /dev/null +++ b/tests/export.test.js @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ExportManager } from '../src/export.js'; +import { DesignState } from '../src/state.js'; +import { Vector2 } from '../tests/__mocks__/three.js'; + +function makeMockRenderer() { + return { + renderer: { + getSize: vi.fn((v) => v.set(800, 600)), + getPixelRatio: vi.fn(() => 1), + setSize: vi.fn(), + setPixelRatio: vi.fn(), + render: vi.fn(), + domElement: { + toDataURL: vi.fn(() => 'data:image/png;base64,mock') + } + }, + camera: { + aspect: 800 / 600, + updateProjectionMatrix: vi.fn() + }, + scene: {}, + container: { + dispatchEvent: vi.fn() + }, + houseData: { + floors: [{ id: 'eg', rooms: [{ id: 'r1', name: 'Room1' }] }] + }, + currentFloor: 0, + designData: null, + _clearFloor: vi.fn(), + _renderRoom: vi.fn(), + _placeFurnitureForFloor: vi.fn() + }; +} + +function makeDesign() { + return { + name: 'Test Design', + rooms: [{ roomId: 'r1', furniture: [{ catalogId: 'c1', position: { x: 1, z: 2 }, rotation: 0 }] }] + }; +} + +describe('ExportManager', () => { + let exportMgr, state, mockRenderer; + + beforeEach(() => { + // Stub DOM APIs + globalThis.URL = { createObjectURL: vi.fn(() => 'blob:url'), revokeObjectURL: vi.fn() }; + globalThis.Blob = class Blob { constructor(parts, opts) { this.parts = parts; this.type = opts?.type; } }; + globalThis.document = globalThis.document || {}; + globalThis.document.createElement = (tag) => { + return { href: '', download: '', click: vi.fn(), type: '', accept: '', files: [], addEventListener: vi.fn() }; + }; + + state = new DesignState(makeDesign()); + mockRenderer = makeMockRenderer(); + exportMgr = new ExportManager(mockRenderer, state); + }); + + describe('exportDesignJSON', () => { + it('creates a download with correct filename', () => { + const mockAnchor = { href: '', download: '', click: vi.fn() }; + globalThis.document.createElement = () => mockAnchor; + + exportMgr.exportDesignJSON(); + + expect(mockAnchor.download).toBe('Test Design.json'); + expect(mockAnchor.click).toHaveBeenCalled(); + }); + + it('includes exportedAt timestamp', () => { + let blobContent = ''; + globalThis.Blob = class { + constructor(parts) { blobContent = parts[0]; } + }; + globalThis.document.createElement = () => ({ href: '', download: '', click: vi.fn() }); + + exportMgr.exportDesignJSON(); + const data = JSON.parse(blobContent); + expect(data.exportedAt).toBeDefined(); + expect(new Date(data.exportedAt).getTime()).not.toBeNaN(); + }); + + it('exports current state data', () => { + let blobContent = ''; + globalThis.Blob = class { + constructor(parts) { blobContent = parts[0]; } + }; + globalThis.document.createElement = () => ({ href: '', download: '', click: vi.fn() }); + + exportMgr.exportDesignJSON(); + const data = JSON.parse(blobContent); + expect(data.name).toBe('Test Design'); + expect(data.rooms).toHaveLength(1); + expect(data.rooms[0].furniture[0].catalogId).toBe('c1'); + }); + + it('uses "design" as fallback filename', () => { + // State with no name + state.loadDesign({ rooms: [] }); + const mockAnchor = { href: '', download: '', click: vi.fn() }; + globalThis.document.createElement = () => mockAnchor; + + exportMgr.exportDesignJSON(); + expect(mockAnchor.download).toBe('design.json'); + }); + }); + + describe('_loadDesignFile', () => { + it('loads valid design file', async () => { + const design = { name: 'Loaded', rooms: [{ roomId: 'r2', furniture: [] }] }; + const file = { text: () => Promise.resolve(JSON.stringify(design)), name: 'loaded.json' }; + + await exportMgr._loadDesignFile(file); + + expect(state.design.name).toBe('Loaded'); + expect(state.design.rooms).toHaveLength(1); + }); + + it('rejects file without rooms array', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const file = { text: () => Promise.resolve('{"name": "bad"}'), name: 'bad.json' }; + + await exportMgr._loadDesignFile(file); + + expect(mockRenderer.container.dispatchEvent).toHaveBeenCalled(); + const event = mockRenderer.container.dispatchEvent.mock.calls.find( + c => c[0].type === 'loaderror' + ); + expect(event).toBeDefined(); + errorSpy.mockRestore(); + }); + + it('rejects invalid JSON', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const file = { text: () => Promise.resolve('not json'), name: 'bad.json' }; + + await exportMgr._loadDesignFile(file); + + expect(mockRenderer.container.dispatchEvent).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it('dispatches designloaded event on success', async () => { + const design = { name: 'Success', rooms: [{ roomId: 'r1', furniture: [] }] }; + const file = { text: () => Promise.resolve(JSON.stringify(design)), name: 'test.json' }; + + await exportMgr._loadDesignFile(file); + + const event = mockRenderer.container.dispatchEvent.mock.calls.find( + c => c[0].type === 'designloaded' + ); + expect(event).toBeDefined(); + expect(event[0].detail.name).toBe('Success'); + }); + }); + + describe('exportScreenshot', () => { + it('renders at requested resolution', () => { + const mockAnchor = { href: '', download: '', click: vi.fn() }; + globalThis.document.createElement = () => mockAnchor; + + exportMgr.exportScreenshot(1920, 1080); + + expect(mockRenderer.renderer.setSize).toHaveBeenCalledWith(1920, 1080); + expect(mockRenderer.renderer.render).toHaveBeenCalled(); + expect(mockAnchor.download).toBe('house-design.png'); + }); + + it('restores original renderer size after capture', () => { + const mockAnchor = { href: '', download: '', click: vi.fn() }; + globalThis.document.createElement = () => mockAnchor; + + exportMgr.exportScreenshot(); + + // setSize called twice: once for capture, once to restore + expect(mockRenderer.renderer.setSize).toHaveBeenCalledTimes(2); + // Last call restores original 800x600 + const lastCall = mockRenderer.renderer.setSize.mock.calls[1]; + expect(lastCall).toEqual([800, 600]); + }); + + it('updates camera projection matrix twice', () => { + globalThis.document.createElement = () => ({ href: '', download: '', click: vi.fn() }); + exportMgr.exportScreenshot(); + expect(mockRenderer.camera.updateProjectionMatrix).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/tests/interaction.test.js b/tests/interaction.test.js new file mode 100644 index 0000000..175cb71 --- /dev/null +++ b/tests/interaction.test.js @@ -0,0 +1,311 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { InteractionManager } from '../src/interaction.js'; +import { DesignState } from '../src/state.js'; +import { Group, Vector3, Euler } from '../tests/__mocks__/three.js'; + +function makeState() { + return new DesignState({ + name: 'Test', + rooms: [{ + roomId: 'room-1', + furniture: [ + { catalogId: 'chair', position: { x: 1, z: 2 }, rotation: 0 }, + { catalogId: 'table', position: { x: 3, z: 4 }, rotation: 90 } + ] + }] + }); +} + +function makeMockRenderer() { + const listeners = {}; + const canvasListeners = {}; + const container = { + addEventListener: (t, fn) => { listeners[t] = listeners[t] || []; listeners[t].push(fn); }, + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + }; + + const furnitureMeshes = new Map(); + + return { + container, + renderer: { + domElement: { + addEventListener: (t, fn) => { canvasListeners[t] = canvasListeners[t] || []; canvasListeners[t].push(fn); }, + removeEventListener: vi.fn(), + getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) + } + }, + camera: { position: new Vector3(6, 12, 14) }, + raycaster: { + setFromCamera: vi.fn(), + intersectObject: vi.fn(() => []), + ray: { intersectPlane: vi.fn(() => new Vector3()) } + }, + houseData: { + floors: [{ + id: 'eg', + rooms: [{ + id: 'room-1', + position: { x: 0, y: 0 }, + dimensions: { width: 5, length: 5 } + }] + }] + }, + currentFloor: 0, + furnitureMeshes, + setControlsEnabled: vi.fn(), + designData: {}, + _clearFloor: vi.fn(), + _renderRoom: vi.fn(), + _placeFurnitureForFloor: vi.fn(), + _listeners: listeners, + _canvasListeners: canvasListeners + }; +} + +describe('InteractionManager', () => { + let interaction, state, mockRenderer; + + beforeEach(() => { + globalThis.window = globalThis.window || {}; + globalThis.window.addEventListener = vi.fn(); + globalThis.window.removeEventListener = vi.fn(); + + state = makeState(); + mockRenderer = makeMockRenderer(); + interaction = new InteractionManager(mockRenderer, state); + }); + + describe('mode system', () => { + it('starts in view mode', () => { + expect(interaction.mode).toBe('view'); + }); + + it('setMode changes mode and emits event', () => { + const listener = vi.fn(); + interaction.onChange(listener); + interaction.setMode('select'); + expect(interaction.mode).toBe('select'); + expect(listener).toHaveBeenCalledWith('modechange', { oldMode: 'view', newMode: 'select' }); + }); + + it('setMode does nothing if already in that mode', () => { + const listener = vi.fn(); + interaction.onChange(listener); + interaction.setMode('view'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('switching to view clears selection', () => { + // Use select() to properly set up, which also transitions to 'select' mode + const mesh = new Group(); + mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' }; + interaction.select(mesh); + expect(interaction.mode).toBe('select'); + + interaction.setMode('view'); + expect(interaction.selectedObject).toBeNull(); + expect(interaction.selectedRoomId).toBeNull(); + }); + }); + + describe('selection', () => { + it('select sets selection properties', () => { + const mesh = new Group(); + mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' }; + + interaction.select(mesh); + expect(interaction.selectedObject).toBe(mesh); + expect(interaction.selectedRoomId).toBe('room-1'); + expect(interaction.selectedIndex).toBe(0); + }); + + it('select emits select event', () => { + const listener = vi.fn(); + interaction.onChange(listener); + + const mesh = new Group(); + mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' }; + interaction.select(mesh); + + expect(listener).toHaveBeenCalledWith('select', expect.objectContaining({ + roomId: 'room-1', + index: 0, + catalogId: 'chair' + })); + }); + + it('select switches mode from view to select', () => { + const mesh = new Group(); + mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' }; + interaction.select(mesh); + expect(interaction.mode).toBe('select'); + }); + + it('selecting same object does nothing', () => { + const mesh = new Group(); + mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' }; + interaction.select(mesh); + const listener = vi.fn(); + interaction.onChange(listener); + interaction.select(mesh); // same mesh + expect(listener).not.toHaveBeenCalled(); + }); + + it('clearSelection resets state and emits deselect', () => { + const mesh = new Group(); + mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' }; + interaction.select(mesh); + + const listener = vi.fn(); + interaction.onChange(listener); + interaction.clearSelection(); + + expect(interaction.selectedObject).toBeNull(); + expect(interaction.selectedRoomId).toBeNull(); + expect(interaction.selectedIndex).toBe(-1); + expect(listener).toHaveBeenCalledWith('deselect', { roomId: 'room-1', index: 0 }); + }); + + it('clearSelection does nothing with no selection', () => { + const listener = vi.fn(); + interaction.onChange(listener); + interaction.clearSelection(); + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('keyboard shortcuts', () => { + function keyDown(key, opts = {}) { + interaction._onKeyDown({ + key, + ctrlKey: opts.ctrl || false, + metaKey: opts.meta || false, + shiftKey: opts.shift || false, + target: { tagName: opts.tagName || 'BODY' }, + preventDefault: vi.fn() + }); + } + + it('Ctrl+Z triggers undo', () => { + state.moveFurniture('room-1', 0, { x: 99 }); + keyDown('z', { ctrl: true }); + expect(state.getFurniture('room-1', 0).position.x).toBe(1); + }); + + it('Ctrl+Shift+Z triggers redo', () => { + state.moveFurniture('room-1', 0, { x: 99 }); + state.undo(); + keyDown('Z', { ctrl: true, shift: true }); + expect(state.getFurniture('room-1', 0).position.x).toBe(99); + }); + + it('Ctrl+Y triggers redo', () => { + state.moveFurniture('room-1', 0, { x: 99 }); + state.undo(); + keyDown('y', { ctrl: true }); + expect(state.getFurniture('room-1', 0).position.x).toBe(99); + }); + + it('Escape clears selection and returns to view mode', () => { + const mesh = new Group(); + mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' }; + interaction.select(mesh); + + keyDown('Escape'); + expect(interaction.selectedObject).toBeNull(); + expect(interaction.mode).toBe('view'); + }); + + it('Delete removes selected furniture', () => { + const mesh = new Group(); + mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' }; + interaction.select(mesh); + + keyDown('Delete'); + expect(state.getRoomFurniture('room-1')).toHaveLength(1); + // The chair (index 0) was removed; table remains + expect(state.getFurniture('room-1', 0).catalogId).toBe('table'); + }); + + it('R rotates selected furniture by -90 degrees', () => { + const mesh = new Group(); + mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' }; + interaction.select(mesh); + + keyDown('r'); + expect(state.getFurniture('room-1', 0).rotation).toBe(270); // (0 + (-90) + 360) % 360 + }); + + it('Shift+R rotates selected furniture by +90 degrees', () => { + const mesh = new Group(); + mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' }; + interaction.select(mesh); + + keyDown('R', { shift: true }); + expect(state.getFurniture('room-1', 0).rotation).toBe(90); + }); + + it('ignores shortcuts when focused on input', () => { + const listener = vi.fn(); + state.onChange(listener); + state.moveFurniture('room-1', 0, { x: 99 }); + listener.mockClear(); + + keyDown('z', { ctrl: true, tagName: 'INPUT' }); + // Undo should NOT have fired + expect(listener).not.toHaveBeenCalled(); + }); + + it('selection-requiring shortcuts do nothing without selection', () => { + keyDown('Delete'); + keyDown('r'); + // Should not throw, no furniture removed + expect(state.getRoomFurniture('room-1')).toHaveLength(2); + }); + }); + + describe('onChange / observer', () => { + it('registers and unregisters listeners', () => { + const listener = vi.fn(); + const unsub = interaction.onChange(listener); + interaction.setMode('select'); + expect(listener).toHaveBeenCalledTimes(1); + + unsub(); + interaction.setMode('view'); + expect(listener).toHaveBeenCalledTimes(1); + }); + }); + + describe('snap settings', () => { + it('defaults to snap enabled at 0.25m', () => { + expect(interaction.snapEnabled).toBe(true); + expect(interaction.snapSize).toBe(0.25); + }); + }); + + describe('_buildRoomBounds', () => { + it('populates room bounds from house data', () => { + interaction._buildRoomBounds(); + const bounds = interaction._roomBounds.get('room-1'); + expect(bounds).toEqual({ + minX: 0, maxX: 5, + minZ: 0, maxZ: 5 + }); + }); + + it('handles missing house data', () => { + mockRenderer.houseData = null; + interaction._buildRoomBounds(); + expect(interaction._roomBounds.size).toBe(0); + }); + }); + + describe('dispose', () => { + it('does not throw', () => { + expect(() => interaction.dispose()).not.toThrow(); + }); + }); +}); diff --git a/tests/renderer.test.js b/tests/renderer.test.js new file mode 100644 index 0000000..4bcfee1 --- /dev/null +++ b/tests/renderer.test.js @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HouseRenderer, COLORS } from '../src/renderer.js'; + +// Mock container with minimal DOM-like interface +function makeContainer() { + const listeners = {}; + return { + clientWidth: 800, + clientHeight: 600, + appendChild: vi.fn(), + dispatchEvent: vi.fn(), + addEventListener: (type, fn) => { + listeners[type] = listeners[type] || []; + listeners[type].push(fn); + }, + removeEventListener: vi.fn(), + _listeners: listeners, + _fireEvent: (type, detail) => { + for (const fn of listeners[type] || []) fn({ detail }); + } + }; +} + +describe('HouseRenderer', () => { + let renderer; + + beforeEach(() => { + // Stub global APIs used by the constructor + globalThis.window = globalThis.window || {}; + globalThis.window.addEventListener = vi.fn(); + globalThis.window.devicePixelRatio = 1; + globalThis.requestAnimationFrame = vi.fn(); + + // Stub document.createElement for canvas room labels + globalThis.document = globalThis.document || {}; + globalThis.document.createElement = (tag) => { + if (tag === 'canvas') { + return { + getContext: () => ({ + font: '', + measureText: () => ({ width: 100 }), + fillStyle: '', + fillRect: () => {}, + textBaseline: '', + textAlign: '', + fillText: () => {} + }), + width: 0, + height: 0 + }; + } + return { addEventListener: vi.fn(), click: vi.fn(), type: '', accept: '', files: [] }; + }; + + renderer = new HouseRenderer(makeContainer()); + }); + + describe('COLORS export', () => { + it('exports expected color structure', () => { + expect(COLORS.wall).toHaveProperty('exterior'); + expect(COLORS.wall).toHaveProperty('interior'); + expect(COLORS.floor).toHaveProperty('tile'); + expect(COLORS.floor).toHaveProperty('hardwood'); + expect(COLORS).toHaveProperty('ceiling'); + expect(COLORS).toHaveProperty('door'); + expect(COLORS).toHaveProperty('window'); + expect(COLORS).toHaveProperty('windowFrame'); + expect(COLORS).toHaveProperty('grid'); + expect(COLORS).toHaveProperty('selected'); + }); + }); + + describe('initial state', () => { + it('has null data references', () => { + expect(renderer.houseData).toBeNull(); + expect(renderer.catalogData).toBeNull(); + expect(renderer.designData).toBeNull(); + }); + + it('starts on floor 0', () => { + expect(renderer.currentFloor).toBe(0); + }); + + it('has empty mesh maps', () => { + expect(renderer.roomMeshes.size).toBe(0); + expect(renderer.furnitureMeshes.size).toBe(0); + }); + }); + + describe('_computeWallSegments', () => { + it('returns single full-height segment with no openings', () => { + const segments = renderer._computeWallSegments([], 4, 2.6); + expect(segments).toHaveLength(1); + expect(segments[0]).toEqual({ w: 4, h: 2.6, cx: 2, cy: 1.3 }); + }); + + it('creates segments around a door opening', () => { + const openings = [{ position: 1, width: 1, height: 2.1, bottom: 0 }]; + const segments = renderer._computeWallSegments(openings, 4, 2.6); + + // Left of door + const left = segments.find(s => s.cx < 1); + expect(left).toBeDefined(); + expect(left.w).toBeCloseTo(1, 1); + expect(left.h).toBe(2.6); + + // Above door + const above = segments.find(s => s.cy > 2.1); + expect(above).toBeDefined(); + expect(above.w).toBe(1); // door width + expect(above.h).toBeCloseTo(0.5, 1); + + // Right of door + const right = segments.find(s => s.cx > 2); + expect(right).toBeDefined(); + expect(right.w).toBeCloseTo(2, 1); + }); + + it('creates segments around a window with sill', () => { + const openings = [{ + position: 1, width: 1.2, height: 1.0, + bottom: 0.8, sillHeight: 0.8 + }]; + const segments = renderer._computeWallSegments(openings, 4, 2.6); + + // Should have: left, above, below (sill), right + expect(segments.length).toBeGreaterThanOrEqual(4); + + // Below-sill segment + const below = segments.find(s => s.h === 0.8 && s.w === 1.2); + expect(below).toBeDefined(); + }); + + it('handles multiple openings', () => { + const openings = [ + { position: 0.5, width: 0.8, height: 2.1, bottom: 0 }, + { position: 2.5, width: 1.0, height: 1.0, bottom: 0.8 } + ]; + const segments = renderer._computeWallSegments(openings, 5, 2.6); + // Should have segments between and around both openings + expect(segments.length).toBeGreaterThanOrEqual(4); + }); + }); + + describe('getFloors / getRooms', () => { + it('getFloors returns empty array with no house data', () => { + expect(renderer.getFloors()).toEqual([]); + }); + + it('getRooms returns empty array with no house data', () => { + expect(renderer.getRooms()).toEqual([]); + }); + + it('getFloors returns floor list after loading', () => { + renderer.houseData = { + floors: [ + { id: 'eg', name: 'Erdgeschoss', nameEN: 'Ground Floor', rooms: [] }, + { id: 'og', name: 'Obergeschoss', nameEN: 'Upper Floor', rooms: [] } + ] + }; + const floors = renderer.getFloors(); + expect(floors).toHaveLength(2); + expect(floors[0]).toEqual({ index: 0, id: 'eg', name: 'Erdgeschoss', nameEN: 'Ground Floor' }); + }); + + it('getRooms returns rooms for current floor', () => { + renderer.houseData = { + floors: [{ + id: 'eg', name: 'EG', rooms: [ + { id: 'r1', name: 'Room1', nameEN: 'R1', type: 'living', dimensions: { width: 4, length: 5 } }, + { id: 'r2', name: 'Room2', nameEN: 'R2', type: 'bedroom', dimensions: { width: 3, length: 3 } } + ] + }] + }; + renderer.currentFloor = 0; + const rooms = renderer.getRooms(); + expect(rooms).toHaveLength(2); + expect(rooms[0].id).toBe('r1'); + expect(rooms[0].area).toBe(20); + expect(rooms[1].area).toBe(9); + }); + + it('getRooms handles invalid floor index', () => { + renderer.houseData = { floors: [] }; + renderer.currentFloor = 5; + expect(renderer.getRooms()).toEqual([]); + }); + }); + + describe('setControlsEnabled', () => { + it('toggles controls.enabled', () => { + renderer.setControlsEnabled(false); + expect(renderer.controls.enabled).toBe(false); + renderer.setControlsEnabled(true); + expect(renderer.controls.enabled).toBe(true); + }); + }); + + describe('showFloor', () => { + it('updates currentFloor', () => { + renderer.houseData = { + floors: [ + { id: 'eg', name: 'EG', ceilingHeight: 2.6, rooms: [] }, + { id: 'og', name: 'OG', ceilingHeight: 2.5, rooms: [] } + ] + }; + renderer.showFloor(1); + expect(renderer.currentFloor).toBe(1); + }); + }); + + describe('_buildFurnitureMesh', () => { + it('returns group for catalog item with box parts', () => { + const item = { + id: 'test', + mesh: { + type: 'group', + parts: [ + { geometry: 'box', size: [1, 0.5, 0.5], position: [0, 0.25, 0], color: '#8b4513' } + ] + } + }; + const group = renderer._buildFurnitureMesh(item); + expect(group.children).toHaveLength(1); + }); + + it('returns group for cylinder parts', () => { + const item = { + id: 'test', + mesh: { + type: 'group', + parts: [ + { geometry: 'cylinder', radius: 0.1, height: 0.7, position: [0, 0.35, 0], color: '#333' } + ] + } + }; + const group = renderer._buildFurnitureMesh(item); + expect(group.children).toHaveLength(1); + }); + + it('returns empty group for missing mesh def', () => { + const group = renderer._buildFurnitureMesh({ id: 'no-mesh' }); + expect(group.children).toHaveLength(0); + }); + + it('skips unknown geometry types', () => { + const item = { + id: 'test', + mesh: { + type: 'group', + parts: [ + { geometry: 'sphere', radius: 0.5, position: [0, 0, 0], color: '#fff' } + ] + } + }; + const group = renderer._buildFurnitureMesh(item); + expect(group.children).toHaveLength(0); + }); + }); +}); diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..762dc25 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,10 @@ +// Polyfill browser globals missing in Node.js + +if (typeof globalThis.CustomEvent === 'undefined') { + globalThis.CustomEvent = class CustomEvent { + constructor(type, opts = {}) { + this.type = type; + this.detail = opts.detail || null; + } + }; +} diff --git a/tests/state.test.js b/tests/state.test.js new file mode 100644 index 0000000..018925f --- /dev/null +++ b/tests/state.test.js @@ -0,0 +1,347 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DesignState } from '../src/state.js'; + +function makeDesign() { + return { + name: 'Test Design', + rooms: [ + { + roomId: 'room-a', + furniture: [ + { catalogId: 'chair-1', position: { x: 1, z: 2 }, rotation: 0 }, + { catalogId: 'table-1', position: { x: 3, z: 4 }, rotation: 90 } + ] + }, + { + roomId: 'room-b', + furniture: [ + { catalogId: 'sofa-1', position: { x: 0, z: 0 }, rotation: 180 } + ] + } + ] + }; +} + +describe('DesignState', () => { + let state; + + beforeEach(() => { + state = new DesignState(makeDesign()); + }); + + // --- Read operations --- + + describe('read operations', () => { + it('returns the full design via .design', () => { + expect(state.design.name).toBe('Test Design'); + expect(state.design.rooms).toHaveLength(2); + }); + + it('getRoomDesign returns room by id', () => { + const room = state.getRoomDesign('room-a'); + expect(room).toBeDefined(); + expect(room.roomId).toBe('room-a'); + expect(room.furniture).toHaveLength(2); + }); + + it('getRoomDesign returns undefined for missing room', () => { + expect(state.getRoomDesign('nonexistent')).toBeUndefined(); + }); + + it('getFurniture returns specific item', () => { + const item = state.getFurniture('room-a', 0); + expect(item.catalogId).toBe('chair-1'); + expect(item.position).toEqual({ x: 1, z: 2 }); + }); + + it('getFurniture returns undefined for invalid index', () => { + expect(state.getFurniture('room-a', 99)).toBeUndefined(); + }); + + it('getFurniture returns undefined for missing room', () => { + expect(state.getFurniture('nonexistent', 0)).toBeUndefined(); + }); + + it('getRoomFurniture returns array for valid room', () => { + const furniture = state.getRoomFurniture('room-a'); + expect(furniture).toHaveLength(2); + }); + + it('getRoomFurniture returns empty array for missing room', () => { + expect(state.getRoomFurniture('nonexistent')).toEqual([]); + }); + }); + + // --- Write operations --- + + describe('updateFurniture', () => { + it('merges changes into furniture item', () => { + state.updateFurniture('room-a', 0, { color: 'red', rotation: 45 }); + const item = state.getFurniture('room-a', 0); + expect(item.color).toBe('red'); + expect(item.rotation).toBe(45); + // original fields preserved + expect(item.catalogId).toBe('chair-1'); + }); + + it('throws for invalid room', () => { + expect(() => state.updateFurniture('bad', 0, {})).toThrow('Room not found'); + }); + + it('throws for invalid index', () => { + expect(() => state.updateFurniture('room-a', 99, {})).toThrow('Furniture not found'); + }); + }); + + describe('moveFurniture', () => { + it('updates position', () => { + state.moveFurniture('room-a', 0, { x: 10, z: 20 }); + const item = state.getFurniture('room-a', 0); + expect(item.position.x).toBe(10); + expect(item.position.z).toBe(20); + }); + + it('partial position update (only x)', () => { + state.moveFurniture('room-a', 0, { x: 99 }); + const item = state.getFurniture('room-a', 0); + expect(item.position.x).toBe(99); + expect(item.position.z).toBe(2); // unchanged + }); + }); + + describe('rotateFurniture', () => { + it('sets rotation', () => { + state.rotateFurniture('room-a', 0, 270); + expect(state.getFurniture('room-a', 0).rotation).toBe(270); + }); + }); + + describe('addFurniture', () => { + it('adds to existing room and returns new index', () => { + const idx = state.addFurniture('room-a', { + catalogId: 'lamp-1', position: { x: 5, z: 5 }, rotation: 0 + }); + expect(idx).toBe(2); + expect(state.getRoomFurniture('room-a')).toHaveLength(3); + expect(state.getFurniture('room-a', 2).catalogId).toBe('lamp-1'); + }); + + it('creates room entry if it does not exist', () => { + const idx = state.addFurniture('room-new', { + catalogId: 'desk-1', position: { x: 0, z: 0 }, rotation: 0 + }); + expect(idx).toBe(0); + const room = state.getRoomDesign('room-new'); + expect(room).toBeDefined(); + expect(room.furniture).toHaveLength(1); + }); + + it('deep clones the placement (no aliasing)', () => { + const placement = { catalogId: 'x', position: { x: 1, z: 1 }, rotation: 0 }; + state.addFurniture('room-a', placement); + placement.position.x = 999; + expect(state.getFurniture('room-a', 2).position.x).toBe(1); + }); + }); + + describe('removeFurniture', () => { + it('removes and returns the item', () => { + const removed = state.removeFurniture('room-a', 0); + expect(removed.catalogId).toBe('chair-1'); + expect(state.getRoomFurniture('room-a')).toHaveLength(1); + // remaining item shifted down + expect(state.getFurniture('room-a', 0).catalogId).toBe('table-1'); + }); + + it('returns null for invalid room', () => { + expect(state.removeFurniture('bad', 0)).toBeNull(); + }); + + it('returns null for out-of-range index', () => { + expect(state.removeFurniture('room-a', -1)).toBeNull(); + expect(state.removeFurniture('room-a', 99)).toBeNull(); + }); + }); + + describe('loadDesign', () => { + it('replaces entire design', () => { + const newDesign = { name: 'New', rooms: [{ roomId: 'r1', furniture: [] }] }; + state.loadDesign(newDesign); + expect(state.design.name).toBe('New'); + expect(state.design.rooms).toHaveLength(1); + expect(state.getRoomDesign('room-a')).toBeUndefined(); + expect(state.getRoomDesign('r1')).toBeDefined(); + }); + + it('deep clones the loaded design', () => { + const newDesign = { name: 'New', rooms: [] }; + state.loadDesign(newDesign); + newDesign.name = 'Mutated'; + expect(state.design.name).toBe('New'); + }); + }); + + // --- Undo / Redo --- + + describe('undo / redo', () => { + it('initially cannot undo or redo', () => { + expect(state.canUndo).toBe(false); + expect(state.canRedo).toBe(false); + }); + + it('can undo after a mutation', () => { + state.moveFurniture('room-a', 0, { x: 99 }); + expect(state.canUndo).toBe(true); + }); + + it('undo reverts last mutation', () => { + state.moveFurniture('room-a', 0, { x: 99 }); + state.undo(); + expect(state.getFurniture('room-a', 0).position.x).toBe(1); + }); + + it('redo re-applies after undo', () => { + state.moveFurniture('room-a', 0, { x: 99 }); + state.undo(); + state.redo(); + expect(state.getFurniture('room-a', 0).position.x).toBe(99); + }); + + it('undo returns false when empty', () => { + expect(state.undo()).toBe(false); + }); + + it('redo returns false when empty', () => { + expect(state.redo()).toBe(false); + }); + + it('new mutation clears redo stack', () => { + state.moveFurniture('room-a', 0, { x: 10 }); + state.undo(); + expect(state.canRedo).toBe(true); + state.rotateFurniture('room-a', 0, 45); + expect(state.canRedo).toBe(false); + }); + + it('multiple undos walk back through history', () => { + state.moveFurniture('room-a', 0, { x: 10 }); + state.moveFurniture('room-a', 0, { x: 20 }); + state.moveFurniture('room-a', 0, { x: 30 }); + + state.undo(); + expect(state.getFurniture('room-a', 0).position.x).toBe(20); + state.undo(); + expect(state.getFurniture('room-a', 0).position.x).toBe(10); + state.undo(); + expect(state.getFurniture('room-a', 0).position.x).toBe(1); + }); + + it('respects max undo limit', () => { + // Default _maxUndo is 50 + for (let i = 0; i < 55; i++) { + state.moveFurniture('room-a', 0, { x: i }); + } + // Should have at most 50 undo entries + let count = 0; + while (state.canUndo) { + state.undo(); + count++; + } + expect(count).toBe(50); + }); + }); + + // --- Observers --- + + describe('onChange', () => { + it('fires listener on mutation', () => { + const listener = vi.fn(); + state.onChange(listener); + state.moveFurniture('room-a', 0, { x: 5 }); + expect(listener).toHaveBeenCalledWith('furniture-move', { roomId: 'room-a', index: 0 }); + }); + + it('fires correct event types', () => { + const events = []; + state.onChange((type) => events.push(type)); + + state.updateFurniture('room-a', 0, { color: 'blue' }); + state.moveFurniture('room-a', 0, { x: 5 }); + state.rotateFurniture('room-a', 0, 90); + state.addFurniture('room-a', { catalogId: 'x', position: { x: 0, z: 0 }, rotation: 0 }); + state.removeFurniture('room-a', 0); + state.loadDesign({ name: 'n', rooms: [{ roomId: 'room-a', furniture: [] }] }); + state.undo(); + state.redo(); + + expect(events).toEqual([ + 'furniture-update', 'furniture-move', 'furniture-rotate', + 'furniture-add', 'furniture-remove', 'design-load', + 'undo', 'redo' + ]); + }); + + it('returns unsubscribe function', () => { + const listener = vi.fn(); + const unsub = state.onChange(listener); + unsub(); + state.moveFurniture('room-a', 0, { x: 5 }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('catches listener errors without breaking', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const badListener = () => { throw new Error('boom'); }; + const goodListener = vi.fn(); + + state.onChange(badListener); + state.onChange(goodListener); + state.moveFurniture('room-a', 0, { x: 5 }); + + expect(goodListener).toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + }); + + // --- Serialization --- + + describe('toJSON', () => { + it('returns a deep clone of state', () => { + const json = state.toJSON(); + expect(json.name).toBe('Test Design'); + // mutating the clone shouldn't affect state + json.name = 'Mutated'; + expect(state.design.name).toBe('Test Design'); + }); + + it('preserves structure after mutations', () => { + state.addFurniture('room-a', { catalogId: 'new', position: { x: 0, z: 0 }, rotation: 0 }); + const json = state.toJSON(); + const room = json.rooms.find(r => r.roomId === 'room-a'); + expect(room.furniture).toHaveLength(3); + }); + }); + + // --- Constructor edge cases --- + + describe('constructor', () => { + it('deep clones initial design (no aliasing)', () => { + const design = makeDesign(); + const s = new DesignState(design); + design.rooms[0].furniture[0].position.x = 999; + expect(s.getFurniture('room-a', 0).position.x).toBe(1); + }); + + it('handles empty rooms array', () => { + const s = new DesignState({ name: 'Empty', rooms: [] }); + expect(s.design.rooms).toHaveLength(0); + expect(s.getRoomDesign('any')).toBeUndefined(); + }); + + it('handles null/undefined state gracefully in _rebuildIndex', () => { + const s = new DesignState({ name: 'No rooms' }); + expect(s.getRoomDesign('any')).toBeUndefined(); + }); + }); +}); diff --git a/tests/themes.test.js b/tests/themes.test.js new file mode 100644 index 0000000..2fbb226 --- /dev/null +++ b/tests/themes.test.js @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { COLORS } from '../src/renderer.js'; +import { ThemeManager } from '../src/themes.js'; +import { Scene, Color } from '../tests/__mocks__/three.js'; + +// Snapshot of original COLORS to restore between tests (shared mutable state) +const ORIGINAL_COLORS = { + wall: { ...COLORS.wall }, + floor: { ...COLORS.floor }, + ceiling: COLORS.ceiling, + door: COLORS.door, + window: COLORS.window, + windowFrame: COLORS.windowFrame, + grid: COLORS.grid, + selected: COLORS.selected +}; + +function restoreColors() { + Object.assign(COLORS.wall, ORIGINAL_COLORS.wall); + Object.assign(COLORS.floor, ORIGINAL_COLORS.floor); + COLORS.ceiling = ORIGINAL_COLORS.ceiling; + COLORS.door = ORIGINAL_COLORS.door; + COLORS.window = ORIGINAL_COLORS.window; + COLORS.windowFrame = ORIGINAL_COLORS.windowFrame; + COLORS.grid = ORIGINAL_COLORS.grid; + COLORS.selected = ORIGINAL_COLORS.selected; +} + +function makeMockRenderer() { + const scene = new Scene(); + // Add mock lights to the scene + scene.add({ isAmbientLight: true, intensity: 0.6, traverse: (fn) => fn({ isAmbientLight: true, intensity: 0.6 }) }); + scene.add({ isDirectionalLight: true, intensity: 0.8, traverse: (fn) => fn({ isDirectionalLight: true, intensity: 0.8 }) }); + + return { + scene, + currentFloor: 0, + _clearFloor: vi.fn(), + showFloor: vi.fn() + }; +} + +describe('ThemeManager', () => { + let tm, mockRenderer; + + beforeEach(() => { + restoreColors(); + mockRenderer = makeMockRenderer(); + tm = new ThemeManager(mockRenderer); + }); + + afterEach(() => { + restoreColors(); + }); + + describe('getThemes', () => { + it('returns array of theme descriptors', () => { + const themes = tm.getThemes(); + expect(themes.length).toBeGreaterThanOrEqual(4); + for (const theme of themes) { + expect(theme).toHaveProperty('id'); + expect(theme).toHaveProperty('name'); + expect(theme).toHaveProperty('swatch'); + expect(typeof theme.id).toBe('string'); + expect(typeof theme.name).toBe('string'); + expect(theme.swatch).toMatch(/^#[0-9a-f]{6}$/i); + } + }); + + it('includes expected theme ids', () => { + const ids = tm.getThemes().map(t => t.id); + expect(ids).toContain('default'); + expect(ids).toContain('modern'); + expect(ids).toContain('warm'); + expect(ids).toContain('dark'); + expect(ids).toContain('scandinavian'); + }); + }); + + describe('constructor', () => { + it('starts with default theme', () => { + expect(tm.currentTheme).toBe('default'); + }); + }); + + describe('applyTheme', () => { + it('updates currentTheme property', () => { + tm.applyTheme('dark'); + expect(tm.currentTheme).toBe('dark'); + }); + + it('does nothing for invalid theme id', () => { + tm.applyTheme('nonexistent'); + expect(tm.currentTheme).toBe('default'); + }); + + it('mutates the shared COLORS object', () => { + const origExterior = COLORS.wall.exterior; + tm.applyTheme('dark'); + expect(COLORS.wall.exterior).toBe(0x3a3a3a); + // Restore for other tests + tm.applyTheme('default'); + expect(COLORS.wall.exterior).toBe(origExterior); + }); + + it('calls _clearFloor and showFloor on renderer', () => { + tm.applyTheme('modern'); + expect(mockRenderer._clearFloor).toHaveBeenCalled(); + expect(mockRenderer.showFloor).toHaveBeenCalledWith(0); + }); + + it('updates scene background', () => { + tm.applyTheme('dark'); + expect(mockRenderer.scene.background._hex).toBe(0x222222); + }); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..310727c --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.js'], + setupFiles: ['tests/setup.js'], + }, + resolve: { + alias: { + 'three/addons/controls/OrbitControls.js': resolve('tests/__mocks__/OrbitControls.js'), + 'three': resolve('tests/__mocks__/three.js'), + } + } +});