diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e733fb0f..1f693d79 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -83,6 +83,9 @@ jobs: - name: Build assets run: npm run build + - name: Run frontend unit tests + run: npm run test:unit + - name: Run migrations run: php artisan migrate --force env: @@ -97,3 +100,12 @@ jobs: env: DB_PORT: ${{ job.services.postgres.ports['5432'] }} REDIS_PORT: ${{ job.services.redis.ports['6379'] }} + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run browser tests + run: php artisan test tests/Browser --compact + env: + DB_PORT: ${{ job.services.postgres.ports['5432'] }} + REDIS_PORT: ${{ job.services.redis.ports['6379'] }} diff --git a/composer.json b/composer.json index 1edbfebc..f9a4236b 100644 --- a/composer.json +++ b/composer.json @@ -121,6 +121,11 @@ "@test:lint", "@php artisan test" ], + "test:all": [ + "@test", + "npm run test:unit", + "@php artisan test tests/Browser" + ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" diff --git a/package-lock.json b/package-lock.json index 51f067e3..d198f40f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "typescript": "^5.2.2", "typescript-eslint": "^8.23.0", "vite": "^8.0.16", + "vitest": "^4.1.9", "vue-tsc": "^2.2.4" }, "optionalDependencies": { @@ -1670,6 +1671,13 @@ "license": "MIT", "peer": true }, + "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/@swc/helpers": { "version": "0.5.18", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", @@ -2036,6 +2044,17 @@ "tslib": "^2.4.0" } }, + "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/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -2361,6 +2380,13 @@ "dev": true, "license": "MIT" }, + "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", @@ -3187,6 +3213,136 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "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 || ^8.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.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@volar/language-core": { "version": "2.4.15", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", @@ -3787,6 +3943,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3943,6 +4109,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", @@ -5116,6 +5292,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5720,6 +5903,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7638,6 +7831,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -7787,6 +7994,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pbf": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", @@ -8690,6 +8904,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -8751,6 +8972,20 @@ "node": ">=12.0.0" } }, + "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": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -8984,6 +9219,23 @@ "node": ">=12.22" } }, + "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.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -9036,6 +9288,16 @@ "dev": true, "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9554,6 +9816,109 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.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.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "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/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -9799,6 +10164,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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 5920e3a0..4e3792d6 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "dev": "vite", "format": "prettier --write resources/", "format:check": "prettier --check resources/", - "lint": "eslint . --fix" + "lint": "eslint . --fix", + "test:unit": "vitest run" }, "devDependencies": { "@eslint/js": "^9.19.0", @@ -36,6 +37,7 @@ "typescript": "^5.2.2", "typescript-eslint": "^8.23.0", "vite": "^8.0.16", + "vitest": "^4.1.9", "vue-tsc": "^2.2.4" }, "dependencies": { diff --git a/resources/js/lib/imageCrop.test.ts b/resources/js/lib/imageCrop.test.ts new file mode 100644 index 00000000..ce35943b --- /dev/null +++ b/resources/js/lib/imageCrop.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; + +import { + clampSelection, + containScale, + defaultSelection, + resizeSelection, + resolveOutputFileName, + resolveOutputMime, +} from '@/lib/imageCrop'; + +const VIEWPORT = 512; + +describe('containScale', () => { + it('fits a landscape image by its width', () => { + expect(containScale(1000, 500, VIEWPORT)).toBeCloseTo(VIEWPORT / 1000, 6); + }); + + it('fits a portrait image by its height', () => { + expect(containScale(500, 1000, VIEWPORT)).toBeCloseTo(VIEWPORT / 1000, 6); + }); + + it('returns 1:1 when the image already matches the viewport', () => { + expect(containScale(VIEWPORT, VIEWPORT, VIEWPORT)).toBe(1); + }); + + it('falls back to 1 for degenerate dimensions', () => { + expect(containScale(0, 500, VIEWPORT)).toBe(1); + expect(containScale(500, 0, VIEWPORT)).toBe(1); + expect(containScale(-10, 500, VIEWPORT)).toBe(1); + }); +}); + +describe('defaultSelection', () => { + it('is a centered square inset from the edges of a landscape image', () => { + expect(defaultSelection(1000, 500)).toEqual({ sx: 300, sy: 50, sw: 400, sh: 400 }); + }); + + it('is a centered square inset from the edges of a portrait image', () => { + expect(defaultSelection(500, 1000)).toEqual({ sx: 50, sy: 300, sw: 400, sh: 400 }); + }); + + it('stays inset even when the image is already square, so the crop box is always visible', () => { + expect(defaultSelection(800, 800)).toEqual({ sx: 80, sy: 80, sw: 640, sh: 640 }); + }); +}); + +describe('clampSelection', () => { + it('caps the size at the shorter image side', () => { + expect(clampSelection({ sx: 0, sy: 0, sw: 9999, sh: 9999 }, 1000, 500, 50)).toEqual({ + sx: 0, + sy: 0, + sw: 500, + sh: 500, + }); + }); + + it('never shrinks below the minimum size', () => { + expect(clampSelection({ sx: 0, sy: 0, sw: 10, sh: 10 }, 1000, 500, 50).sw).toBe(50); + }); + + it('pulls a selection that ran off the right edge back inside', () => { + const clamped = clampSelection({ sx: 900, sy: 0, sw: 500, sh: 500 }, 1000, 500, 50); + + expect(clamped.sx).toBe(500); + expect(clamped.sx + clamped.sw).toBe(1000); + }); + + it('pulls negative offsets back to the origin', () => { + expect(clampSelection({ sx: -100, sy: -100, sw: 300, sh: 300 }, 1000, 500, 50)).toEqual({ + sx: 0, + sy: 0, + sw: 300, + sh: 300, + }); + }); + + it('pulls a selection that ran off the bottom edge back inside', () => { + const clamped = clampSelection({ sx: 0, sy: 900, sw: 400, sh: 400 }, 500, 1000, 50); + + expect(clamped.sy).toBe(600); + expect(clamped.sy + clamped.sh).toBe(1000); + }); +}); + +describe('resizeSelection', () => { + const base = { sx: 200, sy: 100, sw: 200, sh: 200 }; + + it('grows from the anchored top-left corner when the vertical drag dominates (se)', () => { + const resized = resizeSelection(base, 'se', 500, 500, 1000, 600, 50); + + expect(resized).toEqual({ sx: 200, sy: 100, sw: 400, sh: 400 }); + }); + + it('sizes from the horizontal drag when it dominates (se)', () => { + const resized = resizeSelection(base, 'se', 700, 150, 1000, 600, 50); + + expect(resized).toEqual({ sx: 200, sy: 100, sw: 500, sh: 500 }); + }); + + it('anchors the top-right corner when dragging the bottom-left (sw)', () => { + const resized = resizeSelection(base, 'sw', 50, 120, 1000, 600, 50); + + expect(resized).toEqual({ sx: 50, sy: 100, sw: 350, sh: 350 }); + }); + + it('anchors the bottom-left corner when dragging the top-right (ne)', () => { + const resized = resizeSelection(base, 'ne', 450, 50, 1000, 600, 50); + + expect(resized).toEqual({ sx: 200, sy: 50, sw: 250, sh: 250 }); + }); + + it('anchors the bottom-right corner when dragging the top-left (nw)', () => { + const resized = resizeSelection(base, 'nw', 100, 50, 1000, 600, 50); + + expect(resized).toEqual({ sx: 100, sy: 0, sw: 300, sh: 300 }); + }); + + it('stays square whichever axis the pointer favours', () => { + const resized = resizeSelection(base, 'se', 260, 900, 1000, 600, 50); + + expect(resized.sw).toBe(resized.sh); + }); + + it('honours the minimum size when collapsed', () => { + const resized = resizeSelection(base, 'se', 210, 110, 1000, 600, 50); + + expect(resized).toEqual({ sx: 200, sy: 100, sw: 50, sh: 50 }); + }); + + it('never escapes the image bounds under an extreme drag', () => { + const resized = resizeSelection(base, 'ne', 5000, -5000, 1000, 600, 50); + + expect(resized.sx).toBeGreaterThanOrEqual(0); + expect(resized.sy).toBeGreaterThanOrEqual(0); + expect(resized.sx + resized.sw).toBeLessThanOrEqual(1000); + expect(resized.sy + resized.sh).toBeLessThanOrEqual(600); + }); +}); + +describe('resolveOutputMime', () => { + it('passes through mimes the canvas can encode', () => { + expect(resolveOutputMime('image/jpeg')).toBe('image/jpeg'); + expect(resolveOutputMime('image/png')).toBe('image/png'); + expect(resolveOutputMime('image/webp')).toBe('image/webp'); + }); + + it('coerces mimes the canvas cannot encode to png', () => { + expect(resolveOutputMime('image/gif')).toBe('image/png'); + expect(resolveOutputMime('image/svg+xml')).toBe('image/png'); + expect(resolveOutputMime('image/avif')).toBe('image/png'); + expect(resolveOutputMime('')).toBe('image/png'); + }); +}); + +describe('resolveOutputFileName', () => { + it('swaps the extension to match the output mime', () => { + expect(resolveOutputFileName('logo.gif', 'image/png')).toBe('logo.png'); + expect(resolveOutputFileName('photo.jpeg', 'image/jpeg')).toBe('photo.jpg'); + expect(resolveOutputFileName('shot.PNG', 'image/webp')).toBe('shot.webp'); + }); + + it('handles names without a usable extension', () => { + expect(resolveOutputFileName('photo', 'image/png')).toBe('photo.png'); + expect(resolveOutputFileName('photo.', 'image/png')).toBe('photo.png'); + expect(resolveOutputFileName('', 'image/png')).toBe('image.png'); + }); +}); diff --git a/tests/Browser/ImageCropperTest.php b/tests/Browser/ImageCropperTest.php index 6377707a..3de0b1e7 100644 --- a/tests/Browser/ImageCropperTest.php +++ b/tests/Browser/ImageCropperTest.php @@ -12,7 +12,7 @@ */ function selectPhoto(mixed $page): void { - $base64 = base64_encode((string) file_get_contents(base_path('tests/fixtures/blue-logo.png'))); + $base64 = base64_encode((string) file_get_contents(base_path('tests/fixtures/crop-quadrants.png'))); $page->script(<< { @@ -59,6 +59,7 @@ function recordUpload(mixed $page): void url: this.__url, keys: [...body.keys()], size: photo instanceof File ? photo.size : 0, + type: photo instanceof File ? photo.type : null, }; } return send.apply(this, arguments); @@ -87,7 +88,23 @@ function recordUpload(mixed $page): void return 'null'; } const bitmap = await createImageBitmap(window.__uploadFile); - return JSON.stringify({ ...window.__uploadRequest, width: bitmap.width, height: bitmap.height }); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext('2d'); + context.drawImage(bitmap, 0, 0); + const sample = (x, y) => Array.from(context.getImageData(x, y, 1, 1).data); + return JSON.stringify({ + ...window.__uploadRequest, + width: bitmap.width, + height: bitmap.height, + pixels: { + topLeft: sample(128, 128), + topRight: sample(384, 128), + bottomLeft: sample(128, 384), + bottomRight: sample(384, 384), + }, + }); })(); JS), true); @@ -96,6 +113,19 @@ function recordUpload(mixed $page): void ->and($request['url'])->toContain(route('app.profile.upload-photo', absolute: false)) ->and($request['keys'])->toContain('photo') ->and($request['size'])->toBeGreaterThan(0) + ->and($request['type'])->toBe('image/png') ->and($request['width'])->toBe(512) ->and($request['height'])->toBe(512); + + $isColour = function (array $pixel, array $rgb): bool { + return abs($pixel[0] - $rgb[0]) <= 24 + && abs($pixel[1] - $rgb[1]) <= 24 + && abs($pixel[2] - $rgb[2]) <= 24 + && $pixel[3] >= 250; + }; + + expect($isColour($request['pixels']['topLeft'], [255, 0, 0]))->toBeTrue() + ->and($isColour($request['pixels']['topRight'], [0, 255, 0]))->toBeTrue() + ->and($isColour($request['pixels']['bottomLeft'], [0, 0, 255]))->toBeTrue() + ->and($isColour($request['pixels']['bottomRight'], [255, 255, 0]))->toBeTrue(); }); diff --git a/tests/fixtures/crop-quadrants.png b/tests/fixtures/crop-quadrants.png new file mode 100644 index 00000000..6ead7a23 Binary files /dev/null and b/tests/fixtures/crop-quadrants.png differ diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..6742ea73 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath } from 'node:url'; + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./resources/js', import.meta.url)), + }, + }, + test: { + environment: 'node', + include: ['resources/js/**/*.{test,spec}.ts'], + }, +});