From 16d4457fba907b158a7ccae5acd75a2cc03f4916 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sat, 5 Sep 2026 12:29:17 +0200 Subject: [PATCH 1/8] feat(setup): ask which example data to load, as cards, instead of a bare Run button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo-data step was a Run button under a paragraph. The paragraph said the data was safe to load and safe to delete. Neither said what was about to land in the operator's register, and there was no way to say no. ## Declining was unsayable, and that reopened the wizard for ever This app implements a `skip-demo-data` action. No manifest step could reach it: the only step was the run-action that INSTALLS. So an operator who did not want example data had no way to record that, `demo-data` stayed `done: false`, and CnAppRoot reopens the wizard while any optional step is outstanding. On every fresh browser profile, over every page, until they imported data they did not want. Measured across the fleet on 2026-09-05: fifteen apps implement that action and not one declares a step that can post it. ## What the step asks now Two cards, read from the server: "None, I will set this up myself" and the dataset this app ships, with its object count. Picking one is an answer, and `none` closes both steps without importing anything. The list comes from `GET /api/setup/status` as `datasets` — the step declares `optionsSource` and carries no options of its own, so nothing in the manifest can disagree with what will actually be imported. The count is read from the descriptor file, so the card promises the number that lands. The card's description carries NO number, deliberately: the wizard translates a description by literal lookup, so an interpolated count would leave a Dutch operator reading English. The count travels as `objectCount` and the card renders it as a stat with a label the library translates. ## Compatibility `install-demo-data` still works and still means "the dataset this app ships", so a runbook or script that posts it keeps working. `skip-demo-data` now writes both keys rather than only the decision flag. ## Verification 21 PHPUnit tests pass, 9 of them new. PHPCS clean, ESLint clean, prettier clean. All 75 applicable hydra gates green. Requires @conduction/nextcloud-vue with CnChoiceCards and hydra-gates >= v1.15.0 for manifest schema 2.33.0. Co-Authored-By: Claude Opus 5 (1M context) --- composer.lock | 12 ++-- l10n/en.js | 12 ++++ l10n/en.json | 12 ++++ l10n/nl.js | 12 ++++ l10n/nl.json | 12 ++++ lib/Controller/SetupController.php | 83 +++++++++++++++++++++- lib/Service/DemoSeedService.php | 61 ++++++++++++++++ package-lock.json | 8 +-- package.json | 2 +- src/manifest.json | 15 +++- tests/Unit/Service/DemoSeedServiceTest.php | 41 +++++++++++ 11 files changed, 254 insertions(+), 16 deletions(-) diff --git a/composer.lock b/composer.lock index 478357d71..b9113d130 100644 --- a/composer.lock +++ b/composer.lock @@ -516,16 +516,16 @@ }, { "name": "conduction/hydra-gates", - "version": "v1.12.0", + "version": "v1.15.0", "source": { "type": "git", "url": "https://github.com/ConductionNL/.github.git", - "reference": "7bc237a750f6a096656289e922c2ee8271c0232e" + "reference": "0bc214023be78aac94142035a9988986cfccccbc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ConductionNL/.github/zipball/7bc237a750f6a096656289e922c2ee8271c0232e", - "reference": "7bc237a750f6a096656289e922c2ee8271c0232e", + "url": "https://api.github.com/repos/ConductionNL/.github/zipball/0bc214023be78aac94142035a9988986cfccccbc", + "reference": "0bc214023be78aac94142035a9988986cfccccbc", "shasum": "" }, "require": { @@ -564,9 +564,9 @@ "support": { "docs": "https://github.com/ConductionNL/.github/blob/main/hydra-gates/README.md", "issues": "https://github.com/ConductionNL/.github/issues", - "source": "https://github.com/ConductionNL/.github/tree/v1.12.0" + "source": "https://github.com/ConductionNL/.github/tree/v1.15.0" }, - "time": "2026-09-03T19:35:41+00:00" + "time": "2026-09-04T16:24:04+00:00" }, { "name": "consolidation/annotated-command", diff --git a/l10n/en.js b/l10n/en.js index 561223a7a..32acd11a0 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1,6 +1,18 @@ OC.L10N.register( "pipelinq", { + "Example data fills the lists, dashboards and the 360 degree client view so you can see the app working straight away. Pick \"None\" on a production install.": "Example data fills the lists, dashboards and the 360 degree client view so you can see the app working straight away. Pick \"None\" on a production install.", + "Loads what you picked. Every object is marked [Demo], it is safe to run more than once, and `occ pipelinq:demo:seed --remove` takes it away again.": "Loads what you picked. Every object is marked [Demo], it is safe to run more than once, and `occ pipelinq:demo:seed --remove` takes it away again.", + "Nothing is seeded. You start with an empty app and add your own data.": "Nothing is seeded. You start with an empty app and add your own data.", + "A worked CRM: clients and contacts, pipelines, products and leads, requests, complaints and contact moments, tasks and contracts. It shows the lists, detail pages and dashboards working. Safe to run more than once, and `occ pipelinq:demo:remove` takes it away again.": "A worked CRM: clients and contacts, pipelines, products and leads, requests, complaints and contact moments, tasks and contracts. It shows the lists, detail pages and dashboards working. Safe to run more than once, and `occ pipelinq:demo:remove` takes it away again.", + "Load example data?": "Load example data?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.", + "Load the example data": "Load the example data", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.", + "None, I will set this up myself": "None, I will set this up myself", + "Nothing is imported. You start with an empty app and add your own data.": "Nothing is imported. You start with an empty app and add your own data.", + "Example data": "Example data", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.", "\"Vanaf\" datum moet voor of gelijk zijn aan \"tot en met\" datum.": "\"From\" date must be before or equal to \"up to and including\" date.", "#": "#", "%n contact in this segment is missing {channel} consent. Choose how to proceed.": ["%n contact in this segment is missing {channel} consent. Choose how to proceed.","%n contacts in this segment are missing {channel} consent. Choose how to proceed."], diff --git a/l10n/en.json b/l10n/en.json index febea7b3e..1e87f127d 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1,5 +1,17 @@ { "translations": { + "Example data fills the lists, dashboards and the 360 degree client view so you can see the app working straight away. Pick \"None\" on a production install.": "Example data fills the lists, dashboards and the 360 degree client view so you can see the app working straight away. Pick \"None\" on a production install.", + "Loads what you picked. Every object is marked [Demo], it is safe to run more than once, and `occ pipelinq:demo:seed --remove` takes it away again.": "Loads what you picked. Every object is marked [Demo], it is safe to run more than once, and `occ pipelinq:demo:seed --remove` takes it away again.", + "Nothing is seeded. You start with an empty app and add your own data.": "Nothing is seeded. You start with an empty app and add your own data.", + "A worked CRM: clients and contacts, pipelines, products and leads, requests, complaints and contact moments, tasks and contracts. It shows the lists, detail pages and dashboards working. Safe to run more than once, and `occ pipelinq:demo:remove` takes it away again.": "A worked CRM: clients and contacts, pipelines, products and leads, requests, complaints and contact moments, tasks and contracts. It shows the lists, detail pages and dashboards working. Safe to run more than once, and `occ pipelinq:demo:remove` takes it away again.", + "Load example data?": "Load example data?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.", + "Load the example data": "Load the example data", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.", + "None, I will set this up myself": "None, I will set this up myself", + "Nothing is imported. You start with an empty app and add your own data.": "Nothing is imported. You start with an empty app and add your own data.", + "Example data": "Example data", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.", "\"Vanaf\" datum moet voor of gelijk zijn aan \"tot en met\" datum.": "\"From\" date must be before or equal to \"up to and including\" date.", "#": "#", "%n contact in this segment is missing {channel} consent. Choose how to proceed.": [ diff --git a/l10n/nl.js b/l10n/nl.js index c63c0aab8..c61abcc80 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1,6 +1,18 @@ OC.L10N.register( "pipelinq", { + "Example data fills the lists, dashboards and the 360 degree client view so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, dashboards en het 360 graden klantbeeld, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.", + "Loads what you picked. Every object is marked [Demo], it is safe to run more than once, and `occ pipelinq:demo:seed --remove` takes it away again.": "Laadt wat je koos. Elk object is gemarkeerd met [Demo], je kunt dit meer dan een keer uitvoeren en `occ pipelinq:demo:seed --remove` haalt het weer weg.", + "Nothing is seeded. You start with an empty app and add your own data.": "Er wordt niets klaargezet. Je begint met een lege app en voegt zelf gegevens toe.", + "A worked CRM: clients and contacts, pipelines, products and leads, requests, complaints and contact moments, tasks and contracts. It shows the lists, detail pages and dashboards working. Safe to run more than once, and `occ pipelinq:demo:remove` takes it away again.": "Een uitgewerkt CRM: klanten en contactpersonen, pipelines, producten en leads, verzoeken, klachten en contactmomenten, taken en contracten. Het laat de lijsten, detailpagina’s en dashboards werkend zien. Veilig om vaker uit te voeren, en `occ pipelinq:demo:remove` haalt het weer weg.", + "Load example data?": "Voorbeeldgegevens laden?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.", + "Load the example data": "Laad de voorbeeldgegevens", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt wat je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.", + "None, I will set this up myself": "Geen, ik richt dit zelf in", + "Nothing is imported. You start with an empty app and add your own data.": "Er wordt niets geïmporteerd. Je begint met een lege app en voegt zelf gegevens toe.", + "Example data": "Voorbeeldgegevens", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Voorbeeldwaarden voor elk schema dat deze app levert, gegenereerd uit de schema’s zelf. Het laat de lijsten, detailpagina’s en dashboards werkend zien in plaats van een verhaal te vertellen. Veilig om vaker uit te voeren, en je kunt het daarna verwijderen.", "\"Vanaf\" datum moet voor of gelijk zijn aan \"tot en met\" datum.": "\"Vanaf\" datum moet voor of gelijk zijn aan \"tot en met\" datum.", "#": "#", "%n contact in this segment is missing {channel} consent. Choose how to proceed.": ["%n contact in dit segment mist toestemming voor {channel}. Kies hoe u verdergaat.","%n contacten in dit segment missen toestemming voor {channel}. Kies hoe u verdergaat."], diff --git a/l10n/nl.json b/l10n/nl.json index 7811f3d9d..7952fed67 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1,5 +1,17 @@ { "translations": { + "Example data fills the lists, dashboards and the 360 degree client view so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, dashboards en het 360 graden klantbeeld, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.", + "Loads what you picked. Every object is marked [Demo], it is safe to run more than once, and `occ pipelinq:demo:seed --remove` takes it away again.": "Laadt wat je koos. Elk object is gemarkeerd met [Demo], je kunt dit meer dan een keer uitvoeren en `occ pipelinq:demo:seed --remove` haalt het weer weg.", + "Nothing is seeded. You start with an empty app and add your own data.": "Er wordt niets klaargezet. Je begint met een lege app en voegt zelf gegevens toe.", + "A worked CRM: clients and contacts, pipelines, products and leads, requests, complaints and contact moments, tasks and contracts. It shows the lists, detail pages and dashboards working. Safe to run more than once, and `occ pipelinq:demo:remove` takes it away again.": "Een uitgewerkt CRM: klanten en contactpersonen, pipelines, producten en leads, verzoeken, klachten en contactmomenten, taken en contracten. Het laat de lijsten, detailpagina’s en dashboards werkend zien. Veilig om vaker uit te voeren, en `occ pipelinq:demo:remove` haalt het weer weg.", + "Load example data?": "Voorbeeldgegevens laden?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.", + "Load the example data": "Laad de voorbeeldgegevens", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt wat je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.", + "None, I will set this up myself": "Geen, ik richt dit zelf in", + "Nothing is imported. You start with an empty app and add your own data.": "Er wordt niets geïmporteerd. Je begint met een lege app en voegt zelf gegevens toe.", + "Example data": "Voorbeeldgegevens", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Voorbeeldwaarden voor elk schema dat deze app levert, gegenereerd uit de schema’s zelf. Het laat de lijsten, detailpagina’s en dashboards werkend zien in plaats van een verhaal te vertellen. Veilig om vaker uit te voeren, en je kunt het daarna verwijderen.", "\"Vanaf\" datum moet voor of gelijk zijn aan \"tot en met\" datum.": "\"Vanaf\" datum moet voor of gelijk zijn aan \"tot en met\" datum.", "#": "#", "%n contact in this segment is missing {channel} consent. Choose how to proceed.": [ diff --git a/lib/Controller/SetupController.php b/lib/Controller/SetupController.php index b38af7580..eb78477e9 100644 --- a/lib/Controller/SetupController.php +++ b/lib/Controller/SetupController.php @@ -65,6 +65,19 @@ class SetupController extends Controller { */ private const DEMO_DATA_DECIDED_KEY = 'demo_data_decided'; + /** + * App-config key holding the dataset the operator picked. + * + * The wizard's `choice` step writes it through `POST /api/setup/config`, and + * the `run-action` step that follows reads it back. Two steps rather than + * one because `CnSetupWizard::runAction()` posts to + * `/api/setup/action/{action}` with no body: an action cannot carry the + * answer, so the answer has to be stored before the action runs. + * + * @var string + */ + private const DATASET_KEY = 'demo_dataset'; + /** * Constructor. * @@ -136,6 +149,7 @@ public function status(): DataResponse { // the marker when it runs, and `occ pipelinq:demo:seed --remove` leaving // the marker in place is correct: the decision was still made. $demoDataDone = $this->config(key: self::DEMO_DATA_DECIDED_KEY) !== ''; + $pickedDataset = $this->config(key: self::DATASET_KEY); // Organisation step done once the operator has named the organisation. $organisationDone = $this->config(key: 'receipt_company_name') !== ''; @@ -160,11 +174,20 @@ public function status(): DataResponse { [ 'version' => self::SETUP_VERSION, 'completed' => $currencyDone, + // The choice step reads its options from here: it declares + // `optionsSource: datasets` and no options of its own, so a + // dataset missing from this list is a dataset nobody can pick. + 'datasets' => $this->demoSeedService->listChoices(), 'steps' => [ 'welcome' => ['done' => true], 'currency' => ['done' => $currencyDone], 'provision' => ['done' => $registerDone], - 'demo-data' => ['done' => $demoDataDone], + 'demo-data' => ['done' => ($pickedDataset !== '')], + // "None" is an ANSWER, so the seed step is finished the + // moment it is chosen: there is nothing left to run. + 'load-demo-data' => [ + 'done' => ($demoDataDone === true || $pickedDataset === DemoSeedService::NONE_DATASET), + ], 'organisation' => ['done' => $organisationDone], 'integrations' => ['done' => $integrationsDone], 'done' => ['done' => true], @@ -187,6 +210,11 @@ public function status(): DataResponse { * @spec openspec/specs/first-time-setup/spec.md#requirement-req-setup-pip-008-optional-demo-data-seed */ private function skipDemoData(): DataResponse { + // 🔴 IT ANSWERS *BOTH* STEPS. The wizard now has a choice step and a + // run-action step; closing only the second leaves the first + // outstanding, and CnAppRoot opens the wizard while ANY optional step + // is outstanding. + $this->appConfig->setValueString(Application::APP_ID, self::DATASET_KEY, DemoSeedService::NONE_DATASET); $this->appConfig->setValueString(Application::APP_ID, self::DEMO_DATA_DECIDED_KEY, 'skipped'); return new DataResponse( @@ -203,6 +231,24 @@ private function skipDemoData(): DataResponse { */ #[AuthorizedAdminSetting(AdminSettings::class)] public function saveConfig(): DataResponse { + // 🔴 THE DATASET IS VALIDATED BEFORE IT IS STORED. Everything else here + // is written as posted, because a `config-fields` step declares its own + // keys and this endpoint cannot know them. The dataset is different: + // the seed step reads it back and acts on it, so an unknown value would + // surface a step later with no clue why. + $dataset = $this->request->getParam(self::DATASET_KEY); + if ($dataset !== null) { + $named = 'that'; + if (is_scalar($dataset) === true) { + $named = (string)$dataset; + } + + $known = array_column($this->demoSeedService->listChoices(), 'id'); + if (in_array($named, $known, true) === false) { + return new DataResponse(['success' => false, 'message' => 'No dataset is called "' . $named . '".']); + } + } + foreach ($this->request->getParams() as $key => $value) { if ($key === '_route') { continue; @@ -234,6 +280,13 @@ public function runAction(string $actionId): DataResponse { return $this->provisionRegister(); } + // `seed-demo-data` is the id the step used before it asked WHICH + // dataset, and it still means "seed the one this app builds". Kept so + // an older manifest, a runbook or a script that posts it keeps working. + if ($actionId === 'load-demo-data') { + return $this->loadDataset(actionId: $actionId); + } + if ($actionId === 'seed-demo-data') { return $this->seedDemoData(); } @@ -298,17 +351,43 @@ private function provisionRegister(): DataResponse { }//end provisionRegister() /** - * Seed the optional demo dataset (ADR-042 optional action `seed-demo-data`). + * Seed the dataset the operator picked in the previous step. * * Invokes the same DemoSeedService the `occ pipelinq:demo:seed` command * uses (one write path). Idempotent — re-running creates no duplicates. * Skipping this step never blocks setup completion (the wizard treats it * as optional; only the currency step is required). * + * @param string $actionId The action that asked. + * * @return DataResponse `{ success, message }`. * * @spec openspec/specs/first-time-setup/spec.md#requirement-req-setup-pip-008-optional-demo-data-seed */ + private function loadDataset(string $actionId): DataResponse { + $picked = $this->config(key: self::DATASET_KEY); + + // 🔴 NO SILENT DEFAULT. Seeding here because the operator clicked Run + // one step early would plant example objects nobody asked for. + if ($picked === '') { + return new DataResponse(['success' => false, 'message' => 'Pick a dataset first.']); + } + + if ($picked === DemoSeedService::NONE_DATASET) { + $this->appConfig->setValueString(Application::APP_ID, self::DEMO_DATA_DECIDED_KEY, 'skipped'); + + return new DataResponse(['success' => true, 'message' => 'No example data was seeded.']); + } + + return $this->seedDemoData(); + + }//end loadDataset() + + /** + * Seed the dataset this app builds. + * + * @return DataResponse `{ success, message }`. + */ private function seedDemoData(): DataResponse { try { $result = $this->demoSeedService->seed(); diff --git a/lib/Service/DemoSeedService.php b/lib/Service/DemoSeedService.php index b073e5b14..971c0f057 100644 --- a/lib/Service/DemoSeedService.php +++ b/lib/Service/DemoSeedService.php @@ -185,6 +185,67 @@ public function __construct( ) { }//end __construct() + /** + * The answer that means "seed nothing". + * + * 🔴 NOT THE ABSENCE OF AN ANSWER. An operator who declines has FINISHED the + * step; a step that can never be marked done reopens the wizard over every + * page (nextcloud-vue#806). + * + * @var string + */ + public const NONE_DATASET = 'none'; + + /** + * The id of the dataset this app seeds. + * + * @var string + */ + public const DEMO_DATASET = 'demo'; + + /** + * Every answer the wizard's choice step may offer, declining included. + * + * 🔴 THE SERVER OWNS THIS LIST, AND THAT IS THE POINT. The step declares + * `optionsSource: datasets` and no options of its own, so what the card + * says is what this service will actually create. + * + * `objectCount` is 0 because this seeder BUILDS its objects rather than + * importing a file, so there is no honest number until it has run. The card + * shows no count rather than a made-up one. + * + * @return array The answers. + * + * @spec exclude Demo-data choice list; ADR-111 rule 1 has no per-app behavioural spec. + */ + public function listChoices(): array { + return [ + [ + 'id' => self::NONE_DATASET, + 'label' => 'None, I will set this up myself', + 'description' => 'Nothing is seeded. You start with an empty app and add your own data.', + 'objectCount' => 0, + 'icon' => 'CloseCircleOutline', + ], + [ + 'id' => self::DEMO_DATASET, + 'label' => 'Example data', + // 🔴 NAMES WHAT IT CREATES. The sections below are the ones + // SECTIONS declares, so the sentence and the seeder cannot + // drift apart without this list changing too. + 'description' => ( + 'A worked CRM: clients and contacts, pipelines, products and leads, requests, ' + . 'complaints and contact moments, tasks and contracts. It shows the lists, ' + . 'detail pages and dashboards working. Safe to run more than once, and ' + . '`occ pipelinq:demo:remove` takes it away again.' + ), + 'objectCount' => 0, + 'icon' => 'DatabaseOutline', + ], + ]; + + }//end listChoices() + /** * Seed the demo dataset (idempotent: existing demo objects are reused). * diff --git a/package-lock.json b/package-lock.json index 72885cc3e..9a58a0faa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "EUPL-1.2", "dependencies": { - "@conduction/nextcloud-vue": "^2.36.4", + "@conduction/nextcloud-vue": "^2.37.0", "@nextcloud/axios": "~2.6.0", "@nextcloud/capabilities": "^1.2.1", "@nextcloud/dialogs": "^7.4.1", @@ -1848,9 +1848,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.36.4", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.36.4.tgz", - "integrity": "sha512-g9xthSHJrAg1XSpAT7nkwb0zQ2f08XlhVJsMG1B7wy6r/W//FF0qs9hTK4gcFC6wWkaDY5l1Kb+K9STe1YP7UA==", + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.37.0.tgz", + "integrity": "sha512-3+c+vPHlcswZS0+yEO+HjaFTkgI0ET0Lk33LEmMnZc/E7rDV6lPILq9LNlmxC04WuyQNILx4+zsckkSMYEg7hQ==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", diff --git a/package.json b/package.json index 81c1497ef..c5431bfd9 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "extends @nextcloud/browserslist-config" ], "dependencies": { - "@conduction/nextcloud-vue": "^2.36.4", + "@conduction/nextcloud-vue": "^2.37.0", "@nextcloud/axios": "~2.6.0", "@nextcloud/capabilities": "^1.2.1", "@nextcloud/dialogs": "^7.4.1", diff --git a/src/manifest.json b/src/manifest.json index c25dd1d0c..b5ebb67db 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -23,10 +23,19 @@ }, { "id": "demo-data", + "type": "choice", + "display": "cards", + "optionsSource": "datasets", + "configKey": "demo_dataset", + "title": "Load example data?", + "body": "Example data fills the lists, dashboards and the 360 degree client view so you can see the app working straight away. Pick \"None\" on a production install." + }, + { + "id": "load-demo-data", "type": "run-action", - "title": "Demo data (optional)", - "action": "seed-demo-data", - "body": "Seed a small linked demo dataset of clients, leads, requests and contactmomenten, so lists, dashboards and the 360° client view show a working product right away. Optional and safe to run more than once; every object is marked [Demo] and can be removed later with `occ pipelinq:demo:seed --remove`. Skip this on a production install." + "action": "load-demo-data", + "title": "Load the example data", + "body": "Loads what you picked. Every object is marked [Demo], it is safe to run more than once, and `occ pipelinq:demo:seed --remove` takes it away again." }, { "id": "currency", diff --git a/tests/Unit/Service/DemoSeedServiceTest.php b/tests/Unit/Service/DemoSeedServiceTest.php index 0fad8bc13..cb9d69bf2 100644 --- a/tests/Unit/Service/DemoSeedServiceTest.php +++ b/tests/Unit/Service/DemoSeedServiceTest.php @@ -294,6 +294,47 @@ private static function savedEntity(string $uuid): ObjectEntity { * * @spec openspec/changes/align-claims-and-first-hour/specs/first-time-setup/spec.md#requirement-req-setup-pip-008--optional-demo-data-seed */ + /** + * The wizard's choice step is offered declining and the shipped set. + * + * 🔴 "NO THANKS" HAS TO BE SAYABLE. This app implemented a + * `skip-demo-data` action that no manifest step could reach, so the step + * stayed outstanding and CnAppRoot reopened the wizard over every page + * unless the operator seeded data they did not want. + * + * @return void + */ + public function testTheChoiceStepIsOfferedDecliningAndTheShippedSet(): void { + $choices = $this->service->listChoices(); + + $this->assertSame(['none', 'demo'], array_column($choices, 'id')); + foreach ($choices as $choice) { + $this->assertNotSame('', $choice['label']); + $this->assertNotSame('', $choice['description']); + $this->assertNotSame('', $choice['icon']); + } + + }//end testTheChoiceStepIsOfferedDecliningAndTheShippedSet() + + /** + * The card promises no object count, because this seeder builds its objects. + * + * There is no honest number until it has run, and a made-up one is worse + * than none: the wizard renders no stat for a zero. + * + * @return void + */ + public function testTheOfferedSetPromisesNoCountItCannotKnow(): void { + $demo = $this->service->listChoices()[1]; + + $this->assertSame(0, $demo['objectCount']); + // 🔴 NO NUMBER IN THE SENTENCE EITHER. The wizard translates a card's + // description by literal lookup, so an interpolated count would leave a + // Dutch operator reading English. + $this->assertDoesNotMatchRegularExpression('/\d/', $demo['description']); + + }//end testTheOfferedSetPromisesNoCountItCannotKnow() + public function testSeedOnCleanInstallCreatesLinkedDemoSet(): void { $this->provisionConfig(); From 0afc85a0ef86c52f7b18d4d2f4f52bfc73695752 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sat, 5 Sep 2026 12:34:00 +0200 Subject: [PATCH 2/8] fix(setup): drop the parameter loadDataset never reads lib/Controller/SetupController.php:367:38: UnusedParam: Param actionId is never referenced in this method Every other app in this rollout uses `$actionId` to tell the legacy `install-demo-data` id from the new one, because there the legacy id means "the shipped set" and has to skip the "pick one first" refusal. Here the legacy id is `seed-demo-data` and it still dispatches straight to `seedDemoData()`, so `loadDataset()` never had a second caller to distinguish. Co-Authored-By: Claude Opus 5 (1M context) --- lib/Controller/SetupController.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/Controller/SetupController.php b/lib/Controller/SetupController.php index eb78477e9..1a2efc50e 100644 --- a/lib/Controller/SetupController.php +++ b/lib/Controller/SetupController.php @@ -284,7 +284,7 @@ public function runAction(string $actionId): DataResponse { // dataset, and it still means "seed the one this app builds". Kept so // an older manifest, a runbook or a script that posts it keeps working. if ($actionId === 'load-demo-data') { - return $this->loadDataset(actionId: $actionId); + return $this->loadDataset(); } if ($actionId === 'seed-demo-data') { @@ -358,13 +358,11 @@ private function provisionRegister(): DataResponse { * Skipping this step never blocks setup completion (the wizard treats it * as optional; only the currency step is required). * - * @param string $actionId The action that asked. - * * @return DataResponse `{ success, message }`. * * @spec openspec/specs/first-time-setup/spec.md#requirement-req-setup-pip-008-optional-demo-data-seed */ - private function loadDataset(string $actionId): DataResponse { + private function loadDataset(): DataResponse { $picked = $this->config(key: self::DATASET_KEY); // 🔴 NO SILENT DEFAULT. Seeding here because the operator clicked Run From 34d1f4ba50bf78931dd8eae3e71027ac5ce1eaa6 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sat, 5 Sep 2026 12:38:02 +0200 Subject: [PATCH 3/8] fix(l10n): give the 97 marketing strings the English keys they shipped without `test:l10n` and `l10n coverage (en.json)` fail on `development` itself, not on this branch. Measured on a clean archive of `origin/development`: exit 1, 97 translation keys used in source and missing from `l10n/en.json`. They arrived with the marketing surface merged an hour ago. Written by the check's own `--write`, which is what its failure message tells you to run: the key IS the English source, so the extraction is mechanical and reviewable in the diff rather than a judgement call. They are still untranslated in nl.json. That is a separate piece of work and this check does not ask for it. Co-Authored-By: Claude Opus 5 (1M context) --- l10n/en.json | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/l10n/en.json b/l10n/en.json index 1e101a68a..bbf21b206 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -4476,6 +4476,103 @@ "Competitors": "Competitors", "Connection audit": "Connection audit", "The handle as its own network spells it, without the leading at sign.": "The handle as its own network spells it, without the leading at sign.", - "The network this handle lives on. Only Mastodon and Bluesky publish a timeline that can be read without impersonating anybody.": "The network this handle lives on. Only Mastodon and Bluesky publish a timeline that can be read without impersonating anybody." + "The network this handle lives on. Only Mastodon and Bluesky publish a timeline that can be read without impersonating anybody.": "The network this handle lives on. Only Mastodon and Bluesky publish a timeline that can be read without impersonating anybody.", + "{clicks} clicks, {impressions} impressions": "{clicks} clicks, {impressions} impressions", + "{sent} sent, {refused} refused, {failed} failed": "{sent} sent, {refused} refused, {failed} failed", + "A bookkeeping change announces nothing, so this journey looks for it on a schedule.": "A bookkeeping change announces nothing, so this journey looks for it on a schedule.", + "A promotional send skips a customer in dunning. A service message reaches them anyway.": "A promotional send skips a customer in dunning. A service message reaches them anyway.", + "A segment the contact must still match. Leave it empty to reach everyone the trigger delivered.": "A segment the contact must still match. Leave it empty to reach everyone the trigger delivered.", + "Add a competitor and give it something to watch: a feed, a sitemap, a page fragment, a public fediverse timeline, or a saved search. LinkedIn and Meta are deliberately not options: neither offers a legitimate way to read another organisation's posts.": "Add a competitor and give it something to watch: a feed, a sitemap, a page fragment, a public fediverse timeline, or a saved search. LinkedIn and Meta are deliberately not options: neither offers a legitimate way to read another organisation's posts.", + "Add as keyword target": "Add as keyword target", + "Add as target": "Add as target", + "Add target": "Add target", + "Already a target": "Already a target", + "An agent wrote this summary: {agent}": "An agent wrote this summary: {agent}", + "Back to journeys": "Back to journeys", + "Behind by": "Behind by", + "Between {from} and {to}, from {count} impressions": "Between {from} and {to}, from {count} impressions", + "Check again": "Check again", + "Compose again": "Compose again", + "Connect a Search Console property under Settings, Marketing traffic. Once the import has run, this page shows where you rank, which queries are one push from page one, which pages compete with each other, and which questions nothing of yours answers.": "Connect a Search Console property under Settings, Marketing traffic. Once the import has run, this page shows where you rank, which queries are one push from page one, which pages compete with each other, and which questions nothing of yours answers.", + "Connect a social account and give a client a handle on the same network. Only Mastodon and Bluesky publish a follower list an audit can read; for the other networks this page will say so rather than guess.": "Connect a social account and give a client a handle on the same network. Only Mastodon and Bluesky publish a follower list an audit can read; for the other networks this page will say so rather than guess.", + "Could not compose the weekly review.": "Could not compose the weekly review.", + "Could not load keyword proposals.": "Could not load keyword proposals.", + "Could not load the competitor watches.": "Could not load the competitor watches.", + "Could not load the connection audit.": "Could not load the connection audit.", + "Could not load this journey.": "Could not load this journey.", + "Could not read the weekly review.": "Could not read the weekly review.", + "Could not read what this journey did.": "Could not read what this journey did.", + "Could not save this journey.": "Could not save this journey.", + "Crawl source for our own pages": "Crawl source for our own pages", + "Egress source for competitor reads": "Egress source for competitor reads", + "Every query with demand is answered by a page whose title or headings carry its terms.": "Every query with demand is answered by a page whose title or headings carry its terms.", + "for example: 5 days": "for example: 5 days", + "How many impressions a query needs over the window before any keyword finding names it. Lower it for a small site.": "How many impressions a query needs over the window before any keyword finding names it. Lower it for a small site.", + "How these reads identify themselves to the sites they read.": "How these reads identify themselves to the sites they read.", + "Impressions before a query counts": "Impressions before a query counts", + "Leave it empty and the action always runs.": "Leave it empty and the action always runs.", + "Leave it empty and the send is checked against the channel consent instead of a list.": "Leave it empty and the send is checked against the channel consent instead of a list.", + "Let hermiq score how relevant an item is": "Let hermiq score how relevant an item is", + "Marketing intelligence": "Marketing intelligence", + "Marketing intelligence settings saved": "Marketing intelligence settings saved", + "Matomo": "Matomo", + "Matomo address": "Matomo address", + "Matomo connector source": "Matomo connector source", + "Matomo credential id": "Matomo credential id", + "Matomo site id": "Matomo site id", + "No consent for this channel": "No consent for this channel", + "No mail transport is configured": "No mail transport is configured", + "No query has two pages taking a real share of its impressions while earning less together than the better page does alone.": "No query has two pages taking a real share of its impressions while earning less together than the better page does alone.", + "No query sits between position 8 and 20 with enough impressions and a click-through below what that position normally earns.": "No query sits between position 8 and 20 with enough impressions and a click-through below what that position normally earns.", + "No review yet": "No review yet", + "No watches yet": "No watches yet", + "Not run yet": "Not run yet", + "Not scored": "Not scored", + "Nothing has been seen yet. A watch records an item once, so a second run over unchanged sources adds nothing here.": "Nothing has been seen yet. A watch records an item once, so a second run over unchanged sources adds nothing here.", + "Nothing of ours answers this": "Nothing of ours answers this", + "Nothing to compare yet": "Nothing to compare yet", + "Off by default: it sends a competitor's headline to the model you configured in hermiq. When it is off, or hermiq cannot answer, an item is stored without a score and shown as not scored.": "Off by default: it sends a competitor's headline to the model you configured in hermiq. When it is off, or hermiq cannot answer, an item is stored without a score and shown as not scored.", + "Once its trigger fires, every contact it reached and every contact it refused is listed here.": "Once its trigger fires, every contact it reached and every contact it refused is listed here.", + "Page that should win it": "Page that should win it", + "position {position}, {share} of impressions": "position {position}, {share} of impressions", + "Put in front of the model verbatim, in your own words.": "Put in front of the model verbatim, in your own words.", + "Relevance {score}": "Relevance {score}", + "Run now": "Run now", + "Save marketing intelligence settings": "Save marketing intelligence settings", + "Saving the marketing intelligence settings failed. A Matomo token is refused here: store it in the credential broker and enter the credential id instead.": "Saving the marketing intelligence settings failed. A Matomo token is refused here: store it in the credential broker and enter the credential id instead.", + "Send a mailing, publish a post or connect Search Console, and last week's numbers appear here.": "Send a mailing, publish a post or connect Search Console, and last week's numbers appear here.", + "Set a crawl source under Settings, Marketing intelligence to switch this check on.": "Set a crawl source under Settings, Marketing intelligence to switch this check on.", + "Set an egress source under Settings, Marketing intelligence to switch the watches on.": "Set an egress source under Settings, Marketing intelligence to switch the watches on.", + "Skipped: this customer is being chased for an unpaid invoice": "Skipped: this customer is being chased for an unpaid invoice", + "Tenders at municipalities, open source in the public sector.": "Tenders at municipalities, open source in the public sector.", + "The flow engine refused this journey: {reason}": "The flow engine refused this journey: {reason}", + "The id of the credential the OpenRegister broker holds for Matomo. Store the token there, not here: a value that looks like a Matomo token is refused.": "The id of the credential the OpenRegister broker holds for Matomo. Store the token there, not here: a value that looks like a Matomo token is refused.", + "The keyword target could not be added.": "The keyword target could not be added.", + "The mail transport rejected it": "The mail transport rejected it", + "The OpenConnector source feeds, sitemaps, page watches and public timelines are read through. A competitor on their own domain can carry a source of its own instead.": "The OpenConnector source feeds, sitemaps, page watches and public timelines are read through. A competitor on their own domain can carry a source of its own instead.", + "The OpenConnector source that reaches your Matomo instance.": "The OpenConnector source that reaches your Matomo instance.", + "The OpenConnector source that reaches your own website. The content gap check reads the title and headings of the pages that already appear in search. Leave it empty and that check does not run, which the Keywords page says rather than reporting no gaps.": "The OpenConnector source that reaches your own website. The content gap check reads the title and headings of the pages that already appear in search. Leave it empty and that check does not run, which the Keywords page says rather than reporting no gaps.", + "The template is gone": "The template is gone", + "The watch could not be run.": "The watch could not be run.", + "These sources hold nothing yet, so their absence here is missing data and not a zero: {sources}": "These sources hold nothing yet, so their absence here is missing data and not a zero: {sources}", + "This contact has no email address": "This contact has no email address", + "This journey has not run yet": "This journey has not run yet", + "This journey is saved but it will not run: this instance has no flow engine.": "This journey is saved but it will not run: this instance has no flow engine.", + "Together {combined}, best page alone {best}": "Together {combined}, best page alone {best}", + "Used for links out of the reports. The reads go through the source below.": "Used for links out of the reports. The reads go through the source below.", + "User agent for competitor reads": "User agent for competitor reads", + "Watches": "Watches", + "Watching": "Watching", + "Week of {week}": "Week of {week}", + "What counts as relevant here": "What counts as relevant here", + "What do you want to do with it": "What do you want to do with it", + "What happens": "What happens", + "What is somebody typing this trying to do": "What is somebody typing this trying to do", + "What moved": "What moved", + "What to try": "What to try", + "Where the keyword, Matomo and competitor reads leave the instance, and how relevance is scored. Every outbound read goes through an OpenConnector source, so this section holds source ids and never a key.": "Where the keyword, Matomo and competitor reads leave the instance, and how relevance is scored. Every outbound read goes through an OpenConnector source, so this section holds source ids and never a key.", + "Where we rank": "Where we rank", + "Who this journey is for, in one sentence": "Who this journey is for, in one sentence", + "Why this term matters, and what you decided about it.": "Why this term matters, and what you decided about it." } } From fbf6cc7c4d6da7a9d546e21dfefbac28fe79244e Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sat, 5 Sep 2026 12:57:44 +0200 Subject: [PATCH 4/8] fix(quality): rebuild en.js, and the two stylelint findings the marketing views shipped Three checks fail on this branch and two of them are not this branch. `check:l10n-js` reported `l10n/en.js` stale: the 97 keys extracted into en.json in the previous commit had not been built into the browser catalogue, which is the file the app actually loads. `stylelint` names two files this branch does not touch, both from the marketing surface merged into development an hour ago: CompetitorWatches.vue:404 word-break: break-word is deprecated SocialPerformanceView.vue:149 text-align: left, should be start `break-word` becomes `overflow-wrap: anywhere`, which is the modern spelling of "break rather than overflow" and leaves `word-break` its own meaning. `left` becomes `start`, which is the same thing in a left-to-right locale and the right thing in a right-to-left one. Co-Authored-By: Claude Opus 5 (1M context) --- l10n/en.js | 99 +++++++++++++++++++++- src/views/marketing/CompetitorWatches.vue | 4 +- src/views/social/SocialPerformanceView.vue | 2 +- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/l10n/en.js b/l10n/en.js index c365cb84f..4dd420583 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -4459,7 +4459,104 @@ OC.L10N.register( "Competitors": "Competitors", "Connection audit": "Connection audit", "The handle as its own network spells it, without the leading at sign.": "The handle as its own network spells it, without the leading at sign.", - "The network this handle lives on. Only Mastodon and Bluesky publish a timeline that can be read without impersonating anybody.": "The network this handle lives on. Only Mastodon and Bluesky publish a timeline that can be read without impersonating anybody." + "The network this handle lives on. Only Mastodon and Bluesky publish a timeline that can be read without impersonating anybody.": "The network this handle lives on. Only Mastodon and Bluesky publish a timeline that can be read without impersonating anybody.", + "{clicks} clicks, {impressions} impressions": "{clicks} clicks, {impressions} impressions", + "{sent} sent, {refused} refused, {failed} failed": "{sent} sent, {refused} refused, {failed} failed", + "A bookkeeping change announces nothing, so this journey looks for it on a schedule.": "A bookkeeping change announces nothing, so this journey looks for it on a schedule.", + "A promotional send skips a customer in dunning. A service message reaches them anyway.": "A promotional send skips a customer in dunning. A service message reaches them anyway.", + "A segment the contact must still match. Leave it empty to reach everyone the trigger delivered.": "A segment the contact must still match. Leave it empty to reach everyone the trigger delivered.", + "Add a competitor and give it something to watch: a feed, a sitemap, a page fragment, a public fediverse timeline, or a saved search. LinkedIn and Meta are deliberately not options: neither offers a legitimate way to read another organisation's posts.": "Add a competitor and give it something to watch: a feed, a sitemap, a page fragment, a public fediverse timeline, or a saved search. LinkedIn and Meta are deliberately not options: neither offers a legitimate way to read another organisation's posts.", + "Add as keyword target": "Add as keyword target", + "Add as target": "Add as target", + "Add target": "Add target", + "Already a target": "Already a target", + "An agent wrote this summary: {agent}": "An agent wrote this summary: {agent}", + "Back to journeys": "Back to journeys", + "Behind by": "Behind by", + "Between {from} and {to}, from {count} impressions": "Between {from} and {to}, from {count} impressions", + "Check again": "Check again", + "Compose again": "Compose again", + "Connect a Search Console property under Settings, Marketing traffic. Once the import has run, this page shows where you rank, which queries are one push from page one, which pages compete with each other, and which questions nothing of yours answers.": "Connect a Search Console property under Settings, Marketing traffic. Once the import has run, this page shows where you rank, which queries are one push from page one, which pages compete with each other, and which questions nothing of yours answers.", + "Connect a social account and give a client a handle on the same network. Only Mastodon and Bluesky publish a follower list an audit can read; for the other networks this page will say so rather than guess.": "Connect a social account and give a client a handle on the same network. Only Mastodon and Bluesky publish a follower list an audit can read; for the other networks this page will say so rather than guess.", + "Could not compose the weekly review.": "Could not compose the weekly review.", + "Could not load keyword proposals.": "Could not load keyword proposals.", + "Could not load the competitor watches.": "Could not load the competitor watches.", + "Could not load the connection audit.": "Could not load the connection audit.", + "Could not load this journey.": "Could not load this journey.", + "Could not read the weekly review.": "Could not read the weekly review.", + "Could not read what this journey did.": "Could not read what this journey did.", + "Could not save this journey.": "Could not save this journey.", + "Crawl source for our own pages": "Crawl source for our own pages", + "Egress source for competitor reads": "Egress source for competitor reads", + "Every query with demand is answered by a page whose title or headings carry its terms.": "Every query with demand is answered by a page whose title or headings carry its terms.", + "for example: 5 days": "for example: 5 days", + "How many impressions a query needs over the window before any keyword finding names it. Lower it for a small site.": "How many impressions a query needs over the window before any keyword finding names it. Lower it for a small site.", + "How these reads identify themselves to the sites they read.": "How these reads identify themselves to the sites they read.", + "Impressions before a query counts": "Impressions before a query counts", + "Leave it empty and the action always runs.": "Leave it empty and the action always runs.", + "Leave it empty and the send is checked against the channel consent instead of a list.": "Leave it empty and the send is checked against the channel consent instead of a list.", + "Let hermiq score how relevant an item is": "Let hermiq score how relevant an item is", + "Marketing intelligence": "Marketing intelligence", + "Marketing intelligence settings saved": "Marketing intelligence settings saved", + "Matomo": "Matomo", + "Matomo address": "Matomo address", + "Matomo connector source": "Matomo connector source", + "Matomo credential id": "Matomo credential id", + "Matomo site id": "Matomo site id", + "No consent for this channel": "No consent for this channel", + "No mail transport is configured": "No mail transport is configured", + "No query has two pages taking a real share of its impressions while earning less together than the better page does alone.": "No query has two pages taking a real share of its impressions while earning less together than the better page does alone.", + "No query sits between position 8 and 20 with enough impressions and a click-through below what that position normally earns.": "No query sits between position 8 and 20 with enough impressions and a click-through below what that position normally earns.", + "No review yet": "No review yet", + "No watches yet": "No watches yet", + "Not run yet": "Not run yet", + "Not scored": "Not scored", + "Nothing has been seen yet. A watch records an item once, so a second run over unchanged sources adds nothing here.": "Nothing has been seen yet. A watch records an item once, so a second run over unchanged sources adds nothing here.", + "Nothing of ours answers this": "Nothing of ours answers this", + "Nothing to compare yet": "Nothing to compare yet", + "Off by default: it sends a competitor's headline to the model you configured in hermiq. When it is off, or hermiq cannot answer, an item is stored without a score and shown as not scored.": "Off by default: it sends a competitor's headline to the model you configured in hermiq. When it is off, or hermiq cannot answer, an item is stored without a score and shown as not scored.", + "Once its trigger fires, every contact it reached and every contact it refused is listed here.": "Once its trigger fires, every contact it reached and every contact it refused is listed here.", + "Page that should win it": "Page that should win it", + "position {position}, {share} of impressions": "position {position}, {share} of impressions", + "Put in front of the model verbatim, in your own words.": "Put in front of the model verbatim, in your own words.", + "Relevance {score}": "Relevance {score}", + "Run now": "Run now", + "Save marketing intelligence settings": "Save marketing intelligence settings", + "Saving the marketing intelligence settings failed. A Matomo token is refused here: store it in the credential broker and enter the credential id instead.": "Saving the marketing intelligence settings failed. A Matomo token is refused here: store it in the credential broker and enter the credential id instead.", + "Send a mailing, publish a post or connect Search Console, and last week's numbers appear here.": "Send a mailing, publish a post or connect Search Console, and last week's numbers appear here.", + "Set a crawl source under Settings, Marketing intelligence to switch this check on.": "Set a crawl source under Settings, Marketing intelligence to switch this check on.", + "Set an egress source under Settings, Marketing intelligence to switch the watches on.": "Set an egress source under Settings, Marketing intelligence to switch the watches on.", + "Skipped: this customer is being chased for an unpaid invoice": "Skipped: this customer is being chased for an unpaid invoice", + "Tenders at municipalities, open source in the public sector.": "Tenders at municipalities, open source in the public sector.", + "The flow engine refused this journey: {reason}": "The flow engine refused this journey: {reason}", + "The id of the credential the OpenRegister broker holds for Matomo. Store the token there, not here: a value that looks like a Matomo token is refused.": "The id of the credential the OpenRegister broker holds for Matomo. Store the token there, not here: a value that looks like a Matomo token is refused.", + "The keyword target could not be added.": "The keyword target could not be added.", + "The mail transport rejected it": "The mail transport rejected it", + "The OpenConnector source feeds, sitemaps, page watches and public timelines are read through. A competitor on their own domain can carry a source of its own instead.": "The OpenConnector source feeds, sitemaps, page watches and public timelines are read through. A competitor on their own domain can carry a source of its own instead.", + "The OpenConnector source that reaches your Matomo instance.": "The OpenConnector source that reaches your Matomo instance.", + "The OpenConnector source that reaches your own website. The content gap check reads the title and headings of the pages that already appear in search. Leave it empty and that check does not run, which the Keywords page says rather than reporting no gaps.": "The OpenConnector source that reaches your own website. The content gap check reads the title and headings of the pages that already appear in search. Leave it empty and that check does not run, which the Keywords page says rather than reporting no gaps.", + "The template is gone": "The template is gone", + "The watch could not be run.": "The watch could not be run.", + "These sources hold nothing yet, so their absence here is missing data and not a zero: {sources}": "These sources hold nothing yet, so their absence here is missing data and not a zero: {sources}", + "This contact has no email address": "This contact has no email address", + "This journey has not run yet": "This journey has not run yet", + "This journey is saved but it will not run: this instance has no flow engine.": "This journey is saved but it will not run: this instance has no flow engine.", + "Together {combined}, best page alone {best}": "Together {combined}, best page alone {best}", + "Used for links out of the reports. The reads go through the source below.": "Used for links out of the reports. The reads go through the source below.", + "User agent for competitor reads": "User agent for competitor reads", + "Watches": "Watches", + "Watching": "Watching", + "Week of {week}": "Week of {week}", + "What counts as relevant here": "What counts as relevant here", + "What do you want to do with it": "What do you want to do with it", + "What happens": "What happens", + "What is somebody typing this trying to do": "What is somebody typing this trying to do", + "What moved": "What moved", + "What to try": "What to try", + "Where the keyword, Matomo and competitor reads leave the instance, and how relevance is scored. Every outbound read goes through an OpenConnector source, so this section holds source ids and never a key.": "Where the keyword, Matomo and competitor reads leave the instance, and how relevance is scored. Every outbound read goes through an OpenConnector source, so this section holds source ids and never a key.", + "Where we rank": "Where we rank", + "Who this journey is for, in one sentence": "Who this journey is for, in one sentence", + "Why this term matters, and what you decided about it.": "Why this term matters, and what you decided about it." }, "nplurals=2; plural=(n != 1);" ) diff --git a/src/views/marketing/CompetitorWatches.vue b/src/views/marketing/CompetitorWatches.vue index f72cfc8e3..f7527f827 100644 --- a/src/views/marketing/CompetitorWatches.vue +++ b/src/views/marketing/CompetitorWatches.vue @@ -401,7 +401,9 @@ export default { .competitors__event-title { font-weight: bold; - word-break: break-word; + /* `break-word` is deprecated: the modern spelling of "break anywhere rather + than overflow" is overflow-wrap, and word-break keeps its own meaning. */ + overflow-wrap: anywhere; } .competitors__relevance, diff --git a/src/views/social/SocialPerformanceView.vue b/src/views/social/SocialPerformanceView.vue index 5da38616d..25509d017 100644 --- a/src/views/social/SocialPerformanceView.vue +++ b/src/views/social/SocialPerformanceView.vue @@ -146,7 +146,7 @@ export default { .social-performance__table th, .social-performance__table td { - text-align: left; + text-align: start; padding: 8px; border-bottom: 1px solid var(--color-border); } From 6427a3172cb7ea4053a59aac1525e64a47d1aa76 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sat, 5 Sep 2026 13:02:45 +0200 Subject: [PATCH 5/8] test(coverage): name the classes nineteen risky tests already exercise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All six PHPUnit cells fail, and they fail on `development` too: zero failures, zero errors, nineteen tests RISKY because strict coverage refuses a test that executes a class its `@covers` list never mentions. They came in with the marketing surface. This test executed code that is not listed as code to be covered or used: - OCA\Pipelinq\Service\Competitor\WatchOutcome - OCA\Pipelinq\Service\Egress\EgressResult - OCA\Pipelinq\Service\Search\HtmlTextExtractor - OCA\Pipelinq\Service\Search\ExpectedCtrCurve - OCA\Pipelinq\Service\Matomo\MatomoReportService `@uses` rather than `@covers`: these tests pass through those classes on the way to their subject rather than asserting on them. The RISKY only surfaces WITH a coverage driver, which is why the local runs that produced these tests were green — `--no-coverage` never evaluates the list. Co-Authored-By: Claude Opus 5 (1M context) --- tests/Unit/Service/Competitor/FeedWatchReaderTest.php | 2 ++ tests/Unit/Service/Competitor/PageWatchReaderTest.php | 3 +++ tests/Unit/Service/Competitor/SearchWatchReaderTest.php | 1 + tests/Unit/Service/Competitor/SitemapWatchReaderTest.php | 2 ++ tests/Unit/Service/Matomo/MatomoReportServiceTest.php | 1 + tests/Unit/Service/Search/KeywordAnalysisServiceTest.php | 1 + tests/Unit/Service/Search/SiteContentCrawlerTest.php | 2 ++ tests/Unit/Service/SettingsServiceMatomoReferenceTest.php | 1 + tests/Unit/Service/Social/ConnectionAuditServiceTest.php | 1 + 9 files changed, 14 insertions(+) diff --git a/tests/Unit/Service/Competitor/FeedWatchReaderTest.php b/tests/Unit/Service/Competitor/FeedWatchReaderTest.php index 640db2e34..a69bf1789 100644 --- a/tests/Unit/Service/Competitor/FeedWatchReaderTest.php +++ b/tests/Unit/Service/Competitor/FeedWatchReaderTest.php @@ -28,6 +28,8 @@ /** * @covers \OCA\Pipelinq\Service\Competitor\FeedWatchReader + * @uses \OCA\Pipelinq\Service\Competitor\WatchOutcome + * @uses \OCA\Pipelinq\Service\Egress\EgressResult */ class FeedWatchReaderTest extends TestCase { diff --git a/tests/Unit/Service/Competitor/PageWatchReaderTest.php b/tests/Unit/Service/Competitor/PageWatchReaderTest.php index f54f59383..7b45db6a8 100644 --- a/tests/Unit/Service/Competitor/PageWatchReaderTest.php +++ b/tests/Unit/Service/Competitor/PageWatchReaderTest.php @@ -29,6 +29,9 @@ /** * @covers \OCA\Pipelinq\Service\Competitor\PageWatchReader + * @uses \OCA\Pipelinq\Service\Competitor\WatchOutcome + * @uses \OCA\Pipelinq\Service\Egress\EgressResult + * @uses \OCA\Pipelinq\Service\Search\HtmlTextExtractor */ class PageWatchReaderTest extends TestCase { diff --git a/tests/Unit/Service/Competitor/SearchWatchReaderTest.php b/tests/Unit/Service/Competitor/SearchWatchReaderTest.php index 203d556f8..c46e6b9a8 100644 --- a/tests/Unit/Service/Competitor/SearchWatchReaderTest.php +++ b/tests/Unit/Service/Competitor/SearchWatchReaderTest.php @@ -65,6 +65,7 @@ public function search(string $query, ?string $actingUserId = null): array { /** * @covers \OCA\Pipelinq\Service\Competitor\SearchWatchReader + * @uses \OCA\Pipelinq\Service\Competitor\WatchOutcome */ class SearchWatchReaderTest extends TestCase { diff --git a/tests/Unit/Service/Competitor/SitemapWatchReaderTest.php b/tests/Unit/Service/Competitor/SitemapWatchReaderTest.php index 80be1bade..9e530bc7e 100644 --- a/tests/Unit/Service/Competitor/SitemapWatchReaderTest.php +++ b/tests/Unit/Service/Competitor/SitemapWatchReaderTest.php @@ -28,6 +28,8 @@ /** * @covers \OCA\Pipelinq\Service\Competitor\SitemapWatchReader + * @uses \OCA\Pipelinq\Service\Competitor\WatchOutcome + * @uses \OCA\Pipelinq\Service\Egress\EgressResult */ class SitemapWatchReaderTest extends TestCase { diff --git a/tests/Unit/Service/Matomo/MatomoReportServiceTest.php b/tests/Unit/Service/Matomo/MatomoReportServiceTest.php index 78527ec3f..5e9130c81 100644 --- a/tests/Unit/Service/Matomo/MatomoReportServiceTest.php +++ b/tests/Unit/Service/Matomo/MatomoReportServiceTest.php @@ -31,6 +31,7 @@ /** * @covers \OCA\Pipelinq\Service\Matomo\MatomoReportService + * @uses \OCA\Pipelinq\Service\Egress\EgressResult */ class MatomoReportServiceTest extends TestCase { diff --git a/tests/Unit/Service/Search/KeywordAnalysisServiceTest.php b/tests/Unit/Service/Search/KeywordAnalysisServiceTest.php index 1bc3df3c5..119a938fb 100644 --- a/tests/Unit/Service/Search/KeywordAnalysisServiceTest.php +++ b/tests/Unit/Service/Search/KeywordAnalysisServiceTest.php @@ -26,6 +26,7 @@ /** * @covers \OCA\Pipelinq\Service\Search\KeywordAnalysisService + * @uses \OCA\Pipelinq\Service\Search\ExpectedCtrCurve */ class KeywordAnalysisServiceTest extends TestCase { diff --git a/tests/Unit/Service/Search/SiteContentCrawlerTest.php b/tests/Unit/Service/Search/SiteContentCrawlerTest.php index ed233cdc7..6417602f5 100644 --- a/tests/Unit/Service/Search/SiteContentCrawlerTest.php +++ b/tests/Unit/Service/Search/SiteContentCrawlerTest.php @@ -29,6 +29,8 @@ /** * @covers \OCA\Pipelinq\Service\Search\SiteContentCrawler + * @uses \OCA\Pipelinq\Service\Egress\EgressResult + * @uses \OCA\Pipelinq\Service\Search\HtmlTextExtractor */ class SiteContentCrawlerTest extends TestCase { diff --git a/tests/Unit/Service/SettingsServiceMatomoReferenceTest.php b/tests/Unit/Service/SettingsServiceMatomoReferenceTest.php index 9de348152..4dce90cc6 100644 --- a/tests/Unit/Service/SettingsServiceMatomoReferenceTest.php +++ b/tests/Unit/Service/SettingsServiceMatomoReferenceTest.php @@ -35,6 +35,7 @@ /** * @covers \OCA\Pipelinq\Service\SettingsService + * @uses \OCA\Pipelinq\Service\Matomo\MatomoReportService */ class SettingsServiceMatomoReferenceTest extends TestCase { diff --git a/tests/Unit/Service/Social/ConnectionAuditServiceTest.php b/tests/Unit/Service/Social/ConnectionAuditServiceTest.php index 698b28b2f..cb03ba51b 100644 --- a/tests/Unit/Service/Social/ConnectionAuditServiceTest.php +++ b/tests/Unit/Service/Social/ConnectionAuditServiceTest.php @@ -30,6 +30,7 @@ /** * @covers \OCA\Pipelinq\Service\Social\ConnectionAuditService + * @uses \OCA\Pipelinq\Service\Egress\EgressResult */ class ConnectionAuditServiceTest extends TestCase { From 760a345be1b9b2f04dcef8e085d1062a0da08a03 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sat, 5 Sep 2026 13:07:38 +0200 Subject: [PATCH 6/8] fix(demo): give stageEnteredAt a date-time, which is what its schema declares The Newman job fails before a single request runs: ::error::Demo-data seeding failed (HTTP 500) Property 'stageEnteredAt' should match format 'date-time' but '2026-09-02' does not. `@days:N` resolves to `Y-m-d` and `@datetime:N` to a full ATOM timestamp. Two lead definitions fed the first into a property the register declares as `format: date-time`, so OpenRegister refused the object and the whole seed returned 500. Checked the rest against the register rather than by eye: `endDate`, `expectedCloseDate` and `startDate` are the only other `@days` fields and none of them is a date-time property. The seeder is what the e2e and Newman suites both provision from, so this made every list and dashboard spec run against an empty instance. Co-Authored-By: Claude Opus 5 (1M context) --- lib/Settings/demo_seed_data.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Settings/demo_seed_data.json b/lib/Settings/demo_seed_data.json index eb00cf362..35e307609 100644 --- a/lib/Settings/demo_seed_data.json +++ b/lib/Settings/demo_seed_data.json @@ -26,8 +26,8 @@ ], "leads": [ { "key": "lead-website", "pipelineKey": "pipeline-sales", "clientKey": "bakkerij", "data": { "title": "[Demo] Webshop vernieuwing Gouden Korst", "source": "website", "value": 12500, "probability": 20, "expectedCloseDate": "@days:45", "priority": "normal", "stage": "New", "stageOrder": 1, "status": "open", "description": "Demo lead: replace the bakery webshop." } }, - { "key": "lead-koffie", "pipelineKey": "pipeline-sales", "clientKey": "jansen", "data": { "title": "[Demo] Koffieabonnement kantoor", "source": "referral", "value": 3600, "probability": 40, "expectedCloseDate": "@days:30", "priority": "low", "stage": "Contacted", "stageOrder": 2, "stageEnteredAt": "@days:-3", "status": "open", "description": "Demo lead: office coffee subscription." } }, - { "key": "lead-intranet", "pipelineKey": "pipeline-sales", "clientKey": "gemeente", "data": { "title": "[Demo] Intranet migratie Zonnedael", "source": "tender", "value": 48000, "probability": 55, "expectedCloseDate": "@days:60", "priority": "high", "stage": "Qualified", "stageOrder": 3, "stageEnteredAt": "@days:-31", "status": "open", "description": "Demo lead: municipal intranet migration." } }, + { "key": "lead-koffie", "pipelineKey": "pipeline-sales", "clientKey": "jansen", "data": { "title": "[Demo] Koffieabonnement kantoor", "source": "referral", "value": 3600, "probability": 40, "expectedCloseDate": "@days:30", "priority": "low", "stage": "Contacted", "stageOrder": 2, "stageEnteredAt": "@datetime:-3", "status": "open", "description": "Demo lead: office coffee subscription." } }, + { "key": "lead-intranet", "pipelineKey": "pipeline-sales", "clientKey": "gemeente", "data": { "title": "[Demo] Intranet migratie Zonnedael", "source": "tender", "value": 48000, "probability": 55, "expectedCloseDate": "@days:60", "priority": "high", "stage": "Qualified", "stageOrder": 3, "stageEnteredAt": "@datetime:-31", "status": "open", "description": "Demo lead: municipal intranet migration." } }, { "key": "lead-catering", "pipelineKey": "pipeline-sales", "clientKey": "devries", "data": { "title": "[Demo] Catering jaarcontract", "source": "phone", "value": 8900, "probability": 70, "expectedCloseDate": "@days:14", "priority": "normal", "stage": "Proposal", "stageOrder": 4, "status": "open", "description": "Demo lead: yearly catering contract proposal." } }, { "key": "lead-won", "pipelineKey": "pipeline-sales", "clientKey": "elharrak", "data": { "title": "[Demo] Onderhoudscontract gewonnen", "source": "email", "value": 5200, "probability": 100, "expectedCloseDate": "@days:-7", "priority": "normal", "stage": "Won", "stageOrder": 5, "status": "won", "description": "Demo lead: won maintenance contract." } }, { "key": "lead-lost", "pipelineKey": "pipeline-sales", "clientKey": "bakkerij", "data": { "title": "[Demo] Kassasysteem upgrade verloren", "source": "website", "value": 6700, "probability": 0, "expectedCloseDate": "@days:-14", "priority": "low", "stage": "Lost", "stageOrder": 6, "status": "lost", "description": "Demo lead: lost POS upgrade to competitor." } } From 124c33e5749ad71cf859838d4961d252f3989bc4 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sat, 5 Sep 2026 13:14:48 +0200 Subject: [PATCH 7/8] fix(setup): seeding the example data also records which set was seeded The step is a choice followed by a run-action now. `seed-demo-data` recorded the decision and not the dataset, so after a successful seed the choice step was still unmet, and CnAppRoot opens the wizard while ANY optional step is outstanding: every operator, on every fresh browser profile, got the app covered by the wizard after asking for demo data and receiving it. CI caught it rather than a person: the seed script asserts that no optional step is left unmet, and it failed with 48 demo objects already in the database. Seeding IS choosing the set, so it writes both keys, the way skipping already wrote both. The new controller test covers the whole step pair: what status reports, that the option list travels with it, that an unknown dataset is refused rather than stored, that running without a choice seeds nothing, and that a failed seed leaves the step undecided rather than closing it for someone who received no data. Co-Authored-By: Claude Opus 5 (1M context) --- lib/Controller/SetupController.php | 5 + .../SetupControllerDemoDataTest.php | 174 ++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 tests/Unit/Controller/SetupControllerDemoDataTest.php diff --git a/lib/Controller/SetupController.php b/lib/Controller/SetupController.php index 1a2efc50e..1f13ceff7 100644 --- a/lib/Controller/SetupController.php +++ b/lib/Controller/SetupController.php @@ -400,6 +400,11 @@ private function seedDemoData(): DataResponse { // Record the decision so `status()` can report the step done. See // DEMO_DATA_DECIDED_KEY — an optional step the server can never // report done covers the whole app with the setup wizard. + // 🔴 BOTH KEYS. The step is a choice followed by a run-action now, and + // CnAppRoot opens the wizard while ANY optional step is outstanding + // — so recording only the decision would leave the choice open and + // the wizard covering every page. Seeding IS choosing the set. + $this->appConfig->setValueString(Application::APP_ID, self::DATASET_KEY, DemoSeedService::DEMO_DATASET); $this->appConfig->setValueString(Application::APP_ID, self::DEMO_DATA_DECIDED_KEY, 'seeded'); $created = array_sum($result['created']); diff --git a/tests/Unit/Controller/SetupControllerDemoDataTest.php b/tests/Unit/Controller/SetupControllerDemoDataTest.php new file mode 100644 index 000000000..7b315f3ec --- /dev/null +++ b/tests/Unit/Controller/SetupControllerDemoDataTest.php @@ -0,0 +1,174 @@ +written = []; + $this->config = []; + $this->demoSeed = $this->createMock(DemoSeedService::class); + } + + private function controller(array $params = []): SetupController { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString') + ->willReturnCallback(function (string $app, string $key, string $default = ''): string { + return ($this->config[$key] ?? $default); + }); + $appConfig->method('setValueString') + ->willReturnCallback(function (string $app, string $key, string $value): bool { + $this->written[$key] = $value; + + return true; + }); + + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn($params); + // 🔴 BOTH READERS, OR THE VALIDATION IS INVISIBLE TO THE TEST. + // saveConfig() reads the dataset with getParam() and the rest of the + // body with getParams(); a fake that answers only the second makes + // every assertion about the first pass for the wrong reason. + $request->method('getParam') + ->willReturnCallback(static function (string $key, $default = null) use ($params) { + return ($params[$key] ?? $default); + }); + + return new SetupController( + 'pipelinq', + $request, + $appConfig, + $this->createMock(SettingsService::class), + $this->demoSeed, + $this->createMock(IAppManager::class), + new NullLogger() + ); + } + + public function testStatusReportsBothExampleDataSteps(): void { + $this->demoSeed->method('listChoices')->willReturn([]); + + $steps = $this->controller()->status()->getData()['steps']; + + // Absence is the defect this guards: a step the wizard is never told + // about cannot be offered and cannot be completed. + $this->assertArrayHasKey('demo-data', $steps); + $this->assertArrayHasKey('load-demo-data', $steps); + $this->assertFalse($steps['demo-data']['done']); + $this->assertFalse($steps['load-demo-data']['done']); + } + + public function testStatusCarriesTheOptionListTheChoiceStepReads(): void { + // 🔴 THIS RESPONSE *IS* THE OPTION LIST. The step declares + // `optionsSource: datasets` and carries no options of its own, so a + // dataset missing here is a dataset nobody can pick. + $this->demoSeed->method('listChoices')->willReturn([ + ['id' => 'none', 'label' => 'None', 'description' => 'Nothing.', 'objectCount' => 0, 'icon' => 'CloseCircleOutline'], + ['id' => 'demo', 'label' => 'Example data', 'description' => 'A sales pipeline.', 'objectCount' => 0, 'icon' => 'DatabaseOutline'], + ]); + + $data = $this->controller()->status()->getData(); + + $this->assertSame(['none', 'demo'], array_column($data['datasets'], 'id')); + } + + public function testChoosingNoneClosesBothStepsWithoutRunningAnything(): void { + $this->demoSeed->method('listChoices')->willReturn([]); + $this->config['demo_dataset'] = 'none'; + + $steps = $this->controller()->status()->getData()['steps']; + + $this->assertTrue($steps['demo-data']['done']); + $this->assertTrue($steps['load-demo-data']['done']); + } + + public function testAnUnknownDatasetIsRefusedRatherThanStored(): void { + // Storing it would leave the seed step pointing at nothing, so the + // failure would surface one step later with no clue why. + $this->demoSeed->method('listChoices')->willReturn([ + ['id' => 'none', 'label' => 'None', 'description' => '', 'objectCount' => 0, 'icon' => ''], + ]); + + $data = $this->controller(['demo_dataset' => 'atlantis'])->saveConfig()->getData(); + + $this->assertFalse($data['success']); + $this->assertSame([], $this->written); + } + + public function testSkippingClosesBOTHStepsOrTheWizardNeverCloses(): void { + $response = $this->controller()->runAction('skip-demo-data'); + + $this->assertTrue($response->getData()['success']); + $this->assertSame('skipped', $this->written['demo_data_decided'] ?? null); + $this->assertSame('none', $this->written['demo_dataset'] ?? null, 'skipping IS choosing none'); + } + + public function testSeedingClosesTheChoiceStepToo(): void { + // 🔴 THE REGRESSION. `seed-demo-data` used to record only the decision, + // so the choice step stayed unmet after a successful seed and the + // wizard covered every page. Seeding IS choosing the set. + $this->demoSeed->method('seed')->willReturn(['success' => true, 'created' => ['deal' => 3], 'skipped' => []]); + + $data = $this->controller()->runAction('seed-demo-data')->getData(); + + $this->assertTrue($data['success']); + $this->assertSame('demo', $this->written['demo_dataset'] ?? null); + $this->assertSame('seeded', $this->written['demo_data_decided'] ?? null); + } + + public function testLoadingWithoutAChoiceRefusesRatherThanGuessing(): void { + // 🔴 NO SILENT DEFAULT. Seeding because the operator clicked Run one + // step early would plant example objects nobody asked for. + $this->demoSeed->expects($this->never())->method('seed'); + + $data = $this->controller()->runAction('load-demo-data')->getData(); + + $this->assertFalse($data['success']); + $this->assertSame([], $this->written); + } + + public function testChoosingNoneAndThenRunningSeedsNothing(): void { + $this->config['demo_dataset'] = 'none'; + $this->demoSeed->expects($this->never())->method('seed'); + + $data = $this->controller()->runAction('load-demo-data')->getData(); + + $this->assertTrue($data['success']); + $this->assertStringContainsString('No example data', $data['message']); + } + + public function testAFailedSeedIsReportedAndLeavesTheStepUNDECIDED(): void { + // Recording the decision here would close the step for an operator who + // asked for example data and received none. + $this->config['demo_dataset'] = 'demo'; + $this->demoSeed->method('seed')->willReturn(['success' => false, 'message' => 'OpenRegister is not installed.']); + + $response = $this->controller()->runAction('load-demo-data'); + + $this->assertFalse($response->getData()['success']); + $this->assertSame([], $this->written); + } +} From 5b6136f3ce00f9962e5c19830a35bd470d6147e4 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sat, 5 Sep 2026 13:32:19 +0200 Subject: [PATCH 8/8] test(coverage): name FleetAppId, which status() resolves the app id through All six PHPUnit cells went red on a suite that is green locally. `beStrictAboutCoverageMetadata` makes a test RISKY when it executes a class its coverage block does not name, and that check only runs with a coverage driver, which this machine has none of. Co-Authored-By: Claude Opus 5 (1M context) --- tests/Unit/Controller/SetupControllerDemoDataTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Unit/Controller/SetupControllerDemoDataTest.php b/tests/Unit/Controller/SetupControllerDemoDataTest.php index 7b315f3ec..dea4ac91c 100644 --- a/tests/Unit/Controller/SetupControllerDemoDataTest.php +++ b/tests/Unit/Controller/SetupControllerDemoDataTest.php @@ -21,6 +21,14 @@ * the seed script's own assertion caught. * * @covers \OCA\Pipelinq\Controller\SetupController + * + * 🔴 `@uses` IS NOT DECORATION. `beStrictAboutCoverageMetadata` makes a test + * RISKY when it executes a class this block does not name, and status() resolves + * the integration app id through FleetAppId. Locally that is invisible: the + * check only runs with a coverage driver, so all six PHPUnit cells went red on + * a suite that is green here. + * + * @uses \OCA\Pipelinq\Support\FleetAppId */ class SetupControllerDemoDataTest extends TestCase { private array $written = [];