diff --git a/.ddev/commands/web/coverage b/.ddev/commands/web/coverage new file mode 100755 index 0000000..100adee --- /dev/null +++ b/.ddev/commands/web/coverage @@ -0,0 +1,6 @@ +#!/bin/bash +## Description: Run PHPUnit with text coverage summary (pcov) +## Usage: coverage +## Example: ddev coverage + +APP_ENV=test bin/phpunit --coverage-text diff --git a/.ddev/commands/web/frontend b/.ddev/commands/web/frontend new file mode 100755 index 0000000..31bce37 --- /dev/null +++ b/.ddev/commands/web/frontend @@ -0,0 +1,10 @@ +#!/bin/bash +## Description: Run vite dev server (HMR for the SPA) +## Usage: frontend +## Example: ddev frontend +## +## Connect via the URL DDEV prints once vite is up — usually +## https://pictureframe.ddev.site:. For a one-shot prod build +## (writes public/build/), run `ddev exec "cd frontend && npm run build"`. + +cd frontend && npm run dev -- --host 0.0.0.0 diff --git a/.ddev/commands/web/tests b/.ddev/commands/web/tests new file mode 100755 index 0000000..2df1617 --- /dev/null +++ b/.ddev/commands/web/tests @@ -0,0 +1,18 @@ +#!/bin/bash +## Description: Run the full test suite — PHPUnit + vitest + vue-tsc +## Usage: tests [phpunit-args] +## Example: ddev tests +## Example: ddev tests --filter test_id_tz_01 + +set -euo pipefail + +echo "── PHP unit + integration ─────────────────────────────" +# DDEV's web_environment sets APP_ENV=dev for dev work — phpunit's xml +# directive doesn't always override that cleanly, so force the env explicitly. +APP_ENV=test bin/phpunit "$@" + +echo +echo "── Frontend typecheck + tests ─────────────────────────" +cd frontend +npx vue-tsc --noEmit -p tsconfig.app.json +npx vitest run diff --git a/.ddev/commands/web/worker b/.ddev/commands/web/worker new file mode 100755 index 0000000..6e22048 --- /dev/null +++ b/.ddev/commands/web/worker @@ -0,0 +1,6 @@ +#!/bin/bash +## Description: Run the Symfony Messenger worker (foreground) +## Usage: worker +## Example: ddev worker + +php bin/console messenger:consume async image_processing scheduler_default --time-limit=0 -vv diff --git a/.ddev/config.yaml b/.ddev/config.yaml index 1d1bf8d..58bb628 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -3,13 +3,31 @@ type: symfony docroot: public php_version: "8.4" webserver_type: nginx-fpm +xdebug_enabled: false +additional_hostnames: [] +additional_fqdns: [] database: - type: postgres - version: "16" + type: postgres + version: "16" +use_dns_when_possible: true composer_version: "2" +nodejs_version: "22" +# imagick + pcov match the prod php image so dev/test parity is real. +# Installed via apt (deb.sury.org) — DDEV's web image doesn't ship pecl on PATH, +# so the extension-package route is more reliable across arch. webimage_extra_packages: - - php8.4-imagick - - php8.4-pcov + - php8.4-imagick + - php8.4-pcov +web_environment: + - APP_ENV=dev + # DDEV's postgres service is reachable as host `db` from the web container. + - DATABASE_URL=postgresql://db:db@db:5432/db?serverVersion=16&charset=utf8 + - MERCURE_URL=http://mercure/.well-known/mercure + - MERCURE_PUBLIC_URL=https://pictureframe.ddev.site/.well-known/mercure + - MERCURE_JWT_SECRET=!ChangeThisMercureHubJWTSecretKey! hooks: - post-start: - - exec: composer install --no-interaction 2>/dev/null || true + post-start: + # Keep dev deps in sync with the lock file, and warm node_modules so + # `ddev exec cd frontend && npm test` works on a fresh clone. + - exec: "composer install --no-interaction" + - exec: "[ -d frontend/node_modules ] || (cd frontend && npm install)" diff --git a/.ddev/docker-compose.mercure.yaml b/.ddev/docker-compose.mercure.yaml new file mode 100644 index 0000000..97b5eec --- /dev/null +++ b/.ddev/docker-compose.mercure.yaml @@ -0,0 +1,18 @@ +services: + mercure: + image: dunglas/mercure + restart: "no" + environment: + SERVER_NAME: ':80' + MERCURE_PUBLISHER_JWT_KEY: '${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}' + MERCURE_SUBSCRIBER_JWT_KEY: '${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}' + MERCURE_EXTRA_DIRECTIVES: | + cors_origins "*" + anonymous 1 + networks: + - ddev_default + +networks: + ddev_default: + external: true + name: ddev_default diff --git a/.ddev/traefik/config/mercure.yaml b/.ddev/traefik/config/mercure.yaml new file mode 100644 index 0000000..e84c409 --- /dev/null +++ b/.ddev/traefik/config/mercure.yaml @@ -0,0 +1,22 @@ +http: + routers: + pictureframe-mercure-http: + entrypoints: + - http-80 + rule: "HostRegexp(`^pictureframe\\.ddev\\.site$`) && PathPrefix(`/.well-known/mercure`)" + service: "pictureframe-mercure-80" + tls: false + priority: 100 + pictureframe-mercure-https: + entrypoints: + - http-443 + rule: "HostRegexp(`^pictureframe\\.ddev\\.site$`) && PathPrefix(`/.well-known/mercure`)" + service: "pictureframe-mercure-80" + tls: true + priority: 100 + + services: + pictureframe-mercure-80: + loadbalancer: + servers: + - url: http://ddev-pictureframe-mercure-1:80 diff --git a/.env.test b/.env.test index f75b7b8..17e5047 100644 --- a/.env.test +++ b/.env.test @@ -1,6 +1,9 @@ # define your env variables for the test env here KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st' +# Separate database from dev so DAMA's transaction-rollback isolation doesn't +# share state with whatever the dev environment is mid-experiment with. +DATABASE_URL=postgresql://db:db@db:5432/db_test?serverVersion=16&charset=utf8 MESSENGER_TRANSPORT_DSN=in-memory:// SHARE_TOKEN_TTL_DAYS=7 HARD_DELETE_TOKEN_TTL_DAYS=30 diff --git a/composer.json b/composer.json index 171e372..3919f80 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,7 @@ "symfony/string": "7.4.*", "symfony/translation": "7.4.*", "symfony/twig-bundle": "7.4.*", + "symfony/uid": "^7.4", "symfony/validator": "7.4.*", "symfony/web-link": "7.4.*", "symfony/yaml": "7.4.*", @@ -96,6 +97,8 @@ } }, "require-dev": { + "dama/doctrine-test-bundle": "^8.6", + "doctrine/doctrine-fixtures-bundle": "^4.3", "phpunit/phpunit": "^13.1", "symfony/browser-kit": "7.4.*", "symfony/css-selector": "7.4.*", diff --git a/composer.lock b/composer.lock index 130dfda..7353387 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "48a92a649bd9f4bce5a48eca76f90b96", + "content-hash": "7a1da17c4d61bf1d16c4f0e73ddd68ad", "packages": [ { "name": "doctrine/collections", @@ -5628,6 +5628,89 @@ ], "time": "2026-04-26T13:10:57+00:00" }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, { "name": "symfony/process", "version": "v7.4.8", @@ -7304,6 +7387,84 @@ ], "time": "2026-03-24T13:12:05+00:00" }, + { + "name": "symfony/uid", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2676b524340abcfe4d6151ec698463cebafee439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-30T15:19:22+00:00" + }, { "name": "symfony/validator", "version": "v7.4.8", @@ -7957,6 +8118,246 @@ } ], "packages-dev": [ + { + "name": "dama/doctrine-test-bundle", + "version": "v8.6.0", + "source": { + "type": "git", + "url": "https://github.com/dmaicher/doctrine-test-bundle.git", + "reference": "f7e3487643e685432f7e27c50cac64e9f8c515a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dmaicher/doctrine-test-bundle/zipball/f7e3487643e685432f7e27c50cac64e9f8c515a4", + "reference": "f7e3487643e685432f7e27c50cac64e9f8c515a4", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.3 || ^4.0", + "doctrine/doctrine-bundle": "^2.11.0 || ^3.0", + "php": ">= 8.2", + "psr/cache": "^2.0 || ^3.0", + "symfony/cache": "^6.4 || ^7.3 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.3 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<11.0" + }, + "require-dev": { + "behat/behat": "^3.0", + "friendsofphp/php-cs-fixer": "^3.27", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5.41|| ^12.3.14", + "symfony/dotenv": "^6.4 || ^7.3 || ^8.0", + "symfony/process": "^6.4 || ^7.3 || ^8.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "autoload": { + "psr-4": { + "DAMA\\DoctrineTestBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "David Maicher", + "email": "mail@dmaicher.de" + } + ], + "description": "Symfony bundle to isolate doctrine database tests and improve test performance", + "keywords": [ + "doctrine", + "isolation", + "performance", + "symfony", + "testing", + "tests" + ], + "support": { + "issues": "https://github.com/dmaicher/doctrine-test-bundle/issues", + "source": "https://github.com/dmaicher/doctrine-test-bundle/tree/v8.6.0" + }, + "time": "2026-01-21T07:39:44+00:00" + }, + { + "name": "doctrine/data-fixtures", + "version": "2.2.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/data-fixtures.git", + "reference": "bf7ac3a050b54b261cedfb3d0a44733819062275" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/bf7ac3a050b54b261cedfb3d0a44733819062275", + "reference": "bf7ac3a050b54b261cedfb3d0a44733819062275", + "shasum": "" + }, + "require": { + "doctrine/persistence": "^3.1 || ^4.0", + "php": "^8.1", + "psr/log": "^1.1 || ^2 || ^3" + }, + "conflict": { + "doctrine/dbal": "<3.5 || >=5", + "doctrine/orm": "<2.14 || >=4", + "doctrine/phpcr-odm": "<1.3.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "doctrine/dbal": "^3.5 || ^4", + "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0", + "doctrine/orm": "^2.14 || ^3", + "doctrine/phpcr-odm": "^1.8 || ^2.0", + "ext-sqlite3": "*", + "fig/log-test": "^1", + "jackalope/jackalope-fs": "*", + "phpstan/phpstan": "2.1.46", + "phpunit/phpunit": "10.5.63 || 12.5.12", + "symfony/cache": "^6.4 || ^7 || ^8", + "symfony/var-exporter": "^6.4 || ^7 || ^8" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)", + "doctrine/mongodb-odm": "For loading MongoDB ODM fixtures", + "doctrine/orm": "For loading ORM fixtures", + "doctrine/phpcr-odm": "For loading PHPCR ODM fixtures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\DataFixtures\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Data Fixtures for all Doctrine Object Managers", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "database" + ], + "support": { + "issues": "https://github.com/doctrine/data-fixtures/issues", + "source": "https://github.com/doctrine/data-fixtures/tree/2.2.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures", + "type": "tidelift" + } + ], + "time": "2026-04-01T13:56:01+00:00" + }, + { + "name": "doctrine/doctrine-fixtures-bundle", + "version": "4.3.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineFixturesBundle.git", + "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/9e013ed10d49bf7746b07204d336384a7d9b5a4d", + "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d", + "shasum": "" + }, + "require": { + "doctrine/data-fixtures": "^2.2", + "doctrine/doctrine-bundle": "^2.2 || ^3.0", + "doctrine/orm": "^2.14.0 || ^3.0", + "doctrine/persistence": "^2.4 || ^3.0 || ^4.0", + "php": "^8.1", + "psr/log": "^2 || ^3", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/doctrine-bridge": "^6.4.16 || ^7.1.9 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0" + }, + "conflict": { + "doctrine/dbal": "< 3" + }, + "require-dev": { + "doctrine/coding-standard": "14.0.0", + "phpstan/phpstan": "2.1.11", + "phpunit/phpunit": "^10.5.38 || 11.4.14" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\FixturesBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony DoctrineFixturesBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "Fixture", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues", + "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle", + "type": "tidelift" + } + ], + "time": "2025-12-03T16:05:42+00:00" + }, { "name": "masterminds/html5", "version": "2.10.0", diff --git a/config/reference.php b/config/reference.php index c294b90..ed74280 100644 --- a/config/reference.php +++ b/config/reference.php @@ -645,7 +645,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }>, * }, * uid?: bool|array{ // Uid configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * default_uuid_version?: 7|6|4|1|Param, // Default: 7 * name_based_uuid_version?: 5|3|Param, // Default: 5 * name_based_uuid_namespace?: scalar|Param|null, @@ -1452,6 +1452,33 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * generate_final_classes?: bool|Param, // Default: true * generate_final_entities?: bool|Param, // Default: false * } + * @psalm-type DamaDoctrineTestConfig = array{ + * enable_static_connection?: mixed, // Default: true + * enable_static_meta_data_cache?: bool|Param, // Default: true + * enable_static_query_cache?: bool|Param, // Default: true + * connection_keys?: list, + * } + * @psalm-type MercureConfig = array{ + * hubs?: array, + * subscribe?: list, + * secret?: scalar|Param|null, // The JWT Secret to use. + * passphrase?: scalar|Param|null, // The JWT secret passphrase. // Default: "" + * algorithm?: scalar|Param|null, // The algorithm to use to sign the JWT // Default: "hmac.sha256" + * }, + * jwt_provider?: scalar|Param|null, // Deprecated: The child node "jwt_provider" at path "mercure.hubs..jwt_provider" is deprecated, use "jwt.provider" instead. // The ID of a service to call to generate the JSON Web Token. + * bus?: scalar|Param|null, // Name of the Messenger bus where the handler for this hub must be registered. Default to the default bus if Messenger is enabled. + * }>, + * default_hub?: scalar|Param|null, + * default_cookie_lifetime?: int|Param, // Default lifetime of the cookie containing the JWT, in seconds. Defaults to the value of "framework.session.cookie_lifetime". // Default: null + * enable_profiler?: bool|Param, // Deprecated: The child node "enable_profiler" at path "mercure.enable_profiler" is deprecated. // Enable Symfony Web Profiler integration. + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1463,6 +1490,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * twig_extra?: TwigExtraConfig, * security?: SecurityConfig, * monolog?: MonologConfig, + * mercure?: MercureConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1477,6 +1505,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * security?: SecurityConfig, * monolog?: MonologConfig, * maker?: MakerConfig, + * mercure?: MercureConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -1489,6 +1518,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * twig_extra?: TwigExtraConfig, * security?: SecurityConfig, * monolog?: MonologConfig, + * mercure?: MercureConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -1502,6 +1532,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * twig_extra?: TwigExtraConfig, * security?: SecurityConfig, * monolog?: MonologConfig, + * dama_doctrine_test?: DamaDoctrineTestConfig, + * mercure?: MercureConfig, * }, * ...getId(); $image = $setup['image']; + $imageId = $image->getId(); $device->setLockedImage($image); $this->em()->flush(); @@ -154,10 +155,25 @@ class DeviceImageControllerTest extends AppWebTestCase $this->assertResponseStatusCodeSame(200); - // Re-fetch from DB; currentImage should still be null (advance() was never called) + // After the locked-image poll, currentImage now points at the locked + // image — the controller sets it directly because RotationService:: + // advance() was bypassed (lock takes precedence). This is what makes + // Home's preview reflect what the frame is actually showing. The + // "without rotation advance" guarantee is verified separately by + // checking that no DeviceImageHistory row was written. $this->em()->clear(); $device = $this->em()->find(\App\Entity\Device::class, $deviceId); - $this->assertNull($device->getCurrentImage()); + $this->assertNotNull($device->getCurrentImage()); + $this->assertSame($imageId, $device->getCurrentImage()->getId()); + + $historyCount = (int) $this->em()->createQueryBuilder() + ->select('COUNT(h.id)') + ->from(\App\Entity\DeviceImageHistory::class, 'h') + ->where('h.device = :d') + ->setParameter('d', $device) + ->getQuery() + ->getSingleScalarResult(); + $this->assertSame(0, $historyCount, 'lock path must NOT write a history row'); } public function test_returns_304_when_locked_image_matches_current_image_id(): void diff --git a/tests/Unit/Service/RotationServiceTest.php b/tests/Unit/Service/RotationServiceTest.php index e6fb68e..6aac081 100644 --- a/tests/Unit/Service/RotationServiceTest.php +++ b/tests/Unit/Service/RotationServiceTest.php @@ -480,7 +480,11 @@ class RotationServiceTest extends AppKernelTestCase } // RM-06: prioritizeNeverShown is a no-op when no never-shown images - // remain — falls through to the mode. + // remain — falls through to the mode. Uses a uniquenessWindow large + // enough that the recent-window filter wipes the candidate set and the + // fallback restores the full pool, otherwise window=1 would exclude the + // most-recently-served image and the mode-picks-from-2 assertion is + // testing the wrong axis. public function test_prioritize_never_shown_falls_through_when_all_shown(): void { [$device, $images] = $this->setupDeviceAndImages('prio-fall@example.com', [ @@ -488,10 +492,12 @@ class RotationServiceTest extends AppKernelTestCase ]); $device->setRotationMode(RotationMode::NewestUpload); $device->setPrioritizeNeverShown(true); - $device->setUniquenessWindow(1); + $device->setUniquenessWindow(2); self::em()->flush(); - // Both shown — never-shown set is empty, so mode (NewestUpload) takes over. + // Both shown — never-shown set is empty, so mode (NewestUpload) takes + // over. Window=2 wipes both via the recent filter and the fallback + // restores the full pool, so the mode genuinely chooses between both. $this->recordHistoryAt($device, $images[0], '2025-06-01'); $this->recordHistoryAt($device, $images[1], '2025-06-02');