diff --git a/package-lock.json b/package-lock.json index 3392bff..a3e3616 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "jsdom": "^24.0.0", "sharp": "^0.34.5", "vite": "^6.4.1", - "vitest": "^4.1.0" + "vitest": "^3.2.4" } }, "node_modules/@asamuzakjp/css-color": { @@ -1586,13 +1586,6 @@ "win32" ] }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1633,40 +1626,39 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", "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" + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0", + "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^0.30.17" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -1678,42 +1670,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0", - "pathe": "^2.0.3" + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", - "magic-string": "^0.30.21", + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", "pathe": "^2.0.3" }, "funding": { @@ -1721,25 +1713,28 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1962,6 +1957,16 @@ "balanced-match": "^1.0.0" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1986,15 +1991,32 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, "engines": { "node": ">=18" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -2128,13 +2150,6 @@ "proto-list": "~1.2.1" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2234,6 +2249,16 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2352,9 +2377,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, @@ -2809,6 +2834,13 @@ "node": ">=20" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsdom": { "version": "24.1.3", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", @@ -2862,6 +2894,13 @@ "node": ">=8" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -2985,17 +3024,6 @@ "dev": true, "license": "MIT" }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, "node_modules/p-locate": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", @@ -3095,6 +3123,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3423,9 +3461,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, @@ -3533,6 +3571,19 @@ "node": ">=8" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -3557,14 +3608,11 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } + "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.16", @@ -3583,10 +3631,30 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", "engines": { @@ -3733,72 +3801,89 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", "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", + "@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.3", - "std-env": "^4.0.0-rc.1", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", "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", + "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" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0", - "@vitest/browser-preview": "4.1.0", - "@vitest/browser-webdriverio": "4.1.0", - "@vitest/ui": "4.1.0", + "@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": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "jsdom": "*" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@opentelemetry/api": { + "@types/debug": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { + "@vitest/browser": { "optional": true }, "@vitest/ui": { @@ -3809,9 +3894,6 @@ }, "jsdom": { "optional": true - }, - "vite": { - "optional": false } } }, diff --git a/package.json b/package.json index 940821c..2dc3d6d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,6 @@ "jsdom": "^24.0.0", "sharp": "^0.34.5", "vite": "^6.4.1", - "vitest": "^4.1.0" + "vitest": "^3.2.4" } } diff --git a/src/composables/useHistory.js b/src/composables/useHistory.js deleted file mode 100644 index 9a73b92..0000000 --- a/src/composables/useHistory.js +++ /dev/null @@ -1,26 +0,0 @@ -const STORAGE_KEY = 'mbti_lab_result' - -export function useHistory() { - function saveResult(result) { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ - ...result, - completedAt: Date.now(), - })) - } - - function loadResult() { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return null - try { - return JSON.parse(raw) - } catch { - return null - } - } - - function clearResult() { - localStorage.removeItem(STORAGE_KEY) - } - - return { saveResult, loadResult, clearResult } -} diff --git a/src/composables/useResultStore.js b/src/composables/useResultStore.js new file mode 100644 index 0000000..fb77b51 --- /dev/null +++ b/src/composables/useResultStore.js @@ -0,0 +1,89 @@ +// Single module at the route seam for the result handoff. +// +// Two storage adapters sit behind this interface: +// - sessionStorage carries the fresh handoff from TestView to ResultView, +// read-and-consume so a stale session entry can never shadow the +// persisted history on later visits (e.g. arriving via HomeView's +// "view last result" link). +// - localStorage persists the latest result as history. +// +// Keys, JSON encoding/decoding, completedAt stamping, entry validation +// (type letters and finite percents), and fresh-then-history precedence +// all live here; views never touch storage directly. + +const SESSION_KEY = 'mbti_result' +const HISTORY_KEY = 'mbti_lab_result' + +// Position-ordered valid letters for an MBTI type. Absorbs the validation +// the deleted src/utils/formatMbtiType.js used to encode (and test in isolation). +const TYPE_POSITIONS = [ + ['E', 'I'], // energy + ['N', 'S'], // information + ['T', 'F'], // decisions + ['J', 'P'], // lifestyle +] + +// Dimension keys of `result.percents` with the letters each may hold. +const DIMENSIONS = [ + { key: 'EI', letters: ['E', 'I'] }, + { key: 'NS', letters: ['N', 'S'] }, + { key: 'FT', letters: ['F', 'T'] }, + { key: 'JP', letters: ['J', 'P'] }, +] + +// Canonicalize a type string ('infp' -> 'INFP'); '' when any letter is +// invalid for its position, the length is wrong, or it is not a string. +function normalizeType(value) { + if (typeof value !== 'string') return '' + const letters = value.trim().toUpperCase().split('') + if (letters.length !== TYPE_POSITIONS.length) return '' + return TYPE_POSITIONS.every((options, i) => options.includes(letters[i])) + ? letters.join('') + : '' +} + +// Every dimension must be present with finite numbers for both letters. +function hasFinitePercents(percents) { + if (!percents || typeof percents !== 'object') return false + return DIMENSIONS.every(({ key, letters }) => { + const dim = percents[key] + if (!dim || typeof dim !== 'object') return false + return letters.every((letter) => Number.isFinite(dim[letter])) + }) +} + +// Decode one stored entry; null for anything malformed. +function parseEntry(raw) { + if (!raw) return null + let value + try { + value = JSON.parse(raw) + } catch { + return null + } + if (!value || typeof value !== 'object') return null + const type = normalizeType(value.type) + if (!type || !hasFinitePercents(value.percents)) return null + return { ...value, type } +} + +export function useResultStore() { + // Persist a completed result: stamp it once and write the same entry to + // the fresh handoff (session) and the history (local). + function submit(result) { + const entry = { ...result, completedAt: Date.now() } + sessionStorage.setItem(SESSION_KEY, JSON.stringify(entry)) + localStorage.setItem(HISTORY_KEY, JSON.stringify(entry)) + } + + // Resolve the result to display: the fresh handoff first (consumed on + // read), then the persisted history. null when nothing valid remains. + function load() { + const fresh = parseEntry(sessionStorage.getItem(SESSION_KEY)) + sessionStorage.removeItem(SESSION_KEY) + if (fresh) return fresh + return parseEntry(localStorage.getItem(HISTORY_KEY)) + } + + return { submit, load } +} diff --git a/src/utils/formatMbtiType.js b/src/utils/formatMbtiType.js deleted file mode 100644 index 8c9a4c7..0000000 --- a/src/utils/formatMbtiType.js +++ /dev/null @@ -1,28 +0,0 @@ -const POSITIONS = [ - ['E', 'I'], // energy - ['N', 'S'], // information - ['T', 'F'], // decisions - ['J', 'P'], // lifestyle -] - -/** - * Normalize an MBTI type string. - * - * Accepts a four-letter type in any case, optionally surrounded by - * whitespace, and returns the canonical uppercase form (e.g. 'INFP'). - * Returns an empty string when the value is not a string or any letter - * is invalid for its position. - * - * @param {unknown} value - * @returns {string} - */ -export function formatMbtiType(value) { - if (typeof value !== 'string') return '' - - const letters = value.trim().toUpperCase().split('') - if (letters.length !== POSITIONS.length) return '' - - return POSITIONS.every((options, i) => options.includes(letters[i])) - ? letters.join('') - : '' -} diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 4e4503e..20c3499 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -52,14 +52,14 @@ import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' import LangToggle from '../components/LangToggle.vue' import SharePanel from '../components/SharePanel.vue' -import { useHistory } from '../composables/useHistory.js' +import { useResultStore } from '../composables/useResultStore.js' const router = useRouter() -const { loadResult } = useHistory() +const { load } = useResultStore() const lastResult = ref(null) const showPanel = ref(false) -onMounted(() => { lastResult.value = loadResult() }) +onMounted(() => { lastResult.value = load() }) function start(isShort) { sessionStorage.setItem('mbti_short', isShort ? '1' : '0') diff --git a/src/views/ResultView.vue b/src/views/ResultView.vue index 2a388c2..2051d4c 100644 --- a/src/views/ResultView.vue +++ b/src/views/ResultView.vue @@ -35,19 +35,15 @@ import LangToggle from '../components/LangToggle.vue' import ResultCard from '../components/ResultCard.vue' import ShareCard from '../components/ShareCard.vue' import SharePanel from '../components/SharePanel.vue' -import { useHistory } from '../composables/useHistory.js' +import { useResultStore } from '../composables/useResultStore.js' -const { loadResult } = useHistory() +const { load } = useResultStore() const result = ref(null) const shareCardRef = ref(null) const showPanel = ref(false) onMounted(() => { - const fresh = sessionStorage.getItem('mbti_result') - if (fresh) { - try { result.value = JSON.parse(fresh) } catch { /* ignore */ } - } - if (!result.value) result.value = loadResult() + result.value = load() }) diff --git a/src/views/TestView.vue b/src/views/TestView.vue index faa8102..184d56a 100644 --- a/src/views/TestView.vue +++ b/src/views/TestView.vue @@ -29,11 +29,11 @@ import ProgressBar from '../components/ProgressBar.vue' import QuestionCard from '../components/QuestionCard.vue' import LangToggle from '../components/LangToggle.vue' import { useTest } from '../composables/useTest.js' -import { useHistory } from '../composables/useHistory.js' +import { useResultStore } from '../composables/useResultStore.js' import { questions as allQuestions } from '../data/questions.js' const router = useRouter() -const { saveResult } = useHistory() +const { submit } = useResultStore() const { questions, currentIndex, answers, loadQuestions, answer, goBack, isComplete, result } = useTest() onMounted(() => { @@ -49,8 +49,7 @@ function onAnswer(choice) { watch(isComplete, (val) => { if (val && result.value) { - saveResult(result.value) - sessionStorage.setItem('mbti_result', JSON.stringify(result.value)) + submit(result.value) router.push('/result') } }) diff --git a/tests/ResultView.test.js b/tests/ResultView.test.js index 811c5f6..45aa922 100644 --- a/tests/ResultView.test.js +++ b/tests/ResultView.test.js @@ -96,6 +96,17 @@ describe('ResultView empty state', () => { expect(wrapper.find('.no-result').exists()).toBe(false) expect(wrapper.findComponent({ name: 'ResultCard' }).exists()).toBe(true) }) + + it('renders the empty state for a corrupted session entry instead of crashing ResultCard', async () => { + sessionStorage.setItem('mbti_result', JSON.stringify({ + type: 'XNFJ', // invalid letter for position 1 + percents: { EI: { E: 30, I: 70 }, NS: { N: 65, S: 35 }, FT: { F: 60, T: 40 }, JP: { J: 55, P: 45 } }, + })) + await mountView('zh', { stubs: { ResultCard: true, ShareCard: true } }) + + expect(wrapper.find('.no-result').exists()).toBe(true) + expect(wrapper.findComponent({ name: 'ResultCard' }).exists()).toBe(false) + }) }) function linkHref(wrapper) { diff --git a/tests/formatMbtiType.test.js b/tests/formatMbtiType.test.js deleted file mode 100644 index 52e5ce8..0000000 --- a/tests/formatMbtiType.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { formatMbtiType } from '../src/utils/formatMbtiType' - -describe('formatMbtiType', () => { - it('returns the canonical uppercase type for valid input', () => { - expect(formatMbtiType('INFJ')).toBe('INFJ') - expect(formatMbtiType('infp')).toBe('INFP') - expect(formatMbtiType('IsFp')).toBe('ISFP') - }) - - it('trims surrounding whitespace', () => { - expect(formatMbtiType(' enfp ')).toBe('ENFP') - expect(formatMbtiType('\tistj\n')).toBe('ISTJ') - }) - - it('returns an empty string for invalid letters at each position', () => { - // position 1 must be E/I - expect(formatMbtiType('XNFP')).toBe('') - // position 2 must be N/S - expect(formatMbtiType('IENP')).toBe('') - // position 3 must be T/F - expect(formatMbtiType('INJP')).toBe('') - // position 4 must be J/P - expect(formatMbtiType('INFX')).toBe('') - }) - - it('returns an empty string for wrong-length or empty input', () => { - expect(formatMbtiType('INF')).toBe('') - expect(formatMbtiType('INFJJ')).toBe('') - expect(formatMbtiType('')).toBe('') - expect(formatMbtiType(' ')).toBe('') - }) - - it('returns an empty string for non-string values', () => { - expect(formatMbtiType(null)).toBe('') - expect(formatMbtiType(undefined)).toBe('') - expect(formatMbtiType(1234)).toBe('') - expect(formatMbtiType({ type: 'INFJ' })).toBe('') - }) -}) diff --git a/tests/useResultStore.test.js b/tests/useResultStore.test.js new file mode 100644 index 0000000..10d3183 --- /dev/null +++ b/tests/useResultStore.test.js @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { useResultStore } from '../src/composables/useResultStore.js' + +const RESULT_A = { + type: 'INFP', + percents: { EI: { E: 30, I: 70 }, NS: { N: 65, S: 35 }, FT: { F: 60, T: 40 }, JP: { J: 45, P: 55 } }, +} +const RESULT_B = { + type: 'ESTJ', + percents: { EI: { E: 72, I: 28 }, NS: { N: 40, S: 60 }, FT: { F: 35, T: 65 }, JP: { J: 70, P: 30 } }, +} + +const SESSION_KEY = 'mbti_result' +const HISTORY_KEY = 'mbti_lab_result' + +function createStorage() { + const stored = new Map() + return { + getItem: (key) => stored.get(key) ?? null, + setItem: (key, value) => stored.set(key, String(value)), + removeItem: (key) => stored.delete(key), + clear: () => stored.clear(), + raw: (key) => stored.get(key) ?? null, + } +} + +let sessionStorage +let localStorage + +beforeEach(() => { + sessionStorage = createStorage() + localStorage = createStorage() + vi.stubGlobal('sessionStorage', sessionStorage) + vi.stubGlobal('localStorage', localStorage) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-22T12:00:00Z')) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +function putSession(value) { + sessionStorage.setItem(SESSION_KEY, typeof value === 'string' ? value : JSON.stringify(value)) +} + +function putHistory(value) { + localStorage.setItem(HISTORY_KEY, typeof value === 'string' ? value : JSON.stringify(value)) +} + +describe('submit: round-trip and stamping', () => { + it('writes one identically stamped entry to both storages', () => { + useResultStore().submit(RESULT_A) + + const entry = JSON.parse(sessionStorage.raw(SESSION_KEY)) + expect(entry).toEqual({ ...RESULT_A, completedAt: Date.parse('2026-09-22T12:00:00Z') }) + expect(localStorage.raw(HISTORY_KEY)).toBe(sessionStorage.raw(SESSION_KEY)) + }) + + it('load() returns the submitted result with its timestamp', () => { + useResultStore().submit(RESULT_A) + + expect(useResultStore().load()).toEqual({ + ...RESULT_A, + completedAt: Date.parse('2026-09-22T12:00:00Z'), + }) + }) +}) + +describe('load: fresh-then-history precedence', () => { + it('prefers the fresh session entry over history', () => { + putHistory(RESULT_A) + putSession(RESULT_B) + + expect(useResultStore().load()).toEqual(RESULT_B) + }) + + it('consumes the session entry on read and falls back to history', () => { + putHistory(RESULT_A) + putSession(RESULT_B) + + useResultStore().load() // consumes the handoff + + expect(sessionStorage.raw(SESSION_KEY)).toBeNull() + expect(useResultStore().load()).toEqual(RESULT_A) + }) + + it('falls back to history when there is no session entry', () => { + putHistory(RESULT_A) + + expect(useResultStore().load()).toEqual(RESULT_A) + }) + + it('returns the same result on repeat loads after a submit', () => { + const { submit, load } = useResultStore() + submit(RESULT_A) + + expect(load()).toEqual(load()) + }) +}) + +describe('load: stale session no longer shadows history', () => { + it('shows the newer history result on a later visit (read-and-consume)', () => { + const { submit, load } = useResultStore() + + // Tab 1: complete test A, land on /result -> handoff consumed there. + submit(RESULT_A) + expect(load()).toEqual({ ...RESULT_A, completedAt: Date.parse('2026-09-22T12:00:00Z') }) + + // Meanwhile tab 2 completes test B: history is newer, tab 1's session + // entry is already gone, so it cannot shadow B. + putHistory({ ...RESULT_B, completedAt: Date.parse('2026-09-22T12:30:00Z') }) + + // Tab 1 arrives via HomeView's "view last result" link. + expect(load().type).toBe('ESTJ') + }) +}) + +describe('load: malformed entries return null', () => { + it.each([ + ['non-JSON garbage', '{not json'], + ['JSON but not an object', '"INFP"'], + ['missing type', { percents: RESULT_A.percents }], + ['non-string type', { type: 1234, percents: RESULT_A.percents }], + ['wrong length', { type: 'INF', percents: RESULT_A.percents }], + ['too long', { type: 'INFJJ', percents: RESULT_A.percents }], + ['invalid letter at position 1', { type: 'XNFP', percents: RESULT_A.percents }], + ['invalid letter at position 2', { type: 'IENP', percents: RESULT_A.percents }], + ['invalid letter at position 3', { type: 'INJP', percents: RESULT_A.percents }], + ['invalid letter at position 4', { type: 'INFX', percents: RESULT_A.percents }], + ['missing percents', { type: 'INFP' }], + ['percents missing a dimension', { type: 'INFP', percents: { EI: { E: 30, I: 70 } } }], + ['dimension is not an object', { type: 'INFP', percents: { ...RESULT_A.percents, EI: 70 } }], + ['non-finite percent (NaN)', { type: 'INFP', percents: { ...RESULT_A.percents, EI: { E: NaN, I: 70 } } }], + ['non-finite percent (null)', { type: 'INFP', percents: { ...RESULT_A.percents, EI: { E: null, I: 70 } } }], + ['non-finite percent (string)', { type: 'INFP', percents: { ...RESULT_A.percents, EI: { E: '70', I: 30 } } }], + ])('rejects %s in the session entry', (_label, value) => { + putSession(value) + + expect(useResultStore().load()).toBeNull() + }) + + it('rejects the same malformations in the history entry', () => { + putSession(null) // no handoff + putHistory({ type: 'XNFP', percents: RESULT_A.percents }) + + expect(useResultStore().load()).toBeNull() + }) + + it('returns null when nothing is stored', () => { + expect(useResultStore().load()).toBeNull() + }) + + it('normalizes a stored type to its canonical uppercase form', () => { + putSession({ type: 'infp', percents: RESULT_A.percents }) + + expect(useResultStore().load()).toEqual({ type: 'INFP', percents: RESULT_A.percents }) + }) + + it('trims whitespace around a stored type', () => { + putSession({ type: ' enfp\t', percents: RESULT_A.percents }) + + expect(useResultStore().load().type).toBe('ENFP') + }) +})