From 2a8bf3895f0c6b6f45efaf3947af144031797486 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Fri, 8 May 2026 13:56:36 -0400 Subject: [PATCH] chore(dev): DDEV setup so the test suite actually runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors aqua-iq's pattern but adapted for pictureFrame's stack: postgres 16, php 8.4, node 22, imagick + pcov via apt extras, Mercure hub at https://pictureframe.ddev.site/.well-known/mercure, and four custom commands — `ddev tests`, `ddev coverage`, `ddev frontend` (vite HMR), `ddev worker`. Also restores dev deps (DAMA, Doctrine fixtures, symfony/uid) that got dropped during earlier composer reshuffles, and adds a separate `db_test` connection in .env.test so DAMA's transactional isolation doesn't share state with whatever dev is mid-experiment with. Two test fixes the new env exposed: - RotationServiceTest::test_prioritize_never_shown_falls_through_when_all_shown needed uniquenessWindow=2 so the recent-window filter wipes the set and the fallback restores the full pool — otherwise window=1 excluded the most-recently-served image and the assertion drifted. - DeviceImageControllerTest::test_locked_image_served_without_rotation_advance was asserting currentImage stays null on a lock poll, but the controller intentionally sets currentImage on the lock path so Home reflects the live frame state. Now asserts both the currentImage update AND that no DeviceImageHistory row was written (the actual rotation-bypass guarantee). Backend coverage (full suite via `ddev coverage`): 89.08% lines / 92.24% methods / 74.36% classes — the first real number we've had. Co-Authored-By: Claude Opus 4.7 (1M context) --- .ddev/commands/web/coverage | 6 + .ddev/commands/web/frontend | 10 + .ddev/commands/web/tests | 18 + .ddev/commands/web/worker | 6 + .ddev/config.yaml | 30 +- .ddev/docker-compose.mercure.yaml | 18 + .ddev/traefik/config/mercure.yaml | 22 + .env.test | 3 + composer.json | 3 + composer.lock | 403 +++++++++++++++++- config/reference.php | 34 +- symfony.lock | 30 ++ .../Controller/DeviceImageControllerTest.php | 20 +- tests/Unit/Service/RotationServiceTest.php | 12 +- 14 files changed, 602 insertions(+), 13 deletions(-) create mode 100755 .ddev/commands/web/coverage create mode 100755 .ddev/commands/web/frontend create mode 100755 .ddev/commands/web/tests create mode 100755 .ddev/commands/web/worker create mode 100644 .ddev/docker-compose.mercure.yaml create mode 100644 .ddev/traefik/config/mercure.yaml 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');