diff --git a/core/event/eventManager.ts b/core/event/eventManager.ts index deaa03a..b1c54bc 100644 --- a/core/event/eventManager.ts +++ b/core/event/eventManager.ts @@ -51,7 +51,8 @@ function registerAfter(eventName: K, listener: Listene } const arr = afterListeners[eventName]!; arr.push(listener as Listener); - arr.sort((a, b) => (b.priority ?? Priority.NORMAL) - (a.priority ?? Priority.NORMAL)); + // 優先度の値が小さいほど先に実行される(HIGHEST=1 が最初) + arr.sort((a, b) => (a.priority ?? Priority.NORMAL) - (b.priority ?? Priority.NORMAL)); } function registerBefore(eventName: K, listener: Listener) { @@ -60,7 +61,8 @@ function registerBefore(eventName: K, listener: Liste } const arr = beforeListeners[eventName]!; arr.push(listener as Listener); - arr.sort((a, b) => (b.priority ?? Priority.NORMAL) - (a.priority ?? Priority.NORMAL)); + // 優先度の値が小さいほど先に実行される(HIGHEST=1 が最初) + arr.sort((a, b) => (a.priority ?? Priority.NORMAL) - (b.priority ?? Priority.NORMAL)); } // ---------- dispatch ---------- diff --git a/core/event/types.ts b/core/event/types.ts index 3b527d3..6f6dc3f 100644 --- a/core/event/types.ts +++ b/core/event/types.ts @@ -6,10 +6,11 @@ export interface Listener { /** 優先度 */ export enum Priority { - LOWEST = 5, - LOW = 4, - NORMAL = 3, - HIGH = 2, HIGHEST = 1, - MONITOR = 0 + HIGH = 2, + NORMAL = 3, + LOW = 4, + LOWEST = 5, + /** 監視用(最後に実行される) */ + MONITOR = 6 } diff --git a/package-lock.json b/package-lock.json index 85023eb..f1049ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,13 @@ "@minecraft/server": "^2.4.0", "@minecraft/server-ui": "^2.0.0", "@types/node": "^24.9.2", + "@vitest/ui": "^4.0.17", "eslint": "^9.38.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.2", "vite": "^7.1.7", - "vite-plugin-dts": "^4.5.4" + "vite-plugin-dts": "^4.5.4", + "vitest": "^4.0.17" } }, "node_modules/@babel/helper-string-parser": { @@ -910,6 +912,13 @@ "license": "MIT", "peer": true }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -1406,6 +1415,13 @@ "sprintf-js": "~1.0.2" } }, + "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/argparse": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", @@ -1413,6 +1429,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1693,6 +1727,149 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", + "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", + "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.17", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", + "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", + "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", + "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", + "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.17.tgz", + "integrity": "sha512-hRDjg6dlDz7JlZAvjbiCdAJ3SDG+NH8tjZe21vjxfvT2ssYAn72SRXMge3dKKABm3bIJ3C+3wdunIdur8PHEAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.17", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.17" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", + "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.27", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz", @@ -1928,6 +2105,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1956,6 +2143,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2084,6 +2281,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "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" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2300,6 +2504,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -2363,6 +2577,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2814,6 +3035,16 @@ "pathe": "^2.0.1" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2854,6 +3085,17 @@ "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/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3175,6 +3417,28 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3202,6 +3466,20 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -3251,6 +3529,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3268,6 +3563,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -3468,6 +3783,84 @@ } } }, + "node_modules/vitest": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", + "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.17", + "@vitest/mocker": "4.0.17", + "@vitest/pretty-format": "4.0.17", + "@vitest/runner": "4.0.17", + "@vitest/snapshot": "4.0.17", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.17", + "@vitest/browser-preview": "4.0.17", + "@vitest/browser-webdriverio": "4.0.17", + "@vitest/ui": "4.0.17", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -3491,6 +3884,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index ff648d2..40c2c60 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,11 @@ "type": "module", "scripts": { "build": "tsc && vite build", - "prepare": "npm run build" + "prepare": "npm run build", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage" }, "repository": { "type": "git", @@ -59,14 +63,16 @@ }, "devDependencies": { "@eslint/js": "^9.38.0", + "@minecraft/math": "^2.2.11", "@minecraft/server": "^2.4.0", "@minecraft/server-ui": "^2.0.0", - "@minecraft/math": "^2.2.11", "@types/node": "^24.9.2", + "@vitest/ui": "^4.0.17", "eslint": "^9.38.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.2", "vite": "^7.1.7", - "vite-plugin-dts": "^4.5.4" + "vite-plugin-dts": "^4.5.4", + "vitest": "^4.0.17" } } diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..e2719e8 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,161 @@ +# KeystoneCore テストガイド + +KeystoneCoreの単体テスト環境です。 + +## テストの実行 + +```bash +# すべてのテストを実行 +npm test + +# ウォッチモードでテストを実行 +npm run test:watch + +# テストUIを起動 +npm run test:ui + +# カバレッジを含めてテストを実行 +npm run test:coverage +``` + +## ディレクトリ構造 + +``` +tests/ +├── mocks/ # モックファイル +│ ├── minecraft-server.ts # @minecraft/server のモック +│ ├── minecraft-server-ui.ts # @minecraft/server-ui のモック +│ └── test-utils.ts # テスト用ヘルパー関数 +└── unit/ # ユニットテスト + ├── math/ # 数学関連のテスト + ├── timer/ # タイマー関連のテスト + ├── event/ # イベント関連のテスト + └── form/ # フォーム関連のテスト +``` + +## モックの使い方 + +### @minecraft/server モック + +`@minecraft/server` パッケージのモックが自動的に適用されます。 + +```typescript +import { system, world, Player } from '@minecraft/server'; +import { createTestPlayer, tickSystem } from '../mocks/test-utils'; + +// プレイヤーを作成 +const player = createTestPlayer('TestPlayer'); + +// システムタイマーをシミュレート +tickSystem(10); // 10 tick進める +``` + +### @minecraft/server-ui モック + +フォームUIのモックも自動的に適用されます。 + +```typescript +import { ActionFormData } from '@minecraft/server-ui'; + +// フォームデータはモックされている +const form = new ActionFormData(); +``` + +## テストの書き方 + +### 公開APIを中心にテスト + +ユーザーが実際に使用するインターフェースをテストします。 + +```typescript +import { Vector3 } from '@/math/vector3'; + +describe('Vector3', () => { + it('2つのベクトルを加算できる', () => { + const v1 = new Vector3(1, 2, 3); + const v2 = new Vector3(4, 5, 6); + const result = v1.addVector(v2); + + expect(result.x).toBe(5); + expect(result.y).toBe(7); + expect(result.z).toBe(9); + }); +}); +``` + +### ユーザー目線のテストケース + +実際の使用シナリオを想定してテストを書きます。 + +```typescript +it('プレイヤーにウェルカムメッセージを表示できる', async () => { + const player = createTestPlayer(); + const form = createActionForm({ + title: 'Welcome!', + body: 'Hello, player!', + buttons: [ + new Button({ text: 'OK', handle: () => {} }) + ] + }); + + await form.send(player); + // フォームが正しく表示されたことを確認 +}); +``` + +## 注意事項 + +### EventManager のテスト + +EventManagerは初期化時にworld.afterEvents/beforeEventsを自動的にsubscribeするため、 +通常の単体テストが難しい場合があります。この場合は統合テストを検討してください。 + +### Timer のテスト + +Timerのテストでは`tickSystem()`ヘルパーを使用してシステムタイマーをシミュレートします。 + +```typescript +import { repeating } from '@/timer/timer'; +import { tickSystem } from '../../mocks/test-utils'; + +it('定期的にコールバックを実行できる', () => { + const callback = vi.fn(); + + repeating({ + every: 5, + run: callback + }); + + // 5 tick進める + tickSystem(5); + expect(callback).toHaveBeenCalledTimes(1); +}); +``` + +## カバレッジ + +テストカバレッジは以下のコマンドで確認できます: + +```bash +npm run test:coverage +``` + +カバレッジレポートは `coverage/` ディレクトリに生成されます。 + +## トラブルシューティング + +### モックが動作しない + +- `vitest.config.ts` でaliasが正しく設定されているか確認 +- テストファイルでimportパスが正しいか確認(`@minecraft/server` ではなく相対パス) + +### テストがタイムアウトする + +非同期処理やPromiseを使用する場合は、適切にawaitしているか確認してください。 + +```typescript +it('非同期処理のテスト', async () => { + const result = await someAsyncFunction(); + expect(result).toBe(expected); +}); +``` diff --git a/tests/mocks/minecraft-server-ui.ts b/tests/mocks/minecraft-server-ui.ts new file mode 100644 index 0000000..844534e --- /dev/null +++ b/tests/mocks/minecraft-server-ui.ts @@ -0,0 +1,203 @@ +import type { Player } from './minecraft-server'; + +/** + * @minecraft/server-ui のモック + * KeystoneCore のテスト用モック実装 + */ + +// ActionFormResponse モック +export interface ActionFormResponse { + canceled: boolean; + selection?: number; +} + +// ModalFormResponse モック +export interface ModalFormResponse { + canceled: boolean; + formValues?: (string | number | boolean)[]; +} + +// MessageFormResponse モック +export interface MessageFormResponse { + canceled: boolean; + selection?: number; +} + +// ActionFormData モック +export class ActionFormData { + private _title = ''; + private _body = ''; + private _buttons: Array<{ text: string; iconPath?: string }> = []; + + title(titleText: string): this { + this._title = titleText; + return this; + } + + body(bodyText: string): this { + this._body = bodyText; + return this; + } + + button(text: string, iconPath?: string): this { + this._buttons.push({ text, iconPath }); + return this; + } + + async show(player: Player): Promise { + // デフォルトでは最初のボタンが選択されたことにする + return { + canceled: false, + selection: 0, + }; + } + + // テスト用ヘルパー + __getTitle() { + return this._title; + } + + __getBody() { + return this._body; + } + + __getButtons() { + return this._buttons; + } +} + +// ModalFormData モック +export class ModalFormData { + private _title = ''; + private _controls: Array<{ + type: 'textField' | 'toggle' | 'slider' | 'dropdown'; + label: string; + placeholderText?: string; + defaultValue?: string | number | boolean; + options?: string[]; + minimumValue?: number; + maximumValue?: number; + valueStep?: number; + }> = []; + + title(titleText: string): this { + this._title = titleText; + return this; + } + + textField(label: string, placeholderText?: string, defaultValue?: string): this { + this._controls.push({ type: 'textField', label, placeholderText, defaultValue }); + return this; + } + + toggle(label: string, defaultValue?: boolean): this { + this._controls.push({ type: 'toggle', label, defaultValue }); + return this; + } + + slider( + label: string, + minimumValue: number, + maximumValue: number, + valueStep?: number, + defaultValue?: number + ): this { + this._controls.push({ + type: 'slider', + label, + minimumValue, + maximumValue, + valueStep, + defaultValue, + }); + return this; + } + + dropdown(label: string, options: string[], defaultValueIndex?: number): this { + this._controls.push({ type: 'dropdown', label, options, defaultValue: defaultValueIndex }); + return this; + } + + async show(player: Player): Promise { + // デフォルトで各コントロールのデフォルト値を返す + const formValues = this._controls.map((control) => { + if (control.type === 'textField') { + return control.defaultValue || ''; + } else if (control.type === 'toggle') { + return control.defaultValue ?? false; + } else if (control.type === 'slider') { + return control.defaultValue ?? control.minimumValue ?? 0; + } else if (control.type === 'dropdown') { + return (control.defaultValue as number) ?? 0; + } + return ''; + }); + + return { + canceled: false, + formValues, + }; + } + + // テスト用ヘルパー + __getTitle() { + return this._title; + } + + __getControls() { + return this._controls; + } +} + +// MessageFormData モック +export class MessageFormData { + private _title = ''; + private _body = ''; + private _button1 = ''; + private _button2 = ''; + + title(titleText: string): this { + this._title = titleText; + return this; + } + + body(bodyText: string): this { + this._body = bodyText; + return this; + } + + button1(text: string): this { + this._button1 = text; + return this; + } + + button2(text: string): this { + this._button2 = text; + return this; + } + + async show(player: Player): Promise { + // デフォルトでは button1 が選択されたことにする + return { + canceled: false, + selection: 0, + }; + } + + // テスト用ヘルパー + __getTitle() { + return this._title; + } + + __getBody() { + return this._body; + } + + __getButton1() { + return this._button1; + } + + __getButton2() { + return this._button2; + } +} diff --git a/tests/mocks/minecraft-server.ts b/tests/mocks/minecraft-server.ts new file mode 100644 index 0000000..de0b6d1 --- /dev/null +++ b/tests/mocks/minecraft-server.ts @@ -0,0 +1,1758 @@ +import { vi } from 'vitest'; + +/** + * @minecraft/server のモック + * KeystoneCore のテスト用モック実装 + */ + +// ============================ +// 型定義・列挙型 +// ============================ + +// Vector3 モック(@minecraft/server の Vector3 型互換) +export interface Vector3 { + x: number; + y: number; + z: number; +} + +// Direction 列挙型 +export enum Direction { + Down = 'Down', + Up = 'Up', + North = 'North', + South = 'South', + West = 'West', + East = 'East', +} + +// GameMode 列挙型 +export enum GameMode { + survival = 'survival', + creative = 'creative', + adventure = 'adventure', + spectator = 'spectator', +} + +// EntityDamageCause 列挙型 +export enum EntityDamageCause { + none = 'none', + anvil = 'anvil', + blockExplosion = 'blockExplosion', + charging = 'charging', + contact = 'contact', + drowning = 'drowning', + entityAttack = 'entityAttack', + entityExplosion = 'entityExplosion', + fall = 'fall', + fallingBlock = 'fallingBlock', + fire = 'fire', + fireTick = 'fireTick', + fireworks = 'fireworks', + flyIntoWall = 'flyIntoWall', + freezing = 'freezing', + lava = 'lava', + lightning = 'lightning', + magic = 'magic', + magma = 'magma', + override = 'override', + piston = 'piston', + projectile = 'projectile', + selfDestruct = 'selfDestruct', + stalactite = 'stalactite', + stalagmite = 'stalagmite', + starve = 'starve', + suffocation = 'suffocation', + suicide = 'suicide', + temperature = 'temperature', + thorns = 'thorns', + void = 'void', + wither = 'wither', +} + +// EquipmentSlot 列挙型 +export enum EquipmentSlot { + Head = 'Head', + Chest = 'Chest', + Legs = 'Legs', + Feet = 'Feet', + Mainhand = 'Mainhand', + Offhand = 'Offhand', +} + +// ItemLockMode 列挙型 +export enum ItemLockMode { + none = 'none', + inventory = 'inventory', + slot = 'slot', +} + +// EntityDamageSource インターフェース +export interface EntityDamageSource { + cause: EntityDamageCause; + damagingEntity?: Entity; + damagingProjectile?: Entity; +} + +// ScoreboardObjectiveDisplayOptions インターフェース +export interface ScoreboardObjectiveDisplayOptions { + objective: ScoreboardObjective; + sortOrder?: ObjectiveSortOrder; +} + +// ObjectiveSortOrder 列挙型 +export enum ObjectiveSortOrder { + ascending = 0, + descending = 1, +} + +// DisplaySlotId 列挙型 +export enum DisplaySlotId { + BelowName = 'BelowName', + List = 'List', + Sidebar = 'Sidebar', +} + +// ScriptEventMessageAfterEvent インターフェース +export interface ScriptEventMessageAfterEvent { + id: string; + message: string; + sourceBlock?: Block; + sourceEntity?: Entity; + sourceType: ScriptEventSource; +} + +// ScriptEventSource 列挙型 +export enum ScriptEventSource { + Block = 'Block', + Entity = 'Entity', + NPCDialogue = 'NPCDialogue', + Server = 'Server', +} + +// RawMessage インターフェース +export interface RawMessage { + rawtext?: RawMessage[]; + score?: RawMessageScore; + text?: string; + translate?: string; + with?: string[] | RawMessage; +} + +export interface RawMessageScore { + name?: string; + objective?: string; +} + +// RawText インターフェース(RawMessage のエイリアス) +export type RawText = RawMessage; + +// CommandResult インターフェース +export interface CommandResult { + successCount: number; +} + +// ============================ +// Component クラス +// ============================ + +export class Component { + readonly typeId: string; + + constructor(typeId: string) { + this.typeId = typeId; + } + + isValid(): boolean { + return true; + } +} + +// EntityComponent +export class EntityComponent extends Component { + readonly entity?: Entity; + + constructor(typeId: string, entity?: Entity) { + super(typeId); + this.entity = entity; + } +} + +// EntityHealthComponent +export class EntityHealthComponent extends EntityComponent { + private _currentValue = 20; + private _defaultValue = 20; + private _effectiveMax = 20; + private _effectiveMin = 0; + + constructor(entity?: Entity) { + super('minecraft:health', entity); + } + + get currentValue(): number { + return this._currentValue; + } + + get defaultValue(): number { + return this._defaultValue; + } + + get effectiveMax(): number { + return this._effectiveMax; + } + + get effectiveMin(): number { + return this._effectiveMin; + } + + setCurrentValue = vi.fn((value: number): boolean => { + this._currentValue = Math.max(this._effectiveMin, Math.min(value, this._effectiveMax)); + return true; + }); + + resetToDefaultValue = vi.fn((): void => { + this._currentValue = this._defaultValue; + }); + + resetToMaxValue = vi.fn((): void => { + this._currentValue = this._effectiveMax; + }); + + resetToMinValue = vi.fn((): void => { + this._currentValue = this._effectiveMin; + }); + + // テスト用ヘルパー + __setMaxValue(value: number): void { + this._effectiveMax = value; + } +} + +// EntityInventoryComponent +export class EntityInventoryComponent extends EntityComponent { + readonly container: Container; + readonly containerType = 'inventory'; + readonly inventorySize = 36; + + constructor(entity?: Entity) { + super('minecraft:inventory', entity); + this.container = new Container(this.inventorySize); + } +} + +// EntityEquippableComponent +export class EntityEquippableComponent extends EntityComponent { + private equipment = new Map(); + + constructor(entity?: Entity) { + super('minecraft:equippable', entity); + } + + getEquipment = vi.fn((slot: EquipmentSlot): ItemStack | undefined => { + return this.equipment.get(slot); + }); + + setEquipment = vi.fn((slot: EquipmentSlot, itemStack?: ItemStack): boolean => { + this.equipment.set(slot, itemStack); + return true; + }); + + getEquipmentSlot = vi.fn((slot: EquipmentSlot): ContainerSlot => { + return new ContainerSlot(); + }); +} + +// ============================ +// Container クラス +// ============================ + +export class Container { + private items: (ItemStack | undefined)[]; + readonly size: number; + + constructor(size = 27) { + this.size = size; + this.items = new Array(size).fill(undefined); + } + + getItem = vi.fn((slot: number): ItemStack | undefined => { + if (slot < 0 || slot >= this.size) return undefined; + return this.items[slot]; + }); + + setItem = vi.fn((slot: number, itemStack?: ItemStack): void => { + if (slot >= 0 && slot < this.size) { + this.items[slot] = itemStack; + } + }); + + addItem = vi.fn((itemStack: ItemStack): ItemStack | undefined => { + for (let i = 0; i < this.size; i++) { + if (!this.items[i]) { + this.items[i] = itemStack; + return undefined; + } + } + return itemStack; + }); + + transferItem = vi.fn((slot: number, toContainer: Container): ItemStack | undefined => { + const item = this.items[slot]; + if (item) { + this.items[slot] = undefined; + return toContainer.addItem(item); + } + return undefined; + }); + + swapItems = vi.fn((slot: number, otherSlot: number, otherContainer: Container): void => { + const temp = this.items[slot]; + this.items[slot] = otherContainer.getItem(otherSlot); + otherContainer.setItem(otherSlot, temp); + }); + + moveItem = vi.fn((fromSlot: number, toSlot: number, toContainer: Container): void => { + const item = this.items[fromSlot]; + this.items[fromSlot] = undefined; + toContainer.setItem(toSlot, item); + }); + + clearAll = vi.fn((): void => { + this.items.fill(undefined); + }); + + get emptySlotsCount(): number { + return this.items.filter((item) => !item).length; + } + + isValid(): boolean { + return true; + } + + // テスト用ヘルパー + __getItems(): (ItemStack | undefined)[] { + return [...this.items]; + } + + __setItems(items: (ItemStack | undefined)[]): void { + this.items = items.slice(0, this.size); + } +} + +// ContainerSlot クラス +export class ContainerSlot { + private _item?: ItemStack; + + getItem = vi.fn((): ItemStack | undefined => this._item); + + setItem = vi.fn((itemStack?: ItemStack): void => { + this._item = itemStack; + }); + + get hasItem(): boolean { + return this._item !== undefined; + } + + get amount(): number { + return this._item?.amount ?? 0; + } + + get typeId(): string | undefined { + return this._item?.typeId; + } + + isValid(): boolean { + return true; + } +} + +// ============================ +// ItemStack クラス +// ============================ + +export class ItemStack { + typeId: string; + amount: number; + nameTag?: string; + private lore: string[] = []; + private _lockMode: ItemLockMode = ItemLockMode.none; + private _keepOnDeath = false; + private components = new Map(); + + constructor(itemType: string, amount = 1) { + this.typeId = itemType; + this.amount = Math.max(1, Math.min(amount, 64)); + } + + get maxAmount(): number { + return 64; + } + + get isStackable(): boolean { + return this.maxAmount > 1; + } + + get lockMode(): ItemLockMode { + return this._lockMode; + } + + set lockMode(mode: ItemLockMode) { + this._lockMode = mode; + } + + get keepOnDeath(): boolean { + return this._keepOnDeath; + } + + set keepOnDeath(value: boolean) { + this._keepOnDeath = value; + } + + getLore = vi.fn((): string[] => [...this.lore]); + + setLore = vi.fn((loreList?: string[]): void => { + this.lore = loreList ? [...loreList] : []; + }); + + clone = vi.fn((): ItemStack => { + const cloned = new ItemStack(this.typeId, this.amount); + cloned.nameTag = this.nameTag; + cloned.setLore(this.lore); + cloned.lockMode = this._lockMode; + cloned.keepOnDeath = this._keepOnDeath; + return cloned; + }); + + getComponent = vi.fn((componentId: string): any => { + return this.components.get(componentId); + }); + + hasComponent = vi.fn((componentId: string): boolean => { + return this.components.has(componentId); + }); + + getComponents = vi.fn((): any[] => { + return Array.from(this.components.values()); + }); + + setCanDestroy = vi.fn((blockIdentifiers?: string[]): void => {}); + + setCanPlaceOn = vi.fn((blockIdentifiers?: string[]): void => {}); + + isStackableWith = vi.fn((itemStack: ItemStack): boolean => { + return this.typeId === itemStack.typeId && this.isStackable; + }); + + matches = vi.fn((itemType: string, properties?: Record): boolean => { + return this.typeId === itemType; + }); + + // テスト用ヘルパー + __setComponent(componentId: string, component: any): void { + this.components.set(componentId, component); + } +} + +// ============================ +// Block クラス +// ============================ + +export class BlockPermutation { + private _typeId: string; + private states: Record; + + constructor(typeId: string, states: Record = {}) { + this._typeId = typeId; + this.states = states; + } + + get type(): { id: string } { + return { id: this._typeId }; + } + + getState = vi.fn((stateName: string): boolean | number | string | undefined => { + return this.states[stateName]; + }); + + getAllStates = vi.fn((): Record => { + return { ...this.states }; + }); + + withState = vi.fn((name: string, value: boolean | number | string): BlockPermutation => { + const newStates = { ...this.states, [name]: value }; + return new BlockPermutation(this._typeId, newStates); + }); + + matches = vi.fn((blockType: string, states?: Record): boolean => { + if (this._typeId !== blockType) return false; + if (states) { + for (const [key, value] of Object.entries(states)) { + if (this.states[key] !== value) return false; + } + } + return true; + }); + + static resolve = vi.fn((blockType: string, states?: Record): BlockPermutation => { + return new BlockPermutation(blockType, states); + }); +} + +export class Block { + readonly location: Vector3; + readonly x: number; + readonly y: number; + readonly z: number; + readonly dimension: Dimension; + private _permutation: BlockPermutation; + private _typeId: string; + + constructor( + dimension: Dimension, + location: Vector3, + typeId = 'minecraft:air', + permutation?: BlockPermutation + ) { + this.dimension = dimension; + this.location = { ...location }; + this.x = location.x; + this.y = location.y; + this.z = location.z; + this._typeId = typeId; + this._permutation = permutation ?? new BlockPermutation(typeId); + } + + get typeId(): string { + return this._typeId; + } + + get permutation(): BlockPermutation { + return this._permutation; + } + + get isAir(): boolean { + return this._typeId === 'minecraft:air'; + } + + get isLiquid(): boolean { + return this._typeId === 'minecraft:water' || this._typeId === 'minecraft:lava'; + } + + get isSolid(): boolean { + return !this.isAir && !this.isLiquid; + } + + get isValid(): boolean { + return true; + } + + setType = vi.fn((blockType: string): void => { + this._typeId = blockType; + this._permutation = new BlockPermutation(blockType); + }); + + setPermutation = vi.fn((permutation: BlockPermutation): void => { + this._permutation = permutation; + this._typeId = permutation.type.id; + }); + + getComponent = vi.fn((componentId: string): any => { + return undefined; + }); + + hasComponent = vi.fn((componentId: string): boolean => { + return false; + }); + + getItemStack = vi.fn((amount = 1, withData = false): ItemStack | undefined => { + if (this.isAir) return undefined; + return new ItemStack(this._typeId, amount); + }); + + above = vi.fn((steps = 1): Block | undefined => { + return this.dimension.getBlock({ x: this.x, y: this.y + steps, z: this.z }); + }); + + below = vi.fn((steps = 1): Block | undefined => { + return this.dimension.getBlock({ x: this.x, y: this.y - steps, z: this.z }); + }); + + north = vi.fn((steps = 1): Block | undefined => { + return this.dimension.getBlock({ x: this.x, y: this.y, z: this.z - steps }); + }); + + south = vi.fn((steps = 1): Block | undefined => { + return this.dimension.getBlock({ x: this.x, y: this.y, z: this.z + steps }); + }); + + east = vi.fn((steps = 1): Block | undefined => { + return this.dimension.getBlock({ x: this.x + steps, y: this.y, z: this.z }); + }); + + west = vi.fn((steps = 1): Block | undefined => { + return this.dimension.getBlock({ x: this.x - steps, y: this.y, z: this.z }); + }); + + offset = vi.fn((offset: Vector3): Block | undefined => { + return this.dimension.getBlock({ + x: this.x + offset.x, + y: this.y + offset.y, + z: this.z + offset.z, + }); + }); +} + +// ============================ +// Entity クラス +// ============================ + +export class Entity { + readonly id: string; + readonly typeId: string; + nameTag: string; + private _location: Vector3; + private _rotation: { x: number; y: number }; + private _velocity: Vector3; + private _dimension: Dimension; + private components = new Map(); + private _isValid = true; + private tags = new Set(); + private dynamicProperties = new Map(); + + constructor( + typeId = 'minecraft:entity', + id = `entity-${Math.random().toString(36).substr(2, 9)}`, + dimension?: Dimension + ) { + this.typeId = typeId; + this.id = id; + this.nameTag = ''; + this._location = { x: 0, y: 0, z: 0 }; + this._rotation = { x: 0, y: 0 }; + this._velocity = { x: 0, y: 0, z: 0 }; + this._dimension = dimension ?? world.getDimension('overworld'); + + // デフォルトコンポーネントを追加 + this.components.set('minecraft:health', new EntityHealthComponent(this)); + this.components.set('minecraft:inventory', new EntityInventoryComponent(this)); + this.components.set('minecraft:equippable', new EntityEquippableComponent(this)); + } + + get location(): Vector3 { + return { ...this._location }; + } + + get rotation(): { x: number; y: number } { + return { ...this._rotation }; + } + + get velocity(): Vector3 { + return { ...this._velocity }; + } + + get dimension(): Dimension { + return this._dimension; + } + + isValid(): boolean { + return this._isValid; + } + + kill = vi.fn((): boolean => { + this._isValid = false; + return true; + }); + + remove = vi.fn((): void => { + this._isValid = false; + }); + + teleport = vi.fn((location: Vector3, options?: { dimension?: Dimension; rotation?: { x: number; y: number } }): void => { + this._location = { ...location }; + if (options?.dimension) { + this._dimension = options.dimension; + } + if (options?.rotation) { + this._rotation = { ...options.rotation }; + } + }); + + getHeadLocation = vi.fn((): Vector3 => { + return { x: this._location.x, y: this._location.y + 1.62, z: this._location.z }; + }); + + getViewDirection = vi.fn((): Vector3 => { + const pitch = (this._rotation.x * Math.PI) / 180; + const yaw = (this._rotation.y * Math.PI) / 180; + return { + x: -Math.sin(yaw) * Math.cos(pitch), + y: -Math.sin(pitch), + z: Math.cos(yaw) * Math.cos(pitch), + }; + }); + + applyImpulse = vi.fn((vector: Vector3): void => { + this._velocity = { + x: this._velocity.x + vector.x, + y: this._velocity.y + vector.y, + z: this._velocity.z + vector.z, + }; + }); + + applyKnockback = vi.fn((directionX: number, directionZ: number, horizontalStrength: number, verticalStrength: number): void => { + this._velocity = { + x: directionX * horizontalStrength, + y: verticalStrength, + z: directionZ * horizontalStrength, + }; + }); + + clearVelocity = vi.fn((): void => { + this._velocity = { x: 0, y: 0, z: 0 }; + }); + + runCommand = vi.fn((command: string): CommandResult => { + return { successCount: 1 }; + }); + + runCommandAsync = vi.fn(async (command: string): Promise => { + return { successCount: 1 }; + }); + + getComponent = vi.fn((componentId: string): EntityComponent | undefined => { + return this.components.get(componentId); + }); + + hasComponent = vi.fn((componentId: string): boolean => { + return this.components.has(componentId); + }); + + getComponents = vi.fn((): EntityComponent[] => { + return Array.from(this.components.values()); + }); + + addTag = vi.fn((tag: string): boolean => { + if (this.tags.has(tag)) return false; + this.tags.add(tag); + return true; + }); + + removeTag = vi.fn((tag: string): boolean => { + return this.tags.delete(tag); + }); + + hasTag = vi.fn((tag: string): boolean => { + return this.tags.has(tag); + }); + + getTags = vi.fn((): string[] => { + return Array.from(this.tags); + }); + + setDynamicProperty = vi.fn((identifier: string, value?: boolean | number | string | Vector3): void => { + this.dynamicProperties.set(identifier, value); + }); + + getDynamicProperty = vi.fn((identifier: string): boolean | number | string | Vector3 | undefined => { + return this.dynamicProperties.get(identifier); + }); + + getDynamicPropertyIds = vi.fn((): string[] => { + return Array.from(this.dynamicProperties.keys()); + }); + + getDynamicPropertyTotalByteCount = vi.fn((): number => { + return 0; + }); + + clearDynamicProperties = vi.fn((): void => { + this.dynamicProperties.clear(); + }); + + getBlockFromViewDirection = vi.fn((options?: { includeLiquidBlocks?: boolean; includePassableBlocks?: boolean; maxDistance?: number }): { block: Block; face: Direction; faceLocation: Vector3 } | undefined => { + return undefined; + }); + + getEntitiesFromViewDirection = vi.fn((options?: { maxDistance?: number }): { entity: Entity; distance: number }[] => { + return []; + }); + + // テスト用ヘルパー + __setLocation(location: Vector3): void { + this._location = { ...location }; + } + + __setRotation(rotation: { x: number; y: number }): void { + this._rotation = { ...rotation }; + } + + __setVelocity(velocity: Vector3): void { + this._velocity = { ...velocity }; + } + + __setDimension(dimension: Dimension): void { + this._dimension = dimension; + } + + __setValid(valid: boolean): void { + this._isValid = valid; + } + + __addComponent(componentId: string, component: EntityComponent): void { + this.components.set(componentId, component); + } +} + +// ============================ +// Player クラス +// ============================ + +export class Player extends Entity { + readonly name: string; + private _gameMode: GameMode = GameMode.survival; + private _level = 0; + private _xpEarnedAtCurrentLevel = 0; + private _totalXpNeededForNextLevel = 10; + private _selectedSlot = 0; + private _isSneaking = false; + private _isSprinting = false; + private _isFlying = false; + private _isJumping = false; + private _isOnGround = true; + private _isInWater = false; + private screenDisplay: ScreenDisplay; + + constructor(name = 'TestPlayer', id = 'test-player-id', dimension?: Dimension) { + super('minecraft:player', id, dimension); + this.name = name; + this.nameTag = name; + this.screenDisplay = new ScreenDisplay(this); + } + + get gameMode(): GameMode { + return this._gameMode; + } + + get level(): number { + return this._level; + } + + get xpEarnedAtCurrentLevel(): number { + return this._xpEarnedAtCurrentLevel; + } + + get totalXpNeededForNextLevel(): number { + return this._totalXpNeededForNextLevel; + } + + get selectedSlot(): number { + return this._selectedSlot; + } + + set selectedSlot(slot: number) { + this._selectedSlot = Math.max(0, Math.min(slot, 8)); + } + + get isSneaking(): boolean { + return this._isSneaking; + } + + get isSprinting(): boolean { + return this._isSprinting; + } + + get isFlying(): boolean { + return this._isFlying; + } + + get isJumping(): boolean { + return this._isJumping; + } + + get isOnGround(): boolean { + return this._isOnGround; + } + + get isInWater(): boolean { + return this._isInWater; + } + + get isOp(): boolean { + return false; + } + + sendMessage = vi.fn((message: string | RawMessage | (string | RawMessage)[]): void => { + // メッセージ送信のモック + }); + + playSound = vi.fn((soundId: string, options?: { location?: Vector3; pitch?: number; volume?: number }): void => { + // サウンド再生のモック + }); + + addExperience = vi.fn((amount: number): number => { + this._xpEarnedAtCurrentLevel += amount; + while (this._xpEarnedAtCurrentLevel >= this._totalXpNeededForNextLevel) { + this._xpEarnedAtCurrentLevel -= this._totalXpNeededForNextLevel; + this._level++; + this._totalXpNeededForNextLevel = (this._level + 1) * 10; + } + return this._xpEarnedAtCurrentLevel; + }); + + addLevels = vi.fn((amount: number): number => { + this._level = Math.max(0, this._level + amount); + return this._level; + }); + + resetLevel = vi.fn((): void => { + this._level = 0; + this._xpEarnedAtCurrentLevel = 0; + this._totalXpNeededForNextLevel = 10; + }); + + setGameMode = vi.fn((gameMode: GameMode): void => { + this._gameMode = gameMode; + }); + + getItemCooldown = vi.fn((cooldownCategory: string): number => { + return 0; + }); + + startItemCooldown = vi.fn((cooldownCategory: string, tickDuration: number): void => {}); + + getSpawnPoint = vi.fn((): { dimension: Dimension; x: number; y: number; z: number } | undefined => { + return undefined; + }); + + setSpawnPoint = vi.fn((spawnPoint?: { dimension: Dimension; x: number; y: number; z: number }): void => {}); + + clearSpawnPoint = vi.fn((): void => {}); + + onScreenDisplay = { + ...this.screenDisplay, + }; + + // テスト用ヘルパー + __setSneaking(value: boolean): void { + this._isSneaking = value; + } + + __setSprinting(value: boolean): void { + this._isSprinting = value; + } + + __setFlying(value: boolean): void { + this._isFlying = value; + } + + __setJumping(value: boolean): void { + this._isJumping = value; + } + + __setOnGround(value: boolean): void { + this._isOnGround = value; + } + + __setInWater(value: boolean): void { + this._isInWater = value; + } + + __setGameMode(mode: GameMode): void { + this._gameMode = mode; + } + + __setLevel(level: number): void { + this._level = level; + } +} + +// ============================ +// ScreenDisplay クラス +// ============================ + +export class ScreenDisplay { + private player: Player; + + constructor(player: Player) { + this.player = player; + } + + setTitle = vi.fn((title: string | RawMessage, options?: { fadeInDuration?: number; stayDuration?: number; fadeOutDuration?: number; subtitle?: string | RawMessage }): void => {}); + + updateSubtitle = vi.fn((subtitle: string | RawMessage): void => {}); + + setActionBar = vi.fn((text: string | RawMessage): void => {}); + + isValid = vi.fn((): boolean => true); +} + +// ============================ +// Dimension クラス +// ============================ + +export class Dimension { + readonly id: string; + private blocks = new Map(); + private entities: Entity[] = []; + + constructor(id = 'minecraft:overworld') { + this.id = id; + } + + private getBlockKey(location: Vector3): string { + return `${Math.floor(location.x)},${Math.floor(location.y)},${Math.floor(location.z)}`; + } + + getBlock = vi.fn((location: Vector3): Block | undefined => { + const key = this.getBlockKey(location); + if (!this.blocks.has(key)) { + this.blocks.set(key, new Block(this, { x: Math.floor(location.x), y: Math.floor(location.y), z: Math.floor(location.z) })); + } + return this.blocks.get(key); + }); + + setBlockType = vi.fn((location: Vector3, blockType: string): void => { + const block = this.getBlock(location); + if (block) { + block.setType(blockType); + } + }); + + setBlockPermutation = vi.fn((location: Vector3, permutation: BlockPermutation): void => { + const block = this.getBlock(location); + if (block) { + block.setPermutation(permutation); + } + }); + + fillBlocks = vi.fn((begin: Vector3, end: Vector3, block: string | BlockPermutation): void => { + const minX = Math.min(begin.x, end.x); + const maxX = Math.max(begin.x, end.x); + const minY = Math.min(begin.y, end.y); + const maxY = Math.max(begin.y, end.y); + const minZ = Math.min(begin.z, end.z); + const maxZ = Math.max(begin.z, end.z); + + for (let x = minX; x <= maxX; x++) { + for (let y = minY; y <= maxY; y++) { + for (let z = minZ; z <= maxZ; z++) { + if (typeof block === 'string') { + this.setBlockType({ x, y, z }, block); + } else { + this.setBlockPermutation({ x, y, z }, block); + } + } + } + } + }); + + spawnEntity = vi.fn((identifier: string, location: Vector3): Entity => { + const entity = new Entity(identifier, undefined, this); + entity.__setLocation(location); + this.entities.push(entity); + return entity; + }); + + spawnItem = vi.fn((itemStack: ItemStack, location: Vector3): Entity => { + const entity = new Entity('minecraft:item', undefined, this); + entity.__setLocation(location); + this.entities.push(entity); + return entity; + }); + + getEntities = vi.fn((options?: { type?: string; location?: Vector3; maxDistance?: number; closest?: number; tags?: string[]; name?: string }): Entity[] => { + let filtered = [...this.entities]; + if (options?.type) { + filtered = filtered.filter((e) => e.typeId === options.type); + } + if (options?.name) { + filtered = filtered.filter((e) => e.nameTag === options.name); + } + if (options?.tags) { + filtered = filtered.filter((e) => options.tags!.every((tag) => e.hasTag(tag))); + } + if (options?.location && options.maxDistance !== undefined) { + filtered = filtered.filter((e) => { + const dx = e.location.x - options.location!.x; + const dy = e.location.y - options.location!.y; + const dz = e.location.z - options.location!.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz) <= options.maxDistance!; + }); + } + if (options?.closest !== undefined) { + filtered = filtered.slice(0, options.closest); + } + return filtered; + }); + + getPlayers = vi.fn((options?: { name?: string; tags?: string[]; location?: Vector3; maxDistance?: number }): Player[] => { + let players = this.entities.filter((e) => e instanceof Player) as Player[]; + if (options?.name) { + players = players.filter((p) => p.name === options.name); + } + if (options?.tags) { + players = players.filter((p) => options.tags!.every((tag) => p.hasTag(tag))); + } + return players; + }); + + runCommand = vi.fn((command: string): CommandResult => { + return { successCount: 1 }; + }); + + runCommandAsync = vi.fn(async (command: string): Promise => { + return { successCount: 1 }; + }); + + // テスト用ヘルパー + __setBlock(location: Vector3, typeId: string): Block { + const block = new Block(this, { x: Math.floor(location.x), y: Math.floor(location.y), z: Math.floor(location.z) }, typeId); + this.blocks.set(this.getBlockKey(location), block); + return block; + } + + __addEntity(entity: Entity): void { + this.entities.push(entity); + } + + __clearEntities(): void { + this.entities = []; + } + + __clearBlocks(): void { + this.blocks.clear(); + } +} + +// ============================ +// Scoreboard クラス +// ============================ + +export class ScoreboardIdentity { + readonly displayName: string; + readonly id: number; + readonly type: 'Player' | 'Entity' | 'FakePlayer'; + private entity?: Entity; + + constructor(displayName: string, type: 'Player' | 'Entity' | 'FakePlayer' = 'FakePlayer', id?: number, entity?: Entity) { + this.displayName = displayName; + this.type = type; + this.id = id ?? Math.floor(Math.random() * 1000000); + this.entity = entity; + } + + getEntity = vi.fn((): Entity | undefined => { + return this.entity; + }); + + isValid(): boolean { + return true; + } +} + +export class ScoreboardObjective { + readonly id: string; + readonly displayName: string; + private scores = new Map(); + private participants = new Map(); + + constructor(id: string, displayName = id) { + this.id = id; + this.displayName = displayName; + } + + getScore = vi.fn((participant: Entity | ScoreboardIdentity | string): number | undefined => { + const key = this.getParticipantKey(participant); + return this.scores.get(key); + }); + + setScore = vi.fn((participant: Entity | ScoreboardIdentity | string, score: number): void => { + const key = this.getParticipantKey(participant); + this.scores.set(key, score); + if (!this.participants.has(key)) { + this.participants.set( + key, + new ScoreboardIdentity( + key, + participant instanceof Player ? 'Player' : participant instanceof Entity ? 'Entity' : 'FakePlayer', + undefined, + participant instanceof Entity ? participant : undefined + ) + ); + } + }); + + addScore = vi.fn((participant: Entity | ScoreboardIdentity | string, scoreToAdd: number): number => { + const current = this.getScore(participant) ?? 0; + const newScore = current + scoreToAdd; + this.setScore(participant, newScore); + return newScore; + }); + + removeParticipant = vi.fn((participant: Entity | ScoreboardIdentity | string): boolean => { + const key = this.getParticipantKey(participant); + const deleted = this.scores.delete(key); + this.participants.delete(key); + return deleted; + }); + + getParticipants = vi.fn((): ScoreboardIdentity[] => { + return Array.from(this.participants.values()); + }); + + getScores = vi.fn((): { participant: ScoreboardIdentity; score: number }[] => { + const result: { participant: ScoreboardIdentity; score: number }[] = []; + this.participants.forEach((identity, key) => { + const score = this.scores.get(key); + if (score !== undefined) { + result.push({ participant: identity, score }); + } + }); + return result; + }); + + hasParticipant = vi.fn((participant: Entity | ScoreboardIdentity | string): boolean => { + const key = this.getParticipantKey(participant); + return this.scores.has(key); + }); + + isValid(): boolean { + return true; + } + + private getParticipantKey(participant: Entity | ScoreboardIdentity | string): string { + if (typeof participant === 'string') return participant; + if (participant instanceof ScoreboardIdentity) return participant.displayName; + return participant.id; + } + + // テスト用ヘルパー + __clear(): void { + this.scores.clear(); + this.participants.clear(); + } +} + +export class Scoreboard { + private objectives = new Map(); + + addObjective = vi.fn((objectiveId: string, displayName?: string): ScoreboardObjective => { + const objective = new ScoreboardObjective(objectiveId, displayName); + this.objectives.set(objectiveId, objective); + return objective; + }); + + removeObjective = vi.fn((objectiveId: string | ScoreboardObjective): boolean => { + const id = typeof objectiveId === 'string' ? objectiveId : objectiveId.id; + return this.objectives.delete(id); + }); + + getObjective = vi.fn((objectiveId: string): ScoreboardObjective | undefined => { + return this.objectives.get(objectiveId); + }); + + getObjectives = vi.fn((): ScoreboardObjective[] => { + return Array.from(this.objectives.values()); + }); + + getObjectiveAtDisplaySlot = vi.fn((displaySlotId: DisplaySlotId): ScoreboardObjectiveDisplayOptions | undefined => { + return undefined; + }); + + setObjectiveAtDisplaySlot = vi.fn((displaySlotId: DisplaySlotId, objectiveDisplaySetting?: ScoreboardObjectiveDisplayOptions): ScoreboardObjective | undefined => { + return objectiveDisplaySetting?.objective; + }); + + clearObjectiveAtDisplaySlot = vi.fn((displaySlotId: DisplaySlotId): ScoreboardObjective | undefined => { + return undefined; + }); + + getParticipants = vi.fn((): ScoreboardIdentity[] => { + const allParticipants = new Map(); + this.objectives.forEach((objective) => { + objective.getParticipants().forEach((p) => { + allParticipants.set(p.displayName, p); + }); + }); + return Array.from(allParticipants.values()); + }); + + // テスト用ヘルパー + __clear(): void { + this.objectives.clear(); + } +} + +// ============================ +// イベントシグナル +// ============================ + +class EventSignal { + private handlers: ((event: T) => void)[] = []; + + subscribe = vi.fn((handler: (event: T) => void) => { + this.handlers.push(handler); + return handler; + }); + + unsubscribe = vi.fn((handler: (event: T) => void) => { + const index = this.handlers.indexOf(handler); + if (index > -1) { + this.handlers.splice(index, 1); + } + }); + + // テスト用: イベントをディスパッチ + __dispatch(event: T) { + this.handlers.forEach((handler) => { + try { + handler(event); + } catch (e) { + // エラーは無視 + } + }); + } + + // テスト用: ハンドラをクリア + __clear() { + this.handlers = []; + this.subscribe.mockClear(); + this.unsubscribe.mockClear(); + } + + // テスト用: ハンドラの数を取得 + __getHandlerCount(): number { + return this.handlers.length; + } +} + +// ============================ +// イベントインターフェース +// ============================ + +export interface PlayerJoinAfterEvent { + player: Player; + playerId: string; +} + +export interface PlayerLeaveAfterEvent { + playerName: string; + playerId: string; +} + +export interface PlayerSpawnAfterEvent { + player: Player; + initialSpawn: boolean; +} + +export interface ItemUseAfterEvent { + itemStack: ItemStack; + source: Player; +} + +export interface ItemUseBeforeEvent { + itemStack: ItemStack; + source: Player; + cancel: boolean; +} + +export interface EntityHurtAfterEvent { + damage: number; + damageSource: EntityDamageSource; + hurtEntity: Entity; +} + +export interface ChatSendAfterEvent { + message: string; + sender: Player; +} + +export interface ChatSendBeforeEvent { + message: string; + sender: Player; + cancel: boolean; +} + +export interface PlayerBreakBlockBeforeEvent { + block: Block; + dimension: Dimension; + itemStack?: ItemStack; + player: Player; + cancel: boolean; +} + +export interface PlayerPlaceBlockBeforeEvent { + block: Block; + dimension: Dimension; + face: Direction; + faceLocation: Vector3; + player: Player; + cancel: boolean; +} + +export interface BlockBreakAfterEvent { + block: Block; + brokenBlockPermutation: BlockPermutation; + dimension: Dimension; + player?: Player; +} + +export interface BlockPlaceAfterEvent { + block: Block; + dimension: Dimension; + player?: Player; +} + +export interface EntityDieAfterEvent { + damageSource: EntityDamageSource; + deadEntity: Entity; +} + +export interface EntitySpawnAfterEvent { + cause: 'Spawned' | 'Born' | 'Event' | 'Loaded' | 'Summoned' | 'Unknown'; + entity: Entity; +} + +export interface PlayerInteractWithBlockAfterEvent { + block: Block; + blockFace: Direction; + faceLocation: Vector3; + itemStack?: ItemStack; + player: Player; +} + +export interface PlayerInteractWithEntityAfterEvent { + itemStack?: ItemStack; + player: Player; + target: Entity; +} + +export interface ExplosionAfterEvent { + dimension: Dimension; + source?: Entity; + impactedBlocks: Block[]; +} + +export interface EffectAddAfterEvent { + effect: { typeId: string; duration: number; amplifier: number }; + entity: Entity; +} + +export interface WeatherChangeAfterEvent { + dimension: string; + lightning: boolean; + raining: boolean; +} + +// ============================ +// World モック +// ============================ + +class WorldMock { + private dimensions = new Map(); + private _scoreboard = new Scoreboard(); + private dynamicProperties = new Map(); + private players: Player[] = []; + + constructor() { + this.dimensions.set('minecraft:overworld', new Dimension('minecraft:overworld')); + this.dimensions.set('minecraft:nether', new Dimension('minecraft:nether')); + this.dimensions.set('minecraft:the_end', new Dimension('minecraft:the_end')); + } + + afterEvents = { + playerJoin: new EventSignal(), + playerLeave: new EventSignal(), + playerSpawn: new EventSignal(), + itemUse: new EventSignal(), + entityHurt: new EventSignal(), + chatSend: new EventSignal(), + blockBreak: new EventSignal(), + blockPlace: new EventSignal(), + entityDie: new EventSignal(), + entitySpawn: new EventSignal(), + playerInteractWithBlock: new EventSignal(), + playerInteractWithEntity: new EventSignal(), + explosion: new EventSignal(), + effectAdd: new EventSignal(), + weatherChange: new EventSignal(), + scriptEventReceive: new EventSignal(), + }; + + beforeEvents = { + playerBreakBlock: new EventSignal(), + playerPlaceBlock: new EventSignal(), + itemUse: new EventSignal(), + chatSend: new EventSignal(), + }; + + get scoreboard(): Scoreboard { + return this._scoreboard; + } + + getDimension = vi.fn((dimensionId: string): Dimension => { + const dim = this.dimensions.get(dimensionId) ?? this.dimensions.get('minecraft:overworld')!; + return dim; + }); + + getAllPlayers = vi.fn((): Player[] => { + return [...this.players]; + }); + + getPlayers = vi.fn((options?: { name?: string; tags?: string[] }): Player[] => { + let filtered = [...this.players]; + if (options?.name) { + filtered = filtered.filter((p) => p.name === options.name); + } + if (options?.tags) { + filtered = filtered.filter((p) => options.tags!.every((tag) => p.hasTag(tag))); + } + return filtered; + }); + + getEntity = vi.fn((id: string): Entity | undefined => { + for (const dim of this.dimensions.values()) { + const entities = dim.getEntities(); + const found = entities.find((e) => e.id === id); + if (found) return found; + } + return this.players.find((p) => p.id === id); + }); + + sendMessage = vi.fn((message: string | RawMessage | (string | RawMessage)[]): void => { + // ワールドメッセージ送信のモック + }); + + playSound = vi.fn((soundId: string, location: Vector3, options?: { pitch?: number; volume?: number }): void => {}); + + setDynamicProperty = vi.fn((identifier: string, value?: boolean | number | string | Vector3): void => { + this.dynamicProperties.set(identifier, value); + }); + + getDynamicProperty = vi.fn((identifier: string): boolean | number | string | Vector3 | undefined => { + return this.dynamicProperties.get(identifier); + }); + + getDynamicPropertyIds = vi.fn((): string[] => { + return Array.from(this.dynamicProperties.keys()); + }); + + getDynamicPropertyTotalByteCount = vi.fn((): number => { + return 0; + }); + + clearDynamicProperties = vi.fn((): void => { + this.dynamicProperties.clear(); + }); + + getAbsoluteTime = vi.fn((): number => 0); + + getTimeOfDay = vi.fn((): number => 6000); + + setTimeOfDay = vi.fn((timeOfDay: number): void => {}); + + getDay = vi.fn((): number => 0); + + getDefaultSpawnLocation = vi.fn((): Vector3 => ({ x: 0, y: 64, z: 0 })); + + setDefaultSpawnLocation = vi.fn((spawnLocation: Vector3): void => {}); + + // テスト用ヘルパー + __addPlayer(player: Player): void { + this.players.push(player); + this.getDimension('minecraft:overworld').__addEntity(player); + } + + __removePlayer(player: Player): void { + const index = this.players.indexOf(player); + if (index > -1) { + this.players.splice(index, 1); + } + } + + __clearPlayers(): void { + this.players = []; + } + + __clearAllEvents() { + Object.values(this.afterEvents).forEach((signal) => signal.__clear()); + Object.values(this.beforeEvents).forEach((signal) => signal.__clear()); + } + + __clearAll() { + this.__clearAllEvents(); + this.__clearPlayers(); + this._scoreboard.__clear(); + this.dynamicProperties.clear(); + this.dimensions.forEach((dim) => { + dim.__clearEntities(); + dim.__clearBlocks(); + }); + } +} + +// ============================ +// System モック +// ============================ + +class SystemMock { + private intervalId = 0; + private timeoutId = 0; + private intervals = new Map void; tickInterval: number; lastRun: number }>(); + private timeouts = new Map void; targetTick: number }>(); + private currentTick = 0; + + get currentTickValue(): number { + return this.currentTick; + } + + runInterval = vi.fn((callback: () => void, tickInterval?: number) => { + const id = ++this.intervalId; + this.intervals.set(id, { + callback, + tickInterval: tickInterval ?? 1, + lastRun: this.currentTick, + }); + return id; + }); + + clearRun = vi.fn((runId: number) => { + this.intervals.delete(runId); + this.timeouts.delete(runId); + }); + + runTimeout = vi.fn((callback: () => void, tickDelay?: number) => { + const id = ++this.timeoutId; + this.timeouts.set(id, { + callback, + targetTick: this.currentTick + (tickDelay ?? 1), + }); + return id; + }); + + run = vi.fn((callback: () => void) => { + const id = ++this.timeoutId; + this.timeouts.set(id, { callback, targetTick: this.currentTick }); + return id; + }); + + runJob = vi.fn((generator: Generator): number => { + const id = ++this.timeoutId; + // 即座に最初のyieldまで実行 + try { + generator.next(); + } catch (e) { + // エラーは無視 + } + return id; + }); + + clearJob = vi.fn((jobId: number): void => { + // ジョブをクリア + }); + + waitTicks = vi.fn((ticks: number): Promise => { + return new Promise((resolve) => { + this.runTimeout(() => resolve(), ticks); + }); + }); + + // テスト用ヘルパー: 1 tick進める + __tick() { + this.currentTick++; + + // Intervals を実行 + this.intervals.forEach((interval, id) => { + if (this.currentTick - interval.lastRun >= interval.tickInterval) { + interval.lastRun = this.currentTick; + try { + interval.callback(); + } catch (e) { + // エラーは無視 + } + } + }); + + // Timeouts を実行 + const toDelete: number[] = []; + this.timeouts.forEach(({ callback, targetTick }, id) => { + if (this.currentTick >= targetTick) { + toDelete.push(id); + try { + callback(); + } catch (e) { + // エラーは無視 + } + } + }); + toDelete.forEach((id) => this.timeouts.delete(id)); + } + + // テスト用ヘルパー: 指定tick数進める + __tickAll(ticks = 1) { + for (let i = 0; i < ticks; i++) { + this.__tick(); + } + } + + // テスト用ヘルパー: 現在のtickを取得 + __getCurrentTick() { + return this.currentTick; + } + + // テスト用ヘルパー: すべてのタイマーをクリア + __clearAll() { + this.intervals.clear(); + this.timeouts.clear(); + this.currentTick = 0; + this.intervalId = 0; + this.timeoutId = 0; + this.runInterval.mockClear(); + this.clearRun.mockClear(); + this.runTimeout.mockClear(); + this.run.mockClear(); + this.runJob.mockClear(); + this.clearJob.mockClear(); + } + + // テスト用ヘルパー: 登録されているインターバルの数を取得 + __getIntervalCount(): number { + return this.intervals.size; + } + + // テスト用ヘルパー: 登録されているタイムアウトの数を取得 + __getTimeoutCount(): number { + return this.timeouts.size; + } +} + +// ============================ +// MinecraftDimensionTypes +// ============================ + +export const MinecraftDimensionTypes = { + overworld: 'minecraft:overworld', + nether: 'minecraft:nether', + theEnd: 'minecraft:the_end', +} as const; + +// ============================ +// シングルトンインスタンス +// ============================ + +export const world = new WorldMock(); +export const system = new SystemMock(); + +// ============================ +// ヘルパー関数 +// ============================ + +// テスト用ヘルパー: モックをリセット +export function resetMocks() { + vi.clearAllMocks(); + system.__clearAll(); + world.__clearAll(); +} + +// エクスポート (EventSignal クラスもテスト用にエクスポート) +export { EventSignal }; diff --git a/tests/mocks/test-utils.ts b/tests/mocks/test-utils.ts new file mode 100644 index 0000000..4b4b8b9 --- /dev/null +++ b/tests/mocks/test-utils.ts @@ -0,0 +1,635 @@ +/** + * テスト用ユーティリティ + * KeystoneCore のテストで使用する共通ヘルパー関数 + */ + +import { + Player as MockPlayer, + Entity as MockEntity, + system, + world, + resetMocks, + Vector3, + ItemStack, + Block, + Dimension, + GameMode, + Direction, + EntityDamageCause, + BlockPermutation, + type PlayerJoinAfterEvent, + type PlayerLeaveAfterEvent, + type PlayerSpawnAfterEvent, + type ItemUseAfterEvent, + type EntityHurtAfterEvent, + type ChatSendAfterEvent, + type BlockBreakAfterEvent, + type BlockPlaceAfterEvent, + type EntityDieAfterEvent, + type PlayerBreakBlockBeforeEvent, + type PlayerPlaceBlockBeforeEvent, + type ChatSendBeforeEvent, + type ItemUseBeforeEvent, +} from './minecraft-server'; +import type { Player, Entity } from '@minecraft/server'; +import { vi } from 'vitest'; + +// ============================ +// プレイヤー関連 +// ============================ + +/** + * テストプレイヤーを作成 + */ +export function createTestPlayer(name = 'TestPlayer', id = 'test-player-id'): Player { + return new MockPlayer(name, id) as unknown as Player; +} + +/** + * 複数のテストプレイヤーを作成 + */ +export function createTestPlayers(count: number): Player[] { + return Array.from({ length: count }, (_, i) => new MockPlayer(`Player${i + 1}`, `player-${i + 1}`) as unknown as Player); +} + +/** + * ワールドにプレイヤーを追加 + */ +export function addPlayerToWorld(player: Player): void { + world.__addPlayer(player as unknown as MockPlayer); +} + +/** + * ワールドからプレイヤーを削除 + */ +export function removePlayerFromWorld(player: Player): void { + world.__removePlayer(player as unknown as MockPlayer); +} + +/** + * プレイヤーを特定の位置に配置 + */ +export function setPlayerPosition(player: Player, location: Vector3): void { + (player as unknown as MockPlayer).__setLocation(location); +} + +/** + * プレイヤーのゲームモードを設定 + */ +export function setPlayerGameMode(player: Player, gameMode: GameMode): void { + (player as unknown as MockPlayer).__setGameMode(gameMode); +} + +// ============================ +// エンティティ関連 +// ============================ + +/** + * テストエンティティを作成 + */ +export function createTestEntity(typeId = 'minecraft:zombie', id?: string): Entity { + return new MockEntity(typeId, id) as unknown as Entity; +} + +/** + * エンティティを特定の位置に配置 + */ +export function setEntityPosition(entity: Entity, location: Vector3): void { + (entity as unknown as MockEntity).__setLocation(location); +} + +/** + * ディメンションにエンティティを追加 + */ +export function addEntityToDimension(entity: Entity, dimension?: Dimension): void { + const dim = dimension ?? world.getDimension('minecraft:overworld'); + dim.__addEntity(entity as unknown as MockEntity); +} + +// ============================ +// アイテム関連 +// ============================ + +/** + * テストアイテムスタックを作成 + */ +export function createTestItemStack(typeId = 'minecraft:diamond', amount = 1): ItemStack { + return new ItemStack(typeId, amount); +} + +/** + * 名前付きアイテムスタックを作成 + */ +export function createNamedItemStack(typeId: string, name: string, lore?: string[]): ItemStack { + const item = new ItemStack(typeId); + item.nameTag = name; + if (lore) { + item.setLore(lore); + } + return item; +} + +// ============================ +// ブロック関連 +// ============================ + +/** + * テストブロックを作成 + */ +export function createTestBlock( + location: Vector3, + typeId = 'minecraft:stone', + dimension?: Dimension +): Block { + const dim = dimension ?? world.getDimension('minecraft:overworld'); + return dim.__setBlock(location, typeId); +} + +/** + * ブロックパーミュテーションを作成 + */ +export function createBlockPermutation( + typeId: string, + states?: Record +): BlockPermutation { + return BlockPermutation.resolve(typeId, states); +} + +// ============================ +// タイマー関連 +// ============================ + +/** + * システムタイマーをシミュレート + * 指定したtick数だけ時間を進める + */ +export function tickSystem(ticks = 1): void { + system.__tickAll(ticks); +} + +/** + * 1 tick だけ進める + */ +export function tick(): void { + system.__tick(); +} + +/** + * 現在のシステムtickを取得 + */ +export function getCurrentTick(): number { + return system.__getCurrentTick(); +} + +/** + * 指定した条件が満たされるまでtickを進める + */ +export function tickUntil(condition: () => boolean, maxTicks = 100): boolean { + for (let i = 0; i < maxTicks; i++) { + if (condition()) return true; + system.__tick(); + } + return false; +} + +/** + * 非同期でtickを進める(Promise対応のテスト用) + */ +export async function tickAsync(ticks = 1): Promise { + for (let i = 0; i < ticks; i++) { + system.__tick(); + await Promise.resolve(); + } +} + +// ============================ +// イベント関連 +// ============================ + +/** + * イベントをディスパッチ(汎用) + */ +export function dispatchEvent(eventType: 'after' | 'before', eventName: string, eventData: any = {}): void { + const events = eventType === 'after' ? world.afterEvents : world.beforeEvents; + const eventObj = (events as any)[eventName]; + + if (eventObj && typeof eventObj.__dispatch === 'function') { + eventObj.__dispatch(eventData); + } +} + +/** + * プレイヤー参加イベントをディスパッチ + */ +export function dispatchPlayerJoinEvent(player: Player): void { + const event: PlayerJoinAfterEvent = { + player, + playerId: player.id, + }; + world.afterEvents.playerJoin.__dispatch(event); +} + +/** + * プレイヤー退出イベントをディスパッチ + */ +export function dispatchPlayerLeaveEvent(player: Player): void { + const event: PlayerLeaveAfterEvent = { + playerName: player.name, + playerId: player.id, + }; + world.afterEvents.playerLeave.__dispatch(event); +} + +/** + * プレイヤースポーンイベントをディスパッチ + */ +export function dispatchPlayerSpawnEvent(player: Player, initialSpawn = false): void { + const event: PlayerSpawnAfterEvent = { + player, + initialSpawn, + }; + world.afterEvents.playerSpawn.__dispatch(event); +} + +/** + * アイテム使用イベントをディスパッチ + */ +export function dispatchItemUseEvent(player: Player, itemStack: ItemStack): void { + const event: ItemUseAfterEvent = { + itemStack, + source: player, + }; + world.afterEvents.itemUse.__dispatch(event); +} + +/** + * アイテム使用前イベントをディスパッチ(キャンセル可能) + */ +export function dispatchItemUseBeforeEvent( + player: Player, + itemStack: ItemStack +): ItemUseBeforeEvent { + const event: ItemUseBeforeEvent = { + itemStack, + source: player, + cancel: false, + }; + world.beforeEvents.itemUse.__dispatch(event); + return event; +} + +/** + * チャット送信イベントをディスパッチ + */ +export function dispatchChatSendEvent(player: Player, message: string): void { + const event: ChatSendAfterEvent = { + message, + sender: player, + }; + world.afterEvents.chatSend.__dispatch(event); +} + +/** + * チャット送信前イベントをディスパッチ(キャンセル可能) + */ +export function dispatchChatSendBeforeEvent( + player: Player, + message: string +): ChatSendBeforeEvent { + const event: ChatSendBeforeEvent = { + message, + sender: player, + cancel: false, + }; + world.beforeEvents.chatSend.__dispatch(event); + return event; +} + +/** + * エンティティダメージイベントをディスパッチ + */ +export function dispatchEntityHurtEvent( + entity: Entity, + damage: number, + cause: EntityDamageCause = EntityDamageCause.entityAttack, + attacker?: Entity +): void { + const event: EntityHurtAfterEvent = { + hurtEntity: entity, + damage, + damageSource: { + cause, + damagingEntity: attacker, + }, + }; + world.afterEvents.entityHurt.__dispatch(event); +} + +/** + * エンティティ死亡イベントをディスパッチ + */ +export function dispatchEntityDieEvent( + entity: Entity, + cause: EntityDamageCause = EntityDamageCause.entityAttack, + killer?: Entity +): void { + const event: EntityDieAfterEvent = { + deadEntity: entity, + damageSource: { + cause, + damagingEntity: killer, + }, + }; + world.afterEvents.entityDie.__dispatch(event); +} + +/** + * ブロック破壊イベントをディスパッチ + */ +export function dispatchBlockBreakEvent( + block: Block, + player?: Player, + permutation?: BlockPermutation +): void { + const event: BlockBreakAfterEvent = { + block, + brokenBlockPermutation: permutation ?? new BlockPermutation(block.typeId), + dimension: block.dimension, + player, + }; + world.afterEvents.blockBreak.__dispatch(event); +} + +/** + * ブロック破壊前イベントをディスパッチ(キャンセル可能) + */ +export function dispatchPlayerBreakBlockBeforeEvent( + player: Player, + block: Block, + itemStack?: ItemStack +): PlayerBreakBlockBeforeEvent { + const event: PlayerBreakBlockBeforeEvent = { + player, + block, + dimension: block.dimension, + itemStack, + cancel: false, + }; + world.beforeEvents.playerBreakBlock.__dispatch(event); + return event; +} + +/** + * ブロック設置イベントをディスパッチ + */ +export function dispatchBlockPlaceEvent(block: Block, player?: Player): void { + const event: BlockPlaceAfterEvent = { + block, + dimension: block.dimension, + player, + }; + world.afterEvents.blockPlace.__dispatch(event); +} + +/** + * ブロック設置前イベントをディスパッチ(キャンセル可能) + */ +export function dispatchPlayerPlaceBlockBeforeEvent( + player: Player, + block: Block, + face: Direction = Direction.Up, + faceLocation: Vector3 = { x: 0.5, y: 1, z: 0.5 } +): PlayerPlaceBlockBeforeEvent { + const event: PlayerPlaceBlockBeforeEvent = { + player, + block, + dimension: block.dimension, + face, + faceLocation, + cancel: false, + }; + world.beforeEvents.playerPlaceBlock.__dispatch(event); + return event; +} + +// ============================ +// モック管理 +// ============================ + +/** + * すべてのモックをリセット + */ +export function resetAllMocks(): void { + resetMocks(); +} + +/** + * 特定の関数のモック呼び出しをクリア + */ +export function clearMockCalls(...mocks: { mockClear: () => void }[]): void { + mocks.forEach((mock) => mock.mockClear()); +} + +// ============================ +// アサーション ヘルパー +// ============================ + +/** + * Vector3 の値が等しいかチェック + */ +export function expectVector3Equal( + actual: { x: number; y: number; z: number }, + expected: { x: number; y: number; z: number }, + epsilon = 0.0001 +): void { + expect(Math.abs(actual.x - expected.x)).toBeLessThan(epsilon); + expect(Math.abs(actual.y - expected.y)).toBeLessThan(epsilon); + expect(Math.abs(actual.z - expected.z)).toBeLessThan(epsilon); +} + +/** + * Vector3 が近似的に等しいかチェック + */ +export function isVector3Near( + a: Vector3, + b: Vector3, + epsilon = 0.0001 +): boolean { + return ( + Math.abs(a.x - b.x) < epsilon && + Math.abs(a.y - b.y) < epsilon && + Math.abs(a.z - b.z) < epsilon + ); +} + +/** + * モック関数が特定の引数で呼ばれたかチェック + */ +export function expectCalledWithMessage( + mockFn: { mock: { calls: any[][] } }, + expectedMessage: string +): void { + const calls = mockFn.mock.calls; + const found = calls.some((call) => + call.some((arg) => typeof arg === 'string' && arg.includes(expectedMessage)) + ); + expect(found).toBe(true); +} + +/** + * イベントハンドラが正しく登録されたかチェック + */ +export function expectEventSubscribed( + eventSignal: { subscribe: { mock: { calls: any[][] } } } +): void { + expect(eventSignal.subscribe.mock.calls.length).toBeGreaterThan(0); +} + +/** + * プレイヤーにメッセージが送信されたかチェック + */ +export function expectPlayerMessageSent(player: Player, message?: string): void { + expect(player.sendMessage).toHaveBeenCalled(); + if (message) { + expect(player.sendMessage).toHaveBeenCalledWith( + expect.stringContaining(message) + ); + } +} + +/** + * ワールドにメッセージが送信されたかチェック + */ +export function expectWorldMessageSent(message?: string): void { + expect(world.sendMessage).toHaveBeenCalled(); + if (message) { + expect(world.sendMessage).toHaveBeenCalledWith( + expect.stringContaining(message) + ); + } +} + +// ============================ +// ディメンション関連 +// ============================ + +/** + * オーバーワールドを取得 + */ +export function getOverworld(): Dimension { + return world.getDimension('minecraft:overworld'); +} + +/** + * ネザーを取得 + */ +export function getNether(): Dimension { + return world.getDimension('minecraft:nether'); +} + +/** + * エンドを取得 + */ +export function getTheEnd(): Dimension { + return world.getDimension('minecraft:the_end'); +} + +// ============================ +// スコアボード関連 +// ============================ + +/** + * スコアボードオブジェクティブを作成 + */ +export function createScoreboardObjective(id: string, displayName?: string) { + return world.scoreboard.addObjective(id, displayName); +} + +/** + * スコアボードをクリア + */ +export function clearScoreboard(): void { + world.scoreboard.__clear(); +} + +// ============================ +// コンソール出力キャプチャ +// ============================ + +/** + * console.log の出力をキャプチャ + */ +export function captureConsoleLogs(): { logs: string[]; restore: () => void } { + const logs: string[] = []; + const originalLog = console.log; + + console.log = (...args: any[]) => { + logs.push(args.map(String).join(' ')); + }; + + return { + logs, + restore: () => { + console.log = originalLog; + }, + }; +} + +/** + * console.warn の出力をキャプチャ + */ +export function captureConsoleWarns(): { warns: string[]; restore: () => void } { + const warns: string[] = []; + const originalWarn = console.warn; + + console.warn = (...args: any[]) => { + warns.push(args.map(String).join(' ')); + }; + + return { + warns, + restore: () => { + console.warn = originalWarn; + }, + }; +} + +/** + * console.error の出力をキャプチャ + */ +export function captureConsoleErrors(): { errors: string[]; restore: () => void } { + const errors: string[] = []; + const originalError = console.error; + + console.error = (...args: any[]) => { + errors.push(args.map(String).join(' ')); + }; + + return { + errors, + restore: () => { + console.error = originalError; + }, + }; +} + +// ============================ +// 再エクスポート +// ============================ + +export { + MockPlayer, + MockEntity, + ItemStack, + Block, + Dimension, + GameMode, + Direction, + EntityDamageCause, + BlockPermutation, + Vector3, + world, + system, +}; + +export type { Player, Entity }; diff --git a/tests/unit/event/eventManager.test.ts b/tests/unit/event/eventManager.test.ts new file mode 100644 index 0000000..c5ce278 --- /dev/null +++ b/tests/unit/event/eventManager.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { EventManager } from '@/event/eventManager'; +import { Priority } from '@/event/types'; +import { world } from '../../mocks/minecraft-server'; + +describe('EventManager', () => { + beforeEach(() => { + // EventManager は モジュールロード時に world.afterEvents/beforeEvents に subscribe しているため、 + // world.__clearAllEvents() を呼ぶと EventManager のハンドラも消えてしまう。 + // そのため、ここでは EventManager.clearAllListeners() のみを呼ぶ。 + vi.clearAllMocks(); + EventManager.clearAllListeners(); + }); + + describe('registerAfter', () => { + it('afterEventsにリスナーを登録できる', () => { + const handler = vi.fn(); + + EventManager.registerAfter('playerJoin', { + handler, + priority: Priority.NORMAL, + }); + + // イベントをディスパッチ + const mockEvent = { player: { name: 'TestPlayer' } }; + (world.afterEvents.playerJoin as any).__dispatch(mockEvent); + + // ハンドラが呼ばれることを確認 + expect(handler).toHaveBeenCalledWith(mockEvent); + }); + + it('複数のリスナーを登録できる', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + EventManager.registerAfter('playerJoin', { + handler: handler1, + priority: Priority.NORMAL, + }); + + EventManager.registerAfter('playerJoin', { + handler: handler2, + priority: Priority.HIGH, + }); + + // イベントをディスパッチ + const mockEvent = { player: { name: 'TestPlayer' } }; + (world.afterEvents.playerJoin as any).__dispatch(mockEvent); + + expect(handler1).toHaveBeenCalledWith(mockEvent); + expect(handler2).toHaveBeenCalledWith(mockEvent); + }); + + it('優先度の高い順にリスナーが実行される', () => { + const executionOrder: string[] = []; + + EventManager.registerAfter('playerJoin', { + handler: () => executionOrder.push('NORMAL'), + priority: Priority.NORMAL, + }); + + EventManager.registerAfter('playerJoin', { + handler: () => executionOrder.push('HIGH'), + priority: Priority.HIGH, + }); + + EventManager.registerAfter('playerJoin', { + handler: () => executionOrder.push('LOW'), + priority: Priority.LOW, + }); + + EventManager.registerAfter('playerJoin', { + handler: () => executionOrder.push('HIGHEST'), + priority: Priority.HIGHEST, + }); + + // イベントをディスパッチ + (world.afterEvents.playerJoin as any).__dispatch({}); + + // 優先度: HIGHEST(1) > HIGH(2) > NORMAL(3) > LOW(4) + expect(executionOrder).toEqual(['HIGHEST', 'HIGH', 'NORMAL', 'LOW']); + }); + + it('優先度を指定しない場合はNORMALとして扱われる', () => { + const executionOrder: string[] = []; + + EventManager.registerAfter('playerJoin', { + handler: () => executionOrder.push('NO_PRIORITY'), + }); + + EventManager.registerAfter('playerJoin', { + handler: () => executionOrder.push('HIGH'), + priority: Priority.HIGH, + }); + + EventManager.registerAfter('playerJoin', { + handler: () => executionOrder.push('LOW'), + priority: Priority.LOW, + }); + + // イベントをディスパッチ + (world.afterEvents.playerJoin as any).__dispatch({}); + + // NO_PRIORITY は NORMAL として扱われる + expect(executionOrder).toEqual(['HIGH', 'NO_PRIORITY', 'LOW']); + }); + }); + + describe('registerBefore', () => { + it('beforeEventsにリスナーを登録できる', () => { + const handler = vi.fn(); + + EventManager.registerBefore('playerBreakBlock', { + handler, + priority: Priority.NORMAL, + }); + + // イベントをディスパッチ + const mockEvent = { player: { name: 'TestPlayer' }, block: {} }; + (world.beforeEvents.playerBreakBlock as any).__dispatch(mockEvent); + + // ハンドラが呼ばれることを確認 + expect(handler).toHaveBeenCalledWith(mockEvent); + }); + + it('優先度の高い順にリスナーが実行される', () => { + const executionOrder: string[] = []; + + EventManager.registerBefore('playerBreakBlock', { + handler: () => executionOrder.push('NORMAL'), + priority: Priority.NORMAL, + }); + + EventManager.registerBefore('playerBreakBlock', { + handler: () => executionOrder.push('HIGHEST'), + priority: Priority.HIGHEST, + }); + + EventManager.registerBefore('playerBreakBlock', { + handler: () => executionOrder.push('MONITOR'), + priority: Priority.MONITOR, + }); + + // イベントをディスパッチ + (world.beforeEvents.playerBreakBlock as any).__dispatch({}); + + // 優先度: HIGHEST(1) > NORMAL(3) > MONITOR(0) + // MONITOR(0) が最も高い数値なので最後に実行される + expect(executionOrder).toEqual(['HIGHEST', 'NORMAL', 'MONITOR']); + }); + }); + + describe('エラーハンドリング', () => { + it('リスナーがエラーを投げても他のリスナーは実行される', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const handler1 = vi.fn(() => { + throw new Error('Test error'); + }); + const handler2 = vi.fn(); + + EventManager.registerAfter('playerJoin', { + handler: handler1, + priority: Priority.HIGH, + }); + + EventManager.registerAfter('playerJoin', { + handler: handler2, + priority: Priority.LOW, + }); + + // イベントをディスパッチ + (world.afterEvents.playerJoin as any).__dispatch({}); + + // handler1 はエラーを投げるが、handler2 は実行される + expect(handler1).toHaveBeenCalled(); + expect(handler2).toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('clearAllListeners', () => { + it('登録済みのリスナーをすべてクリアできる', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + EventManager.registerAfter('playerJoin', { + handler: handler1, + }); + + EventManager.registerBefore('playerBreakBlock', { + handler: handler2, + }); + + // クリア + EventManager.clearAllListeners(); + + // イベントをディスパッチ + (world.afterEvents.playerJoin as any).__dispatch({}); + (world.beforeEvents.playerBreakBlock as any).__dispatch({}); + + // ハンドラは呼ばれない + expect(handler1).not.toHaveBeenCalled(); + expect(handler2).not.toHaveBeenCalled(); + }); + }); + + describe('実用例', () => { + it('プレイヤー参加イベントを処理できる', () => { + const welcomeMessage = vi.fn(); + const logJoin = vi.fn(); + + // ウェルカムメッセージ(優先度: HIGH) + EventManager.registerAfter('playerJoin', { + handler: (event: any) => { + welcomeMessage(event.player?.name); + }, + priority: Priority.HIGH, + }); + + // ログ記録(優先度: MONITOR) + EventManager.registerAfter('playerJoin', { + handler: (event: any) => { + logJoin(event.player?.name); + }, + priority: Priority.MONITOR, + }); + + // イベントをディスパッチ + (world.afterEvents.playerJoin as any).__dispatch({ player: { name: 'Alice' } }); + + // 両方のハンドラが実行される + expect(welcomeMessage).toHaveBeenCalledWith('Alice'); + expect(logJoin).toHaveBeenCalledWith('Alice'); + + // ウェルカムメッセージが先に実行される + expect(welcomeMessage.mock.invocationCallOrder[0]).toBeLessThan( + logJoin.mock.invocationCallOrder[0] + ); + }); + }); +}); diff --git a/tests/unit/form/forms.test.ts b/tests/unit/form/forms.test.ts new file mode 100644 index 0000000..5bf888c --- /dev/null +++ b/tests/unit/form/forms.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ActionForm, createActionForm } from '@/form/actionForm'; +import { ModalForm, createModalForm } from '@/form/modalForm'; +import { MessageForm, createMessageForm } from '@/form/messageForm'; +import { button } from '@/form/components'; +import { createTestPlayer, resetAllMocks } from '../../mocks/test-utils'; +import { ActionFormData, ModalFormData, MessageFormData } from '@minecraft/server-ui'; + +describe('Form', () => { + let player: ReturnType; + + beforeEach(() => { + resetAllMocks(); + player = createTestPlayer(); + }); + + describe('ActionForm', () => { + it('createActionForm() でアクションフォームを作成できる', () => { + const form = createActionForm({ + title: 'Test Form', + body: 'Select an option', + buttons: [], + }); + + expect(form).toBeInstanceOf(ActionForm); + }); + + it('send() でプレイヤーにフォームを送信できる', async () => { + const buttonHandler = vi.fn(); + const testButton = button({ + text: 'Click Me', + handler: buttonHandler, + }); + + const form = createActionForm({ + title: 'Test Form', + body: 'Select an option', + buttons: [testButton], + }); + + // ActionFormDataのshowメソッドをモック + ActionFormData.prototype.show = vi.fn().mockResolvedValue({ + canceled: false, + selection: 0, + }); + + await form.send(player); + + expect(ActionFormData.prototype.show).toHaveBeenCalledWith(player); + expect(buttonHandler).toHaveBeenCalledWith(player); + }); + + it('フォームがキャンセルされた場合、前のフォームに戻る', async () => { + const previousForm = createActionForm({ + title: 'Previous Form', + buttons: [], + }); + + const previousFormSendSpy = vi.spyOn(previousForm, 'send').mockResolvedValue(); + + const form = createActionForm({ + title: 'Current Form', + buttons: [], + previousForm, + }); + + ActionFormData.prototype.show = vi.fn().mockResolvedValue({ + canceled: true, + }); + + await form.send(player); + + expect(previousFormSendSpy).toHaveBeenCalledWith(player); + }); + + it('複数のボタンを持つフォームを作成できる', async () => { + const button1Handler = vi.fn(); + const button2Handler = vi.fn(); + + const form = createActionForm({ + title: 'Multi Button Form', + buttons: [ + button({ text: 'Button 1', handler: button1Handler }), + button({ text: 'Button 2', handler: button2Handler }), + ], + }); + + ActionFormData.prototype.show = vi.fn().mockResolvedValue({ + canceled: false, + selection: 1, // 2番目のボタンを選択 + }); + + await form.send(player); + + expect(button1Handler).not.toHaveBeenCalled(); + expect(button2Handler).toHaveBeenCalledWith(player); + }); + }); + + describe('ModalForm', () => { + it('createModalForm() でモーダルフォームを作成できる', () => { + const form = createModalForm({ + title: 'Test Modal', + components: [], + }); + + expect(form).toBeInstanceOf(ModalForm); + }); + + it('send() でプレイヤーにフォームを送信できる', async () => { + const formHandler = vi.fn(); + + const form = createModalForm({ + title: 'Test Modal', + components: [], + handle: formHandler, + }); + + ModalFormData.prototype.show = vi.fn().mockResolvedValue({ + canceled: false, + formValues: [], + }); + + await form.send(player); + + expect(ModalFormData.prototype.show).toHaveBeenCalledWith(player); + expect(formHandler).toHaveBeenCalledWith(player, []); + }); + + it('フォームがキャンセルされた場合、前のフォームに戻る', async () => { + const previousForm = createModalForm({ + title: 'Previous Modal', + components: [], + }); + + const previousFormSendSpy = vi.spyOn(previousForm, 'send').mockResolvedValue(); + + const form = createModalForm({ + title: 'Current Modal', + components: [], + previousForm, + }); + + ModalFormData.prototype.show = vi.fn().mockResolvedValue({ + canceled: true, + }); + + await form.send(player); + + expect(previousFormSendSpy).toHaveBeenCalledWith(player); + }); + + it('各コンポーネントのハンドラが値とともに呼ばれる', async () => { + const componentHandler = vi.fn(); + + const form = createModalForm({ + title: 'Test Modal', + components: [ + { + render: (form) => form.textField('Name', 'Enter your name'), + handle: componentHandler, + }, + ], + }); + + ModalFormData.prototype.show = vi.fn().mockResolvedValue({ + canceled: false, + formValues: ['TestValue'], + }); + + await form.send(player); + + expect(componentHandler).toHaveBeenCalledWith(player, 'TestValue'); + }); + }); + + describe('MessageForm', () => { + it('createMessageForm() でメッセージフォームを作成できる', () => { + const form = createMessageForm({ + title: 'Confirm', + body: 'Are you sure?', + yes: { text: 'Yes', handler: () => {} }, + no: { text: 'No', handler: () => {} }, + }); + + expect(form).toBeInstanceOf(MessageForm); + }); + + it('send() でプレイヤーにフォームを送信できる', async () => { + const yesHandler = vi.fn(); + const noHandler = vi.fn(); + + const form = createMessageForm({ + title: 'Confirm', + body: 'Are you sure?', + yes: { text: 'Yes', handler: yesHandler }, + no: { text: 'No', handler: noHandler }, + }); + + MessageFormData.prototype.show = vi.fn().mockResolvedValue({ + canceled: false, + selection: 0, // Yesを選択 + }); + + await form.send(player); + + expect(MessageFormData.prototype.show).toHaveBeenCalledWith(player); + expect(yesHandler).toHaveBeenCalledWith(player); + expect(noHandler).not.toHaveBeenCalled(); + }); + + it('Noボタンを選択した場合、noハンドラが呼ばれる', async () => { + const yesHandler = vi.fn(); + const noHandler = vi.fn(); + + const form = createMessageForm({ + title: 'Confirm', + body: 'Are you sure?', + yes: { text: 'Yes', handler: yesHandler }, + no: { text: 'No', handler: noHandler }, + }); + + MessageFormData.prototype.show = vi.fn().mockResolvedValue({ + canceled: false, + selection: 1, // Noを選択 + }); + + await form.send(player); + + expect(yesHandler).not.toHaveBeenCalled(); + expect(noHandler).toHaveBeenCalledWith(player); + }); + + it('フォームがキャンセルされた場合、前のフォームに戻る', async () => { + const previousForm = createMessageForm({ + title: 'Previous Message', + body: 'Previous', + yes: { text: 'Yes', handler: () => {} }, + no: { text: 'No', handler: () => {} }, + }); + + const previousFormSendSpy = vi.spyOn(previousForm, 'send').mockResolvedValue(); + + const form = createMessageForm({ + title: 'Current Message', + body: 'Current', + yes: { text: 'Yes', handler: () => {} }, + no: { text: 'No', handler: () => {} }, + previousForm, + }); + + MessageFormData.prototype.show = vi.fn().mockResolvedValue({ + canceled: true, + }); + + await form.send(player); + + expect(previousFormSendSpy).toHaveBeenCalledWith(player); + }); + }); +}); diff --git a/tests/unit/math/axisAlignedBB.test.ts b/tests/unit/math/axisAlignedBB.test.ts new file mode 100644 index 0000000..096c49a --- /dev/null +++ b/tests/unit/math/axisAlignedBB.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect } from 'vitest'; +import { AxisAlignedBB } from '@/math/axisAlignedBB'; + +describe('AxisAlignedBB', () => { + describe('コンストラクタと生成', () => { + it('new AxisAlignedBB() でAABBを生成できる', () => { + const aabb = new AxisAlignedBB( + { x: 5, y: 5, z: 5 }, + { x: 2, y: 2, z: 2 } + ); + expect(aabb.center).toEqual({ x: 5, y: 5, z: 5 }); + expect(aabb.extent).toEqual({ x: 2, y: 2, z: 2 }); + }); + + it('AxisAlignedBB.fromMinMax() で最小点と最大点から生成できる', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 10, y: 10, z: 10 } + ); + expect(aabb.center).toEqual({ x: 5, y: 5, z: 5 }); + expect(aabb.extent).toEqual({ x: 5, y: 5, z: 5 }); + }); + + it('AxisAlignedBB.one() で単位AABBを生成できる', () => { + const aabb = AxisAlignedBB.one(); + expect(aabb.center).toEqual({ x: 0.5, y: 0.5, z: 0.5 }); + expect(aabb.extent).toEqual({ x: 0.5, y: 0.5, z: 0.5 }); + }); + + it('AxisAlignedBB.fromBDS() でオブジェクトから生成できる', () => { + const aabb = AxisAlignedBB.fromBDS({ + center: { x: 3, y: 4, z: 5 }, + extent: { x: 1, y: 1, z: 1 } + }); + expect(aabb.center).toEqual({ x: 3, y: 4, z: 5 }); + expect(aabb.extent).toEqual({ x: 1, y: 1, z: 1 }); + }); + }); + + describe('最小点・最大点', () => { + it('min で最小点を取得できる', () => { + const aabb = new AxisAlignedBB( + { x: 5, y: 5, z: 5 }, + { x: 2, y: 2, z: 2 } + ); + expect(aabb.min).toEqual({ x: 3, y: 3, z: 3 }); + }); + + it('max で最大点を取得できる', () => { + const aabb = new AxisAlignedBB( + { x: 5, y: 5, z: 5 }, + { x: 2, y: 2, z: 2 } + ); + expect(aabb.max).toEqual({ x: 7, y: 7, z: 7 }); + }); + }); + + describe('変形', () => { + it('offset() で位置をずらせる', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 2, y: 2, z: 2 } + ); + const offset = aabb.offset(3, 4, 5); + + expect(offset.center).toEqual({ x: 4, y: 5, z: 6 }); + expect(offset.extent).toEqual({ x: 1, y: 1, z: 1 }); + // 元のAABBは変更されない + expect(aabb.center).toEqual({ x: 1, y: 1, z: 1 }); + }); + + it('expand() で拡大できる', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 2, y: 2, z: 2 } + ); + const expanded = aabb.expand(1, 1, 1); + + expect(expanded.center).toEqual({ x: 1, y: 1, z: 1 }); + expect(expanded.extent).toEqual({ x: 2, y: 2, z: 2 }); + expect(expanded.min).toEqual({ x: -1, y: -1, z: -1 }); + expect(expanded.max).toEqual({ x: 3, y: 3, z: 3 }); + }); + + it('contract() で縮小できる', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 4, y: 4, z: 4 } + ); + const contracted = aabb.contract(1, 1, 1); + + expect(contracted.center).toEqual({ x: 2, y: 2, z: 2 }); + expect(contracted.extent).toEqual({ x: 1, y: 1, z: 1 }); + expect(contracted.min).toEqual({ x: 1, y: 1, z: 1 }); + expect(contracted.max).toEqual({ x: 3, y: 3, z: 3 }); + }); + }); + + describe('衝突判定', () => { + it('intersects() で重なっているAABBを検出できる', () => { + const aabb1 = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 2, y: 2, z: 2 } + ); + const aabb2 = AxisAlignedBB.fromMinMax( + { x: 1, y: 1, z: 1 }, + { x: 3, y: 3, z: 3 } + ); + + expect(aabb1.intersects(aabb2)).toBe(true); + expect(aabb2.intersects(aabb1)).toBe(true); + }); + + it('intersects() で重なっていないAABBを検出できる', () => { + const aabb1 = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 2, y: 2, z: 2 } + ); + const aabb2 = AxisAlignedBB.fromMinMax( + { x: 5, y: 5, z: 5 }, + { x: 7, y: 7, z: 7 } + ); + + expect(aabb1.intersects(aabb2)).toBe(false); + expect(aabb2.intersects(aabb1)).toBe(false); + }); + + it('intersects() で接しているAABBを検出できる', () => { + const aabb1 = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 2, y: 2, z: 2 } + ); + const aabb2 = AxisAlignedBB.fromMinMax( + { x: 2, y: 2, z: 2 }, + { x: 4, y: 4, z: 4 } + ); + + // 接しているだけの場合はfalse(epsilonによる判定) + expect(aabb1.intersects(aabb2)).toBe(false); + }); + + it('contains() で点が内部にあるかチェックできる', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 10, y: 10, z: 10 } + ); + + expect(aabb.contains({ x: 5, y: 5, z: 5 })).toBe(true); + expect(aabb.contains({ x: 1, y: 1, z: 1 })).toBe(true); + expect(aabb.contains({ x: 9, y: 9, z: 9 })).toBe(true); + }); + + it('contains() で点が外部にあるかチェックできる', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 10, y: 10, z: 10 } + ); + + expect(aabb.contains({ x: -1, y: 5, z: 5 })).toBe(false); + expect(aabb.contains({ x: 11, y: 5, z: 5 })).toBe(false); + expect(aabb.contains({ x: 5, y: -1, z: 5 })).toBe(false); + }); + + it('contains() で境界上の点は含まれない', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 10, y: 10, z: 10 } + ); + + // 境界上の点は含まれない(不等号が > と < なので) + expect(aabb.contains({ x: 0, y: 5, z: 5 })).toBe(false); + expect(aabb.contains({ x: 10, y: 5, z: 5 })).toBe(false); + }); + }); + + describe('サイズ計算', () => { + it('getXLength() でX方向の長さを取得できる', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 10, y: 5, z: 3 } + ); + expect(aabb.getXLength()).toBe(10); + }); + + it('getYLength() でY方向の長さを取得できる', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 10, y: 5, z: 3 } + ); + expect(aabb.getYLength()).toBe(5); + }); + + it('getZLength() でZ方向の長さを取得できる', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 10, y: 5, z: 3 } + ); + expect(aabb.getZLength()).toBe(3); + }); + + it('getVolume() で体積を取得できる', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 10, y: 5, z: 2 } + ); + expect(aabb.getVolume()).toBe(100); // 10 * 5 * 2 + }); + }); + + describe('形状判定', () => { + it('isCube() で立方体を判定できる', () => { + const cube = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 5, y: 5, z: 5 } + ); + expect(cube.isCube()).toBe(true); + }); + + it('isCube() で立方体でない形状を判定できる', () => { + const notCube = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 10, y: 5, z: 3 } + ); + expect(notCube.isCube()).toBe(false); + }); + }); + + describe('変換', () => { + it('toBDS() でBDS形式に変換できる', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 4, y: 4, z: 4 } + ); + const bds = aabb.toBDS(); + expect(bds).toEqual({ + center: { x: 2, y: 2, z: 2 }, + extent: { x: 2, y: 2, z: 2 } + }); + }); + + it('toObject() でオブジェクトに変換できる', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 4, y: 4, z: 4 } + ); + const obj = aabb.toObject(); + expect(obj).toEqual({ + center: { x: 2, y: 2, z: 2 }, + extent: { x: 2, y: 2, z: 2 } + }); + }); + + it('toString() で文字列に変換できる', () => { + const aabb = AxisAlignedBB.fromMinMax( + { x: 0, y: 0, z: 0 }, + { x: 4, y: 4, z: 4 } + ); + const str = aabb.toString(); + expect(str).toContain('AxisAlignedBB'); + expect(str).toContain('center'); + expect(str).toContain('extent'); + }); + }); +}); diff --git a/tests/unit/math/vector3.test.ts b/tests/unit/math/vector3.test.ts new file mode 100644 index 0000000..d873a42 --- /dev/null +++ b/tests/unit/math/vector3.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect } from 'vitest'; +import { Vector3 } from '@/math/vector3'; +import { expectVector3Equal } from '../../mocks/test-utils'; + +describe('Vector3', () => { + describe('コンストラクタと生成', () => { + it('new Vector3() でベクトルを生成できる', () => { + const v = new Vector3(1, 2, 3); + expect(v.x).toBe(1); + expect(v.y).toBe(2); + expect(v.z).toBe(3); + }); + + it('Vector3.zero() でゼロベクトルを生成できる', () => { + const v = Vector3.zero(); + expect(v.x).toBe(0); + expect(v.y).toBe(0); + expect(v.z).toBe(0); + }); + + it('Vector3.fromBDS() でオブジェクトから生成できる', () => { + const v = Vector3.fromBDS({ x: 4, y: 5, z: 6 }); + expect(v.x).toBe(4); + expect(v.y).toBe(5); + expect(v.z).toBe(6); + }); + }); + + describe('ゲッター', () => { + it('getX/Y/Z() で各座標を取得できる', () => { + const v = new Vector3(1.5, 2.5, 3.5); + expect(v.getX()).toBe(1.5); + expect(v.getY()).toBe(2.5); + expect(v.getZ()).toBe(3.5); + }); + + it('getFloorX/Y/Z() で整数値を取得できる', () => { + const v = new Vector3(1.7, 2.3, -3.9); + expect(v.getFloorX()).toBe(1); + expect(v.getFloorY()).toBe(2); + expect(v.getFloorZ()).toBe(-4); + }); + }); + + describe('算術演算', () => { + it('add() で加算できる', () => { + const v = new Vector3(1, 2, 3); + const result = v.add(4, 5, 6); + expect(result.x).toBe(5); + expect(result.y).toBe(7); + expect(result.z).toBe(9); + // 元のベクトルは変更されない + expect(v.x).toBe(1); + }); + + it('addVector() でベクトル同士を加算できる', () => { + const v1 = new Vector3(1, 2, 3); + const v2 = { x: 4, y: 5, z: 6 }; + const result = v1.addVector(v2); + expect(result.x).toBe(5); + expect(result.y).toBe(7); + expect(result.z).toBe(9); + }); + + it('subtract() で減算できる', () => { + const v = new Vector3(5, 7, 9); + const result = v.subtract(1, 2, 3); + expect(result.x).toBe(4); + expect(result.y).toBe(5); + expect(result.z).toBe(6); + }); + + it('subtractVector() でベクトル同士を減算できる', () => { + const v1 = new Vector3(5, 7, 9); + const v2 = { x: 1, y: 2, z: 3 }; + const result = v1.subtractVector(v2); + expect(result.x).toBe(4); + expect(result.y).toBe(5); + expect(result.z).toBe(6); + }); + + it('multiply() でスカラー倍できる', () => { + const v = new Vector3(1, 2, 3); + const result = v.multiply(2); + expect(result.x).toBe(2); + expect(result.y).toBe(4); + expect(result.z).toBe(6); + }); + + it('divide() で除算できる', () => { + const v = new Vector3(6, 8, 10); + const result = v.divide(2); + expect(result.x).toBe(3); + expect(result.y).toBe(4); + expect(result.z).toBe(5); + }); + }); + + describe('数値変換', () => { + it('ceil() で切り上げできる', () => { + const v = new Vector3(1.1, 2.5, 3.9); + const result = v.ceil(); + expect(result.x).toBe(2); + expect(result.y).toBe(3); + expect(result.z).toBe(4); + }); + + it('floor() で切り捨てできる', () => { + const v = new Vector3(1.1, 2.5, 3.9); + const result = v.floor(); + expect(result.x).toBe(1); + expect(result.y).toBe(2); + expect(result.z).toBe(3); + }); + + it('round() で四捨五入できる', () => { + const v = new Vector3(1.4, 2.5, 3.6); + const result = v.round(); + expect(result.x).toBe(1); + expect(result.y).toBe(3); + expect(result.z).toBe(4); + }); + + it('round(precision) で指定桁数で四捨五入できる', () => { + const v = new Vector3(1.234, 2.567, 3.891); + const result = v.round(1); + expect(result.x).toBe(1.2); + expect(result.y).toBe(2.6); + expect(result.z).toBe(3.9); + }); + + it('abs() で絶対値を取得できる', () => { + const v = new Vector3(-1, -2, 3); + const result = v.abs(); + expect(result.x).toBe(1); + expect(result.y).toBe(2); + expect(result.z).toBe(3); + }); + }); + + describe('距離計算', () => { + it('distance() でユークリッド距離を計算できる', () => { + const v1 = new Vector3(0, 0, 0); + const v2 = { x: 3, y: 4, z: 0 }; + const distance = v1.distance(v2); + expect(distance).toBe(5); + }); + + it('distanceSquared() で距離の2乗を計算できる', () => { + const v1 = new Vector3(0, 0, 0); + const v2 = { x: 3, y: 4, z: 0 }; + const distanceSquared = v1.distanceSquared(v2); + expect(distanceSquared).toBe(25); + }); + + it('length() でベクトルの長さを計算できる', () => { + const v = new Vector3(3, 4, 0); + expect(v.length()).toBe(5); + }); + + it('lengthSquared() でベクトルの長さの2乗を計算できる', () => { + const v = new Vector3(3, 4, 0); + expect(v.lengthSquared()).toBe(25); + }); + }); + + describe('ベクトル演算', () => { + it('dot() で内積を計算できる', () => { + const v1 = new Vector3(1, 2, 3); + const v2 = { x: 4, y: 5, z: 6 }; + const dotProduct = v1.dot(v2); + expect(dotProduct).toBe(32); // 1*4 + 2*5 + 3*6 = 32 + }); + + it('cross() で外積を計算できる', () => { + const v1 = new Vector3(1, 0, 0); + const v2 = { x: 0, y: 1, z: 0 }; + const crossProduct = v1.cross(v2); + expect(crossProduct.x).toBe(0); + expect(crossProduct.y).toBe(0); + expect(crossProduct.z).toBe(1); + }); + + it('normalize() で正規化できる', () => { + const v = new Vector3(3, 4, 0); + const normalized = v.normalize(); + expectVector3Equal(normalized, { x: 0.6, y: 0.8, z: 0 }); + // 正規化されたベクトルの長さは1 + expect(normalized.length()).toBeCloseTo(1); + }); + + it('normalize() でゼロベクトルを正規化するとゼロベクトルを返す', () => { + const v = Vector3.zero(); + const normalized = v.normalize(); + expect(normalized.x).toBe(0); + expect(normalized.y).toBe(0); + expect(normalized.z).toBe(0); + }); + }); + + describe('比較', () => { + it('equals() で同じベクトルを比較できる', () => { + const v1 = new Vector3(1, 2, 3); + const v2 = { x: 1, y: 2, z: 3 }; + expect(v1.equals(v2)).toBe(true); + }); + + it('equals() で異なるベクトルを比較できる', () => { + const v1 = new Vector3(1, 2, 3); + const v2 = { x: 4, y: 5, z: 6 }; + expect(v1.equals(v2)).toBe(false); + }); + }); + + describe('ユーティリティ', () => { + it('withComponents() で一部の座標を変更できる', () => { + const v = new Vector3(1, 2, 3); + const result = v.withComponents(10, undefined, 30); + expect(result.x).toBe(10); + expect(result.y).toBe(2); + expect(result.z).toBe(30); + }); + + it('toBDS() でBDS形式に変換できる', () => { + const v = new Vector3(1, 2, 3); + const bds = v.toBDS(); + expect(bds).toEqual({ x: 1, y: 2, z: 3 }); + }); + + it('toObject() でオブジェクトに変換できる', () => { + const v = new Vector3(1, 2, 3); + const obj = v.toObject(); + expect(obj).toEqual({ x: 1, y: 2, z: 3 }); + }); + + it('toString() で文字列に変換できる', () => { + const v = new Vector3(1, 2, 3); + const str = v.toString(); + expect(str).toBe('_Vector3(x=1, y=2, z=3)'); + }); + }); + + describe('線分上の点計算', () => { + it('getIntermediateWithXValue() でX座標指定で線分上の点を取得できる', () => { + const start = new Vector3(0, 0, 0); + const end = new Vector3(10, 10, 10); + const point = start.getIntermediateWithXValue(end, 5); + expect(point).toBeDefined(); + expect(point!.x).toBe(5); + expect(point!.y).toBe(5); + expect(point!.z).toBe(5); + }); + + it('getIntermediateWithXValue() で範囲外の場合undefinedを返す', () => { + const start = new Vector3(0, 0, 0); + const end = new Vector3(10, 10, 10); + const point = start.getIntermediateWithXValue(end, 15); + expect(point).toBeUndefined(); + }); + + it('getIntermediateWithYValue() でY座標指定で線分上の点を取得できる', () => { + const start = new Vector3(0, 0, 0); + const end = new Vector3(10, 10, 10); + const point = start.getIntermediateWithYValue(end, 7); + expect(point).toBeDefined(); + expect(point!.x).toBe(7); + expect(point!.y).toBe(7); + expect(point!.z).toBe(7); + }); + + it('getIntermediateWithZValue() でZ座標指定で線分上の点を取得できる', () => { + const start = new Vector3(0, 0, 0); + const end = new Vector3(10, 10, 10); + const point = start.getIntermediateWithZValue(end, 3); + expect(point).toBeDefined(); + expect(point!.x).toBe(3); + expect(point!.y).toBe(3); + expect(point!.z).toBe(3); + }); + }); + + describe('静的メソッド', () => { + it('maxComponents() で各軸の最大値を取得できる', () => { + const v1 = { x: 1, y: 5, z: 3 }; + const v2 = { x: 4, y: 2, z: 6 }; + const v3 = { x: 2, y: 8, z: 1 }; + const max = Vector3.maxComponents(v1, v2, v3); + expect(max.x).toBe(4); + expect(max.y).toBe(8); + expect(max.z).toBe(6); + }); + + it('minComponents() で各軸の最小値を取得できる', () => { + const v1 = { x: 1, y: 5, z: 3 }; + const v2 = { x: 4, y: 2, z: 6 }; + const v3 = { x: 2, y: 8, z: 1 }; + const min = Vector3.minComponents(v1, v2, v3); + expect(min.x).toBe(1); + expect(min.y).toBe(2); + expect(min.z).toBe(1); + }); + + it('sum() で複数ベクトルの合計を計算できる', () => { + const v1 = { x: 1, y: 2, z: 3 }; + const v2 = { x: 4, y: 5, z: 6 }; + const v3 = { x: 7, y: 8, z: 9 }; + const sum = Vector3.sum(v1, v2, v3); + expect(sum.x).toBe(12); + expect(sum.y).toBe(15); + expect(sum.z).toBe(18); + }); + }); +}); diff --git a/tests/unit/timer/timer.test.ts b/tests/unit/timer/timer.test.ts new file mode 100644 index 0000000..8f713f4 --- /dev/null +++ b/tests/unit/timer/timer.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + repeating, + delayed, + sleep, + until, + waitUntil, +} from '@/timer/timer'; +import { tickSystem, resetAllMocks } from '../../mocks/test-utils'; + +describe('Timer', () => { + beforeEach(() => { + resetAllMocks(); + }); + + describe('RepeatingTimer', () => { + it('repeating() で定期的にコールバックを実行できる', () => { + const callback = vi.fn(); + repeating({ + every: 5, + run: callback, + }); + + // 5 tick経過 + tickSystem(5); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(5); + + // さらに5 tick経過(合計10 tick) + tickSystem(5); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledWith(10); + }); + + it('max オプションで最大実行回数を指定できる', () => { + const callback = vi.fn(); + const finalCallback = vi.fn(); + + repeating({ + every: 1, + run: callback, + max: 3, + final: finalCallback, + }); + + // 3 tick経過 + tickSystem(3); + expect(callback).toHaveBeenCalledTimes(3); + expect(finalCallback).toHaveBeenCalledTimes(1); + + // さらに進めても呼ばれない + tickSystem(2); + expect(callback).toHaveBeenCalledTimes(3); + }); + + it('cancel() でタイマーをキャンセルできる', () => { + const callback = vi.fn(); + const cancelCallback = vi.fn(); + + const timer = repeating({ + every: 1, + run: callback, + cancel: cancelCallback, + }); + + // 2 tick経過 + tickSystem(2); + expect(callback).toHaveBeenCalledTimes(2); + + // キャンセル + timer.cancel(); + + // 次のtickでキャンセルが処理される + tickSystem(1); + expect(cancelCallback).toHaveBeenCalledTimes(1); + + // それ以降は呼ばれない + tickSystem(2); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('stop() と resume() でタイマーを一時停止・再開できる', () => { + const callback = vi.fn(); + + const timer = repeating({ + every: 1, + run: callback, + }); + + // 2 tick経過 + tickSystem(2); + expect(callback).toHaveBeenCalledTimes(2); + + // 停止 + timer.stop(); + tickSystem(3); + expect(callback).toHaveBeenCalledTimes(2); // 停止中なので呼ばれない + + // 再開 + timer.resume(); + tickSystem(2); + expect(callback).toHaveBeenCalledTimes(4); + }); + + it('isStopped() で停止状態を確認できる', () => { + const timer = repeating({ + every: 1, + run: () => {}, + }); + + expect(timer.isStopped()).toBe(false); + + timer.stop(); + expect(timer.isStopped()).toBe(true); + + timer.resume(); + expect(timer.isStopped()).toBe(false); + }); + + it('runWhileStopped オプションで停止中でも実行できる', () => { + const callback = vi.fn(); + + const timer = repeating({ + every: 1, + run: callback, + runWhileStopped: true, + }); + + // 2 tick経過 + tickSystem(2); + expect(callback).toHaveBeenCalledTimes(2); + + // 停止 + timer.stop(); + tickSystem(3); + // runWhileStopped が true なので停止中でも実行される + expect(callback).toHaveBeenCalledTimes(5); + }); + }); + + describe('DelayedTimer', () => { + it('delayed() で指定tick後にコールバックを実行できる', () => { + const callback = vi.fn(); + + delayed(10, callback); + + // 9 tick経過(まだ実行されない) + tickSystem(9); + expect(callback).not.toHaveBeenCalled(); + + // 1 tick経過(合計10 tick、実行される) + tickSystem(1); + expect(callback).toHaveBeenCalledTimes(1); + + // さらに進めても1回だけ + tickSystem(10); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('cancel() でタイマーをキャンセルできる', () => { + const callback = vi.fn(); + + const timer = delayed(10, callback); + + // 5 tick経過 + tickSystem(5); + expect(callback).not.toHaveBeenCalled(); + + // キャンセル + timer.cancel(); + + // 進めてもコールバックは呼ばれない + tickSystem(10); + expect(callback).not.toHaveBeenCalled(); + }); + + it('sleep() で指定tick後にresolveされる', async () => { + const promise = sleep(5); + let resolved = false; + + promise.then(() => { + resolved = true; + }); + + // 4 tick経過(まだresolveされない) + tickSystem(4); + await Promise.resolve(); // マイクロタスクを処理 + expect(resolved).toBe(false); + + // 1 tick経過(合計5 tick、resolveされる) + tickSystem(1); + await promise; + expect(resolved).toBe(true); + }); + }); + + describe('UntilTimer', () => { + it('until() で条件が満たされるまで待機できる', () => { + let count = 0; + const condition = () => count >= 5; + const callback = vi.fn(); + + until({ + when: condition, + run: callback, + every: 1, + }); + + // 条件が満たされないまま4 tick経過 + for (let i = 0; i < 4; i++) { + count++; + tickSystem(1); + } + expect(callback).not.toHaveBeenCalled(); + + // 条件が満たされる + count++; + tickSystem(1); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('timeout オプションでタイムアウトを設定できる', () => { + const condition = () => false; // 常にfalse + const callback = vi.fn(); + const timeoutCallback = vi.fn(); + + until({ + when: condition, + run: callback, + every: 1, + timeout: 10, + onTimeout: timeoutCallback, + }); + + // 9 tick経過(まだタイムアウトしない) + tickSystem(9); + expect(callback).not.toHaveBeenCalled(); + expect(timeoutCallback).not.toHaveBeenCalled(); + + // 1 tick経過(合計10 tick、タイムアウト) + tickSystem(1); + expect(callback).not.toHaveBeenCalled(); + expect(timeoutCallback).toHaveBeenCalledTimes(1); + }); + + it('cancel() でタイマーをキャンセルできる', () => { + const condition = () => false; + const callback = vi.fn(); + + const timer = until({ + when: condition, + run: callback, + every: 1, + }); + + // 3 tick経過 + tickSystem(3); + + // キャンセル + timer.cancel(); + + // さらに進めても呼ばれない + tickSystem(5); + expect(callback).not.toHaveBeenCalled(); + }); + + it('stop() でタイマーを一時停止できる', () => { + let count = 0; + const condition = () => count >= 5; + const callback = vi.fn(); + + const timer = until({ + when: condition, + run: callback, + every: 1, + }); + + // 2 tick経過 + tickSystem(2); + + // 停止 + timer.stop(); + count = 10; // 条件を満たす値にする + tickSystem(3); + expect(callback).not.toHaveBeenCalled(); // 停止中なので実行されない + + // 再開 + timer.resume(); + tickSystem(1); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('waitUntil() で条件が満たされるまで待機できる', async () => { + let count = 0; + const condition = () => count >= 3; + + const promise = waitUntil(condition, { every: 1 }); + + // 2 tick経過 + count = 2; + tickSystem(2); + + // 条件が満たされる + count = 3; + tickSystem(1); + + const result = await promise; + expect(result).toBe(true); + }); + + it('waitUntil() でタイムアウトした場合はfalseを返す', async () => { + const condition = () => false; // 常にfalse + + const promise = waitUntil(condition, { + every: 1, + timeout: 5, + }); + + // 5 tick経過(タイムアウト) + tickSystem(5); + + const result = await promise; + expect(result).toBe(false); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 774aecb..abe7717 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,7 @@ }, "include": [ "core/**/*", - "vite-plugin/**/*", + "vite-plugin/**/*" ], "exclude": [ "node_modules", diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..408a1a6 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals"] + }, + "include": [ + "tests/**/*" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..52b415c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + resolve: { + alias: { + '@': resolve(__dirname, 'core'), + '@minecraft/server': resolve(__dirname, 'tests/mocks/minecraft-server.ts'), + '@minecraft/server-ui': resolve(__dirname, 'tests/mocks/minecraft-server-ui.ts'), + }, + }, + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['core/**/*.ts'], + exclude: [ + 'core/**/*.d.ts', + 'core/types/**/*', + 'vite-plugin/**/*', + ], + }, + }, +});