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'), + } + } +});