diff --git a/bun.lock b/bun.lock index 6a57008..38ae39b 100644 --- a/bun.lock +++ b/bun.lock @@ -52,6 +52,7 @@ "tailwindcss": "^4.2.1", "typescript": "^5.7.2", "vite": "^8.0.0", + "vitest": "^4.1.0", }, }, }, @@ -386,6 +387,8 @@ "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], @@ -496,6 +499,12 @@ "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@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=="], + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -508,6 +517,20 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], + "@vitest/expect": ["@vitest/expect@4.1.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.0", "", { "dependencies": { "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.0", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A=="], + + "@vitest/runner": ["@vitest/runner@4.1.0", "", { "dependencies": { "@vitest/utils": "4.1.0", "pathe": "^2.0.3" } }, "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg=="], + + "@vitest/spy": ["@vitest/spy@4.1.0", "", {}, "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw=="], + + "@vitest/utils": ["@vitest/utils@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -532,6 +555,8 @@ "args-tokenizer": ["args-tokenizer@0.3.0", "", {}, "sha512-xXAd7G2Mll5W8uo37GETpQ2VrE84M181Z7ugHFGQnJZ50M2mbOv0osSZ9VsSgPfJQ+LVG0prSi0th+ELMsno7Q=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], @@ -568,6 +593,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001762", "", {}, "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -672,6 +699,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "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=="], @@ -682,6 +711,8 @@ "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], @@ -692,6 +723,8 @@ "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], @@ -948,6 +981,8 @@ "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1086,6 +1121,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -1098,8 +1135,12 @@ "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=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], @@ -1132,12 +1173,16 @@ "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="], "tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="], @@ -1194,12 +1239,16 @@ "vite": ["vite@8.0.0", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q=="], + "vitest": ["vitest@4.1.0", "", { "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", "@vitest/pretty-format": "4.1.0", "@vitest/runner": "4.1.0", "@vitest/snapshot": "4.1.0", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.0", "@vitest/browser-preview": "4.1.0", "@vitest/browser-webdriverio": "4.1.0", "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "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=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], diff --git a/package.json b/package.json index 96c6ac1..0d0652c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "lint": "bun run --parallel \"oxlint\" \"oxfmt --check\"", "prepare": "husky", "release": "bumpp --commit --tag --push", - "start": "vite build && electrobun dev" + "start": "vite build && electrobun dev", + "test": "bun run vitest run" }, "dependencies": { "@base-ui/react": "^1.3.0", @@ -63,7 +64,8 @@ "oxlint-tsgolint": "^0.17.0", "tailwindcss": "^4.2.1", "typescript": "^5.7.2", - "vite": "^8.0.0" + "vite": "^8.0.0", + "vitest": "^4.1.0" }, "lint-staged": { "*": "oxfmt --no-error-on-unmatched-pattern", diff --git a/src/bun/__tests__/config.test.ts b/src/bun/__tests__/config.test.ts new file mode 100644 index 0000000..36beac7 --- /dev/null +++ b/src/bun/__tests__/config.test.ts @@ -0,0 +1,242 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from "fs"; +import { join } from "path"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +// vi.hoisted runs before ESM imports are resolved. +// Use plain require() — no "typeof import()" type assertions, which can confuse +// Vitest's AST hoisting transform. +const tmpDir = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any + const path = require("path") as any; + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any + const os = require("os") as any; + return path.join(os.tmpdir(), `storyforge-test-${process.pid}`) as string; +}); + +vi.mock("electrobun/bun", () => ({ + Utils: { paths: { appData: tmpDir } }, +})); + +// Mock top-level electrobun so the controller can be imported without a native runtime +vi.mock("electrobun", () => ({ + default: { Updater: {} }, + Utils: { openExternal: vi.fn(), quit: vi.fn(), showNotification: vi.fn() }, +})); + +// Mock src/bun/index.ts (imported by the controller as "..") +vi.mock("../index", () => ({ + mainWindow: { + webview: { rpc: { send: vi.fn() } }, + minimize: vi.fn(), + maximize: vi.fn(), + unmaximize: vi.fn(), + isMaximized: vi.fn(() => false), + }, +})); + +import { utilsController } from "../controllers/utils"; +import { + getInstallationsPath, + getModsCachePath, + getStreamMode, + getVersionsPath, + getPlatform, + slugify, +} from "../utils"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const configPath = join(tmpDir, "storyforge", "config.json"); + +// Write via Bun.write() (stateless helper) rather than the singleton configFile, +// so we avoid any BunFile instance-level caching between tests. +async function writeConfig(data: Record) { + mkdirSync(join(tmpDir, "storyforge"), { recursive: true }); + await Bun.write(configPath, JSON.stringify(data)); +} + +beforeAll(() => { + mkdirSync(join(tmpDir, "storyforge"), { recursive: true }); +}); + +beforeEach(() => { + if (existsSync(configPath)) rmSync(configPath); +}); + +// --------------------------------------------------------------------------- +// getStreamMode +// --------------------------------------------------------------------------- + +describe("getStreamMode", () => { + it("returns false by default when no config file exists", async () => { + expect(await getStreamMode()).toBe(false); + }); + + it("returns true when config has streamMode: true", async () => { + await writeConfig({ streamMode: true }); + expect(await getStreamMode()).toBe(true); + }); + + it("returns false when streamMode is not a boolean", async () => { + await writeConfig({ streamMode: "yes" }); + expect(await getStreamMode()).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// getVersionsPath +// --------------------------------------------------------------------------- + +describe("getVersionsPath", () => { + it("returns default path under appData when no relevant config is saved", async () => { + await writeConfig({}); // empty config — no versionPath key + expect(await getVersionsPath()).toBe(join(tmpDir, "storyforge", "versions")); + }); + + it("returns custom path saved in config", async () => { + await writeConfig({ versionPath: "/custom/versions" }); + expect(await getVersionsPath()).toBe("/custom/versions"); + }); +}); + +// --------------------------------------------------------------------------- +// getInstallationsPath +// --------------------------------------------------------------------------- + +describe("getInstallationsPath", () => { + it("returns default path under appData when no relevant config is saved", async () => { + await writeConfig({}); // empty config — no installationsPath key + expect(await getInstallationsPath()).toBe(join(tmpDir, "storyforge", "installations")); + }); + + it("returns custom path saved in config", async () => { + await writeConfig({ installationsPath: "/custom/installations" }); + expect(await getInstallationsPath()).toBe("/custom/installations"); + }); +}); + +// --------------------------------------------------------------------------- +// getModsCachePath +// --------------------------------------------------------------------------- + +describe("getModsCachePath", () => { + it("returns default path under appData when no relevant config is saved", async () => { + await writeConfig({}); // empty config — no modsCachePath key + expect(await getModsCachePath()).toBe(join(tmpDir, "storyforge", "mods_cache")); + }); + + it("returns custom path saved in config", async () => { + await writeConfig({ modsCachePath: "/custom/mods" }); + expect(await getModsCachePath()).toBe("/custom/mods"); + }); +}); + +// --------------------------------------------------------------------------- +// setConfig / getConfig (controller) +// --------------------------------------------------------------------------- + +describe("setConfig", () => { + it("writes all fields to the config file", async () => { + await utilsController.setConfig({ + streamMode: true, + versionPath: "/my/versions", + installationsPath: "/my/installations", + modsCachePath: "/my/mods", + }); + + const saved = JSON.parse(readFileSync(configPath, "utf8")) as Record; + expect(saved.streamMode).toBe(true); + expect(saved.versionPath).toBe("/my/versions"); + expect(saved.installationsPath).toBe("/my/installations"); + expect(saved.modsCachePath).toBe("/my/mods"); + }); + + it("merges partial updates without clobbering existing fields", async () => { + await writeConfig({ streamMode: true, versionPath: "/my/versions" }); + + await utilsController.setConfig({ installationsPath: "/new/installations" }); + + const saved = JSON.parse(readFileSync(configPath, "utf8")) as Record; + expect(saved.streamMode).toBe(true); // unchanged + expect(saved.versionPath).toBe("/my/versions"); // unchanged + expect(saved.installationsPath).toBe("/new/installations"); // updated + }); +}); + +describe("getConfig", () => { + it("reflects values written by setConfig", async () => { + await utilsController.setConfig({ + streamMode: true, + versionPath: "/v", + installationsPath: "/i", + modsCachePath: "/m", + }); + + const cfg = await utilsController.getConfig(); + expect(cfg.streamMode).toBe(true); + expect(cfg.versionPath).toBe("/v"); + expect(cfg.installationsPath).toBe("/i"); + expect(cfg.modsCachePath).toBe("/m"); + }); + + it("returns defaults when no config has been saved", async () => { + const cfg = await utilsController.getConfig(); + expect(cfg.streamMode).toBe(false); + expect(cfg.versionPath).toBe(join(tmpDir, "storyforge", "versions")); + expect(cfg.installationsPath).toBe(join(tmpDir, "storyforge", "installations")); + expect(cfg.modsCachePath).toBe(join(tmpDir, "storyforge", "mods_cache")); + }); +}); + +// --------------------------------------------------------------------------- +// slugify (pure function) +// --------------------------------------------------------------------------- + +describe("slugify", () => { + it("lowercases and converts spaces to hyphens", () => { + expect(slugify("My World")).toBe("my-world"); + }); + + it("removes special characters", () => { + expect(slugify("Hello, World!")).toBe("hello-world"); + }); + + it("collapses multiple hyphens into one", () => { + expect(slugify("foo---bar")).toBe("foo-bar"); + }); + + it("strips leading and trailing hyphens", () => { + expect(slugify(" My Installation ")).toBe("my-installation"); + }); + + it("returns 'default' for empty string", () => { + expect(slugify("")).toBe("default"); + }); + + it("returns 'default' when all characters are stripped", () => { + expect(slugify("!!!")).toBe("default"); + }); +}); + +// --------------------------------------------------------------------------- +// getPlatform (pure function) +// --------------------------------------------------------------------------- + +describe("getPlatform", () => { + it("returns 'mac' on darwin", () => { + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + expect(getPlatform()).toBe("mac"); + }); + + it("returns 'windows' on win32", () => { + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + expect(getPlatform()).toBe("windows"); + }); + + it("returns 'linux' on linux", () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + expect(getPlatform()).toBe("linux"); + }); +}); diff --git a/src/bun/controllers/utils.ts b/src/bun/controllers/utils.ts index 91e2c08..1678fec 100644 --- a/src/bun/controllers/utils.ts +++ b/src/bun/controllers/utils.ts @@ -1,7 +1,13 @@ import Electrobun, { Utils } from "electrobun"; import { InferRPCSchema } from "@/shared/helper"; import { mainWindow } from ".."; -import { getStreamMode } from "../utils"; +import { + getConfigFile, + getInstallationsPath, + getModsCachePath, + getStreamMode, + getVersionsPath, +} from "../utils"; export const utilsController = { getVersion: async (): Promise => { @@ -33,6 +39,35 @@ export const utilsController = { getStreamMode: async (): Promise => { return await getStreamMode(); }, + getConfig: async (): Promise<{ + streamMode: boolean; + versionPath: string; + installationsPath: string; + modsCachePath: string; + }> => { + const streamMode = await getStreamMode(); + const versionPath = await getVersionsPath(); + const installationsPath = await getInstallationsPath(); + const modsCachePath = await getModsCachePath(); + return { streamMode, versionPath, installationsPath, modsCachePath }; + }, + setConfig: async (config: { + streamMode?: boolean; + versionPath?: string; + installationsPath?: string; + modsCachePath?: string; + }): Promise => { + const configFile = getConfigFile(); + const exists = await configFile.exists(); + const configText = exists ? await configFile.text() : "{}"; + const current = Bun.JSON5.parse(configText.trim() || "{}") as Record; + if (config.streamMode !== undefined) current.streamMode = config.streamMode; + if (config.versionPath !== undefined) current.versionPath = config.versionPath; + if (config.installationsPath !== undefined) + current.installationsPath = config.installationsPath; + if (config.modsCachePath !== undefined) current.modsCachePath = config.modsCachePath; + await configFile.write(JSON.stringify(current, null, 2)); + }, openLink: ({ url }: { url: string }): void => { Utils.openExternal(url); }, diff --git a/src/bun/utils.ts b/src/bun/utils.ts index cc08a35..9d67782 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -2,12 +2,16 @@ import { mkdirSync } from "fs"; import { join } from "path"; import { Utils } from "electrobun/bun"; -export const configFile = Bun.file(join(Utils.paths.appData, "storyforge", "config.json")); -export const oldSettingsFile = Bun.file( - join(Utils.paths.appData, "storyforge", "store", "settings.json"), -); +export function getConfigFile(): ReturnType { + return Bun.file(join(Utils.paths.appData, "storyforge", "config.json")); +} + +function getOldSettingsFile(): ReturnType { + return Bun.file(join(Utils.paths.appData, "storyforge", "store", "settings.json")); +} export async function getOldSettings() { + const oldSettingsFile = getOldSettingsFile(); if (await oldSettingsFile.exists()) { const oldConfigText = await oldSettingsFile.text(); const oldConfig = Bun.JSON5.parse(oldConfigText); @@ -23,6 +27,7 @@ export async function getOldSettings() { } export async function getStreamMode(): Promise { + const configFile = getConfigFile(); if (!(await configFile.exists())) { const oldSettings = await getOldSettings(); if (oldSettings) { @@ -59,6 +64,7 @@ export async function getStreamMode(): Promise { } export async function getModsCachePath(): Promise { + const configFile = getConfigFile(); if (!(await configFile.exists())) { mkdirSync(join(Utils.paths.appData, "storyforge"), { recursive: true }); await configFile.write( @@ -82,6 +88,7 @@ export async function getModsCachePath(): Promise { } export async function getVersionsPath(): Promise { + const configFile = getConfigFile(); if (!(await configFile.exists())) { const oldSettings = await getOldSettings(); if (oldSettings) { @@ -123,6 +130,7 @@ export async function getVersionsPath(): Promise { } export async function getInstallationsPath(): Promise { + const configFile = getConfigFile(); if (!(await configFile.exists())) { const oldSettings = await getOldSettings(); if (oldSettings) { diff --git a/src/mainview/routes/settings/index.tsx b/src/mainview/routes/settings/index.tsx index 42efee6..9155139 100644 --- a/src/mainview/routes/settings/index.tsx +++ b/src/mainview/routes/settings/index.tsx @@ -1,9 +1,232 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { useForm } from "@tanstack/react-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, useRouteContext } from "@tanstack/react-router"; +import { useState } from "react"; +import { Button } from "@/mainview/components/ui/button"; +import { Checkbox } from "@/mainview/components/ui/checkbox"; +import { LaptopMinimalCheckIcon } from "@/mainview/components/ui/icons/laptop-minimal-check"; +import { MoonIcon } from "@/mainview/components/ui/icons/moon"; +import { SunMediumIcon } from "@/mainview/components/ui/icons/sun-medium"; +import { Input } from "@/mainview/components/ui/input"; +import { Label } from "@/mainview/components/ui/label"; +import { ScrollArea } from "@/mainview/components/ui/scroll-area"; +import { Separator } from "@/mainview/components/ui/separator"; +import { useTheme, type UserTheme } from "@/mainview/contexts/theme.context"; +import type { ElectroViewContext } from "@/mainview/main"; export const Route = createFileRoute("/settings/")({ component: RouteComponent, }); +type Config = { + streamMode: boolean; + versionPath: string; + installationsPath: string; + modsCachePath: string; +}; + +const themes: { value: UserTheme; label: string; icon: React.ReactNode }[] = [ + { value: "light", label: "Light", icon: }, + { value: "dark", label: "Dark", icon: }, + { value: "system", label: "System", icon: }, +]; + +const emptyConfig: Config = { + streamMode: false, + versionPath: "", + installationsPath: "", + modsCachePath: "", +}; + +function _makeSettingsForm(defaultValues: Config) { + return useForm({ defaultValues }); +} +type SettingsFormApi = ReturnType; + +function SettingsForm({ + form, + version, + electroview, +}: { + form: SettingsFormApi; + version?: string; + electroview: ElectroViewContext; +}) { + const { userTheme, setTheme } = useTheme(); + const queryClient = useQueryClient(); + const [showSaved, setShowSaved] = useState(false); + const [saveError, setSaveError] = useState(null); + + const { mutate: saveConfig, isPending } = useMutation({ + mutationFn: async (values: Config) => { + await electroview.rpc?.request.setConfig(values); + return values; + }, + onSuccess: (savedValues) => { + setSaveError(null); + queryClient.setQueryData(["config"], savedValues); + form.reset(savedValues); + setShowSaved(true); + }, + onError: (error) => { + const message = error instanceof Error ? error.message : "Failed to save settings"; + setSaveError(message); + }, + }); + + return ( + +
+
+

Appearance

+
+ {themes.map(({ value, label, icon }) => ( + + ))} +
+
+ + + +
+
+

Privacy

+ + {(field) => ( +
+ field.handleChange(checked === true)} + /> +
+ +

Hide server IP addresses

+
+
+ )} +
+
+ + + +
+

Paths

+ + {(field) => ( +
+ + field.handleChange(e.target.value)} + /> +
+ )} +
+ + {(field) => ( +
+ + field.handleChange(e.target.value)} + /> +
+ )} +
+ + {(field) => ( +
+ + field.handleChange(e.target.value)} + /> +
+ )} +
+
+ +
+ +
+ {saveError ? ( +

{saveError}

+ ) : ( + s.isDirty}> + {(isDirty) => + isDirty ? ( +

Unsaved changes

+ ) : showSaved ? ( +

Saved ✓

+ ) : null + } +
+ )} +
+
+
+ + + +
+

About

+

Story Forge {version ?? "…"}

+
+
+
+ ); +} + function RouteComponent() { - return
WIP
; + const { electroview } = useRouteContext({ from: "/settings/" }); + + const { data: config, isLoading } = useQuery({ + queryKey: ["config"], + queryFn: () => electroview.rpc?.request.getConfig(), + }); + + const form = useForm({ + defaultValues: config ?? emptyConfig, + }); + + const { data: version } = useQuery({ + queryKey: ["version"], + queryFn: () => electroview.rpc?.request.getVersion(), + }); + + if (isLoading || !config) { + return
Loading…
; + } + + return ; } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a613db7 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { join } from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@": join(import.meta.dirname, "src"), + }, + }, + test: { + include: ["src/**/__tests__/**/*.test.ts"], + }, +});