diff --git a/CLAUDE.md b/CLAUDE.md index 2f8b3e7..1bbb122 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ In cron that is *never* the user being impersonated: `null` if any Deck-owned ba Consequence for `getUserBoards()`: `listBoardsForCurrentUser()` stays **request-only** anyway — Deck may already have cached board lists behind that field, and the job has no use for a board *list*; it asks `findStackIds()` about one known board id. Before using any other `OCA\Deck\*` service from the runner, check whether it carries a frozen `$userId` — the failure mode is silent and user-crossing, not an error. -The filter-matching logic (`WorkflowRunner::cardMatchesFilters()`, `::isOverdue()`) is deliberately `static` and side-effect-free so it's unit-testable without a real Deck installation — see `tests/Unit/Service/WorkflowRunnerFilterTest.php`. +The filter logic (`WorkflowRunner::cardMatchesFilters()`, `::isOverdue()`, `::findDeadFilters()`) is deliberately `static` and side-effect-free so it's unit-testable without a real Deck installation — see `tests/Unit/Service/WorkflowRunnerFilterTest.php`. Keep new pure logic in that shape; it is the only part of this app that has tests at all. ## Workflows are auto-disabled when their Deck targets vanish @@ -40,6 +40,21 @@ The filter-matching logic (`WorkflowRunner::cardMatchesFilters()`, `::isOverdue( Archived boards deliberately do *not* trigger this (they stay readable, so `findStackIds()` succeeds), even though they're filtered out of the settings dropdowns. +## Dead filters are surfaced, never disabled + +`findBrokenTarget()` covers the board and the two stacks — **not** `filter_user_ids` / `filter_label_ids`. Those are plain id lists compared with `array_intersect()` in `cardMatchesFilters()`, so an entry that no longer exists simply stops matching. With three labels of which one was deleted that is exactly right, and disabling would be wrong. + +The pathological case is a filter whose entries are *all* gone: the workflow keeps running, updates `last_run`, and matches nothing — forever, with no mail, no log line and a healthy-looking row. It happens on deleted labels and just as easily on a user losing board access, because Deck's `BoardService::deleteAcl()` calls `assignedUsersMapper->deleteByParticipantOnBoard()` and wipes that user's card assignments. + +Two low-stakes signals, no state and no migration: + +- `WorkflowRunner::warnAboutDeadFilters()` logs a warning on every run. +- `workflowIssues` in `src/PersonalSettings.vue` marks the row red. + +Both obey the same rule as the disable path: **only positive knowledge**. `DeckIntegrationService::findFilterOptions()` therefore *throws* instead of degrading to `[]` — unlike its siblings `listLabels()`/`listParticipants()`, which are wrapped in `call(…, [])` for the dropdowns, where an empty list is harmless. On the JS side the per-board caches store `null` for a failed request, and `isDeadFilter()` returns false for `null`; storing `[]` there would report every stack and filter of the board as deleted the moment one request fails. Don't collapse those two states back together. + +Deliberately no mail and no `enabled = false`: one board edit can revive the filter, and there is no "already warned" column to keep a mail one-off with (unlike the disable case, which is one-off for free because a disabled workflow is never picked up again). + ## The job has no interval — one run per cron pass `RunWorkflowsJob` extends `OCP\BackgroundJob\Job`, **not** `TimedJob`, so it runs every time cron picks it up. There is no interval and no knob: the cadence *is* the instance's cron cadence. Don't reintroduce one — an earlier version read `workflow_deck_automation.interval` from `config.php` (default 300, 60s floor), and that config key is gone as of v0.2.0. `TimedJob` with `setInterval(0)` would behave nearly identically but keeps an interval concept nothing uses. diff --git a/README.md b/README.md index b060f12..a64d319 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,19 @@ Ein Workflow verweist per ID auf ein Board und zwei Stapel. Wird eines davon in Ist Deck vorübergehend nicht erreichbar, wird **nicht** deaktiviert — der Job überspringt den Workflow und versucht es beim nächsten Lauf erneut. Archivierte Boards lösen die Deaktivierung ebenfalls nicht aus, sie sind nur in den Auswahlfeldern ausgeblendet. +### Gelöschte Labels und entfernte Benutzer + +Filter sind ein anderer Fall: Sie werden **nicht** deaktiviert. Wird eines von drei ausgewählten Labels gelöscht, greifen die anderen beiden weiter — das abzuschalten wäre falsch. + +Problematisch ist nur der Grenzfall, dass **alle** Einträge eines Filters verschwunden sind. Der Workflow läuft dann weiter, kann aber keine einzige Karte mehr treffen. Das passiert bei gelöschten Labels und ebenso, wenn ein gefilterter Benutzer die Board-Freigabe verliert — Deck entfernt dabei alle seine Kartenzuweisungen auf diesem Board. + +Damit dieser Zustand nicht unsichtbar bleibt: + +- **In der Übersicht** wird der Workflow rot markiert („Label-Filter trifft keine Karte mehr", „Benutzer-Filter trifft keine Karte mehr"). +- **Im Hintergrundjob** landet bei jedem Lauf eine Warnung im `nextcloud.log`. + +Keine E-Mail und keine Deaktivierung: Ein einziger Board-Klick kann den Filter wieder lebendig machen. Und wie bei Board und Stapel gilt auch hier — lässt sich das Board gerade nicht abfragen, wird nichts gemeldet statt etwas Falsches. + ## Ausführungstakt Der Hintergrundjob hat **kein eigenes Intervall** und ist auch nicht konfigurierbar: Er läuft bei **jedem** Cron-Durchlauf von Nextcloud. Der Takt ist damit exakt der des System-Crons — üblicherweise alle 5 Minuten, bei einem häufiger eingerichteten Cron entsprechend öfter. diff --git a/lib/Service/DeckIntegrationService.php b/lib/Service/DeckIntegrationService.php index 539bb7a..0ada754 100644 --- a/lib/Service/DeckIntegrationService.php +++ b/lib/Service/DeckIntegrationService.php @@ -315,17 +315,80 @@ class DeckIntegrationService { * @return array */ public function listLabels(int $boardId): array { - $labels = $this->call(function () use ($boardId) { - $board = $this->resolve(BoardService::class)->find($boardId, true); - return $board->getLabels() ?? []; - }, []); + $labels = $this->call(fn () => $this->readLabels($boardId), []); return array_map( - static fn ($label) => ['id' => $label->getId(), 'title' => $label->getTitle(), 'color' => $label->getColor()], + static fn ($label) => ['id' => (int)$label->getId(), 'title' => $label->getTitle(), 'color' => $label->getColor()], $labels, ); } + /** + * What a workflow's filters *could* legitimately refer to on this board. + * + * Unlike listLabels()/listParticipants() this throws instead of degrading + * to an empty list, because its only caller draws a conclusion from + * emptiness ("this filter cannot match anything any more"). Same rule as + * findStackIds(): a Deck hiccup must never be read as positive knowledge. + * + * @return array{labelIds: int[], participantUids: string[]} + * @throws DeckUnavailableException + */ + public function findFilterOptions(int $boardId): array { + try { + $board = $this->readBoard($boardId); + + return [ + 'labelIds' => array_map( + static fn ($label) => (int)$label->getId(), + $board->getLabels() ?? [], + ), + 'participantUids' => array_values(array_unique($this->collectParticipantUids($board))), + ]; + } catch (DeckUnavailableException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error('Could not read filter options of board ' . $boardId . ': ' . $e->getMessage(), [ + 'app' => 'workflow_deck_automation', + 'exception' => $e, + ]); + throw new DeckUnavailableException('Deck call failed: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Titles of a workflow's board and stacks, for the notification mail. + * + * Best effort by design: this only decorates a mail that is being sent + * because a card *was* already moved, so anything unreadable degrades to + * `#` rather than costing the user their notification. + * + * @return array{board: string, sourceStack: string, targetStack: string} + */ + public function describeTargets(int $boardId, int $sourceStackId, int $targetStackId): array { + $titles = $this->call(function () use ($boardId) { + $stackTitles = []; + foreach ($this->resolve(StackMapper::class)->findAll($boardId) as $stack) { + $stackTitles[(int)$stack->getId()] = (string)$stack->getTitle(); + } + + return [ + 'board' => (string)$this->resolve(BoardMapper::class)->find($boardId)->getTitle(), + 'stacks' => $stackTitles, + ]; + }, ['board' => '', 'stacks' => []]); + + return [ + 'board' => self::titleOr($titles['board'], $boardId), + 'sourceStack' => self::titleOr($titles['stacks'][$sourceStackId] ?? '', $sourceStackId), + 'targetStack' => self::titleOr($titles['stacks'][$targetStackId] ?? '', $targetStackId), + ]; + } + + private static function titleOr(string $title, int $id): string { + return $title !== '' ? $title : ('#' . $id); + } + /** * Everyone who can hold a card on this board: the board owner (who is * *not* part of the ACL — a private board has an empty ACL) plus every @@ -334,32 +397,7 @@ class DeckIntegrationService { * @return array */ public function listParticipants(int $boardId): array { - $uids = $this->call(function () use ($boardId) { - $board = $this->resolve(BoardService::class)->find($boardId, true); - - $uids = []; - $owner = $this->unwrapUid($board->getOwner()); - if ($owner !== null) { - $uids[] = $owner; - } - - foreach ($board->getAcl() ?? [] as $entry) { - $principal = $this->extractParticipantUid($entry); - if ($principal === null) { - continue; - } - $type = method_exists($entry, 'getType') ? (int)$entry->getType() : self::ACL_TYPE_USER; - if ($type === self::ACL_TYPE_GROUP) { - array_push($uids, ...$this->groupMemberUids($principal)); - } elseif ($type === self::ACL_TYPE_USER) { - $uids[] = $principal; - } - // Circles and federated shares are skipped: their members - // cannot be resolved to plain uids here. - } - - return $uids; - }, []); + $uids = $this->call(fn () => $this->collectParticipantUids($this->readBoard($boardId)), []); $participants = []; foreach ($uids as $uid) { @@ -375,6 +413,47 @@ class DeckIntegrationService { return array_values($participants); } + /** + * The enriched board — `$fullDetails = true` is what populates labels and + * the ACL. Shared by every caller that needs one so a single Deck read + * serves them all. + */ + private function readBoard(int $boardId): mixed { + return $this->resolve(BoardService::class)->find($boardId, true); + } + + private function readLabels(int $boardId): array { + return $this->readBoard($boardId)->getLabels() ?? []; + } + + /** + * @return string[] may contain duplicates + */ + private function collectParticipantUids(mixed $board): array { + $uids = []; + $owner = $this->unwrapUid($board->getOwner()); + if ($owner !== null) { + $uids[] = $owner; + } + + foreach ($board->getAcl() ?? [] as $entry) { + $principal = $this->extractParticipantUid($entry); + if ($principal === null) { + continue; + } + $type = method_exists($entry, 'getType') ? (int)$entry->getType() : self::ACL_TYPE_USER; + if ($type === self::ACL_TYPE_GROUP) { + array_push($uids, ...$this->groupMemberUids($principal)); + } elseif ($type === self::ACL_TYPE_USER) { + $uids[] = $principal; + } + // Circles and federated shares are skipped: their members + // cannot be resolved to plain uids here. + } + + return $uids; + } + /** * @return string[] */ diff --git a/lib/Service/NotificationMailer.php b/lib/Service/NotificationMailer.php index c803f06..9144c2f 100644 --- a/lib/Service/NotificationMailer.php +++ b/lib/Service/NotificationMailer.php @@ -30,7 +30,11 @@ class NotificationMailer { ) { } - public function sendCardMovedNotification(IUser $user, Workflow $workflow, Card $card): void { + /** + * @param array{board: string, sourceStack: string, targetStack: string} $targets + * titles resolved by DeckIntegrationService::describeTargets() + */ + public function sendCardMovedNotification(IUser $user, Workflow $workflow, Card $card, array $targets): void { $email = $user->getEMailAddress(); if ($email === null || $email === '') { $this->logger->info('Skipping notification for workflow ' . $workflow->getId() . ': user ' . $user->getUID() . ' has no email address', [ @@ -55,6 +59,13 @@ class NotificationMailer { 'The card "%1$s" was moved because it is overdue (workflow "%2$s").', [$card->getTitle(), $workflow->getTitle()], )); + // Single-argument addBodyText() runs htmlspecialchars() itself, so + // these board- and stack-titles (user input) are safe as-is — + // unlike the description below, which passes both parts. + $template->addBodyText($this->l10n->t( + 'Board "%1$s": moved from "%2$s" to "%3$s".', + [$targets['board'], $targets['sourceStack'], $targets['targetStack']], + )); $this->addCardDescription($template, $card); $template->addBodyButton($this->l10n->t('Open card'), $cardLink); $template->addFooter(); diff --git a/lib/Service/WorkflowRunner.php b/lib/Service/WorkflowRunner.php index 016551d..353f8cb 100644 --- a/lib/Service/WorkflowRunner.php +++ b/lib/Service/WorkflowRunner.php @@ -159,6 +159,11 @@ class WorkflowRunner { $filterUserIds = $workflow->getFilterUserIdsArray(); $filterLabelIds = $workflow->getFilterLabelIdsArray(); + $this->warnAboutDeadFilters($workflow, $filterUserIds, $filterLabelIds); + + // Resolved at most once per run, and only if a card actually moves + // and the workflow wants a mail — this is decoration, not a check. + $targets = null; foreach ($cards as $card) { if (!self::isOverdue($card->getDaysUntilDue())) { @@ -171,7 +176,18 @@ class WorkflowRunner { continue; } - $this->moveAndNotify($user, $workflow, $card); + if (!$this->moveCard($workflow, $card)) { + continue; + } + + if ($workflow->getNotifyEmail()) { + $targets ??= $this->deckService->describeTargets( + $workflow->getBoardId(), + $workflow->getSourceStackId(), + $workflow->getTargetStackId(), + ); + $this->mailer->sendCardMovedNotification($user, $workflow, $card, $targets); + } } $workflow->setLastRun($this->timeFactory->getDateTime()); @@ -226,7 +242,7 @@ class WorkflowRunner { $this->mailer->sendWorkflowDisabledNotification($user, $workflow, $brokenTarget); } - private function moveAndNotify(IUser $user, Workflow $workflow, Card $card): void { + private function moveCard(Workflow $workflow, Card $card): bool { try { $this->deckService->moveCard($card->getId(), $workflow->getTargetStackId()); } catch (Throwable $e) { @@ -235,11 +251,55 @@ class WorkflowRunner { 'card' => $card->getId(), 'id' => $workflow->getId(), ]); + return false; + } + + return true; + } + + /** + * A filter whose every entry has been deleted from the board is not an + * error — the workflow still runs, it just cannot match a single card any + * more, forever, without anything in the UI or the log saying so. That is + * the one failure mode of this app that is completely invisible, so it + * gets a log line on every run. + * + * Explicitly *not* a reason to disable the workflow or mail the user: a + * filter with three labels of which one was deleted still works as + * intended, and even a fully dead filter may be one board edit away from + * being live again. The settings page shows the same condition in red. + * + * @param string[] $filterUserIds + * @param int[] $filterLabelIds + */ + private function warnAboutDeadFilters(Workflow $workflow, array $filterUserIds, array $filterLabelIds): void { + if ($filterUserIds === [] && $filterLabelIds === []) { return; } - if ($workflow->getNotifyEmail()) { - $this->mailer->sendCardMovedNotification($user, $workflow, $card); + try { + $options = $this->deckService->findFilterOptions($workflow->getBoardId()); + } catch (DeckUnavailableException $e) { + // Same rule as everywhere else: no answer is not a bad answer. + $this->logger->debug('Could not check the filters of workflow {id}: ' . $e->getMessage(), [ + 'app' => 'workflow_deck_automation', + 'id' => $workflow->getId(), + ]); + return; + } + + $dead = self::findDeadFilters($filterUserIds, $filterLabelIds, $options['participantUids'], $options['labelIds']); + + if ($dead !== []) { + $this->logger->warning( + 'Workflow {id} can no longer match any card: none of its ' . implode('/', $dead) + . ' filter entries exists on board {board} any more', + [ + 'app' => 'workflow_deck_automation', + 'id' => $workflow->getId(), + 'board' => $workflow->getBoardId(), + ], + ); } } @@ -247,6 +307,38 @@ class WorkflowRunner { return $daysUntilDue !== null && $daysUntilDue < 0; } + /** + * Which filters cannot match anything any more, because not one of their + * entries is still offered by the board. An empty filter is not dead, it + * is "no restriction on this dimension" — the same reading as in + * cardMatchesFilters(), and getting it backwards would report every + * unfiltered workflow as broken. + * + * Static and side-effect free for the same reason as the matching logic: + * testable without a Deck installation. + * + * @param string[] $filterUserIds + * @param int[] $filterLabelIds + * @param string[] $boardUserIds everyone who can hold a card on the board + * @param int[] $boardLabelIds the board's remaining labels + * @return string[] any of 'label', 'user', in that order + */ + public static function findDeadFilters( + array $filterUserIds, + array $filterLabelIds, + array $boardUserIds, + array $boardLabelIds, + ): array { + $dead = []; + if ($filterLabelIds !== [] && array_intersect($filterLabelIds, $boardLabelIds) === []) { + $dead[] = 'label'; + } + if ($filterUserIds !== [] && array_intersect($filterUserIds, $boardUserIds) === []) { + $dead[] = 'user'; + } + return $dead; + } + /** * Pure filter-matching logic (kept static/side-effect free so it can * be unit tested without real Deck objects). Both filters are OR diff --git a/src/PersonalSettings.vue b/src/PersonalSettings.vue index 3c2e28e..cacf277 100644 --- a/src/PersonalSettings.vue +++ b/src/PersonalSettings.vue @@ -159,7 +159,10 @@ const loading = ref(true) const loadError = ref('') const workflows = ref([]) const boards = ref([]) +// Per board, and `null` whenever the request failed — see loadStacksFor(). const stacksByBoard = reactive({}) +const labelsByBoard = reactive({}) +const participantsByBoard = reactive({}) const showForm = ref(false) const saving = ref(false) @@ -181,9 +184,15 @@ const sourceStackOptions = computed(() => stackOptions.value.filter((option) => const targetStackOptions = computed(() => stackOptions.value.filter((option) => option.value !== form.sourceStackId)) /** - * Workflows whose board or stacks were deleted in Deck, keyed by id. The - * background job switches these off by itself, but a row that just stopped - * working needs to say so instead of looking healthy. + * Workflows that stopped working, keyed by id. Two different kinds: + * + * - Board or stacks deleted in Deck. The background job switches these off + * by itself, but a row that just stopped working needs to say so instead + * of looking healthy. + * - Every entry of a filter deleted from the board. Nothing switches those + * off — deliberately, since one deleted label out of three is harmless — + * yet a filter that cannot match a single card any more is otherwise + * completely invisible: the workflow keeps running and moves nothing. */ const workflowIssues = computed(() => { const issues = {} @@ -195,25 +204,49 @@ const workflowIssues = computed(() => { } const stacks = stacksByBoard[workflow.boardId] - if (!stacks) { - continue + if (stacks) { + const missing = [] + if (!stacks.some((stack) => stack.id === workflow.sourceStackId)) { + missing.push('Quell-Stapel') + } + if (!stacks.some((stack) => stack.id === workflow.targetStackId)) { + missing.push('Ziel-Stapel') + } + if (missing.length) { + issues[workflow.id] = `${missing.join(' und ')} nicht mehr vorhanden` + continue + } } - const missing = [] - if (!stacks.some((stack) => stack.id === workflow.sourceStackId)) { - missing.push('Quell-Stapel') + const dead = [] + if (isDeadFilter(workflow.filterLabelIds, labelsByBoard[workflow.boardId], 'id')) { + dead.push('Label-Filter') } - if (!stacks.some((stack) => stack.id === workflow.targetStackId)) { - missing.push('Ziel-Stapel') + if (isDeadFilter(workflow.filterUserIds, participantsByBoard[workflow.boardId], 'uid')) { + dead.push('Benutzer-Filter') } - if (missing.length) { - issues[workflow.id] = `${missing.join(' und ')} nicht mehr vorhanden` + if (dead.length > 1) { + issues[workflow.id] = `${dead.join(' und ')} treffen keine Karte mehr` + } else if (dead.length) { + issues[workflow.id] = `${dead[0]} trifft keine Karte mehr` } } return issues }) +/** + * True only on positive knowledge: the filter has entries, the board's list + * was actually loaded, and not one entry is still on it. A failed request + * (null) says nothing and must not turn a healthy row red. + */ +function isDeadFilter(selected, available, key) { + if (!selected.length || !available) { + return false + } + return !selected.some((value) => available.some((entry) => entry[key] === value)) +} + const form = reactive(emptyForm()) function emptyForm() { @@ -242,11 +275,27 @@ async function loadStacksFor(boardId) { if (!boardId || stacksByBoard[boardId]) { return } - try { - stacksByBoard[boardId] = await fetchStacks(boardId) - } catch (e) { - stacksByBoard[boardId] = [] + // `null`, not `[]` — "could not ask Deck" has to stay distinguishable + // from "this board has none", or a single failed request reports every + // stack of the board as deleted. + stacksByBoard[boardId] = await fetchStacks(boardId).catch(() => null) +} + +/** + * Labels and participants of a board — needed only to judge whether a stored + * filter can still match anything, so this is fetched just for the boards of + * workflows that actually use filters. + */ +async function loadFiltersFor(boardId) { + if (!boardId || labelsByBoard[boardId]) { + return } + const [labels, participants] = await Promise.all([ + fetchLabels(boardId).catch(() => null), + fetchParticipants(boardId).catch(() => null), + ]) + labelsByBoard[boardId] = labels + participantsByBoard[boardId] = participants } async function loadAll() { @@ -256,7 +305,13 @@ async function loadAll() { const [loadedWorkflows, loadedBoards] = await Promise.all([fetchWorkflows(), fetchBoards()]) workflows.value = loadedWorkflows boards.value = loadedBoards - await Promise.all(loadedWorkflows.map((workflow) => loadStacksFor(workflow.boardId))) + await Promise.all(loadedWorkflows.flatMap((workflow) => { + const pending = [loadStacksFor(workflow.boardId)] + if (workflow.filterLabelIds.length || workflow.filterUserIds.length) { + pending.push(loadFiltersFor(workflow.boardId)) + } + return pending + })) } catch (e) { loadError.value = 'Konnte Workflows oder Deck-Boards nicht laden. Ist die Deck-App aktiviert?' } finally { @@ -281,15 +336,20 @@ async function loadBoardOptions(boardId) { // One failing endpoint must not take the other two dropdowns down with it. const [stacks, labels, participants] = await Promise.all([ - fetchStacks(boardId).catch(() => []), - fetchLabels(boardId).catch(() => []), - fetchParticipants(boardId).catch(() => []), + fetchStacks(boardId).catch(() => null), + fetchLabels(boardId).catch(() => null), + fetchParticipants(boardId).catch(() => null), ]) + // Feed the overview's caches too, so its red markers agree with what the + // form just loaded. `null` propagates on purpose: it means "unknown". stacksByBoard[boardId] = stacks - stackOptions.value = stacks.map((stack) => ({ value: stack.id, label: stack.title })) - labelOptions.value = labels.map((label) => ({ value: label.id, label: label.title })) - participantOptions.value = participants.map((participant) => ({ value: participant.uid, label: participant.displayName })) + labelsByBoard[boardId] = labels + participantsByBoard[boardId] = participants + + stackOptions.value = (stacks ?? []).map((stack) => ({ value: stack.id, label: stack.title })) + labelOptions.value = (labels ?? []).map((label) => ({ value: label.id, label: label.title })) + participantOptions.value = (participants ?? []).map((participant) => ({ value: participant.uid, label: participant.displayName })) } /** diff --git a/tests/Unit/Service/WorkflowRunnerFilterTest.php b/tests/Unit/Service/WorkflowRunnerFilterTest.php index 6f71fda..e9d553b 100644 --- a/tests/Unit/Service/WorkflowRunnerFilterTest.php +++ b/tests/Unit/Service/WorkflowRunnerFilterTest.php @@ -43,4 +43,28 @@ class WorkflowRunnerFilterTest extends TestCase { // both match -> true $this->assertTrue(WorkflowRunner::cardMatchesFilters(['alice'], [1], ['alice'], [1])); } + + public function testEmptyFiltersAreNeverDead(): void { + // "no restriction" must not be reported as a broken filter, however + // little the board has left to offer. + $this->assertSame([], WorkflowRunner::findDeadFilters([], [], [], [])); + $this->assertSame([], WorkflowRunner::findDeadFilters([], [], ['alice'], [1])); + } + + public function testFilterSurvivesAsLongAsOneEntryRemains(): void { + $this->assertSame([], WorkflowRunner::findDeadFilters(['alice', 'bob'], [1, 2], ['bob'], [2])); + } + + public function testFilterIsDeadWhenEveryEntryIsGone(): void { + $this->assertSame(['label'], WorkflowRunner::findDeadFilters([], [1, 2], [], [3])); + $this->assertSame(['user'], WorkflowRunner::findDeadFilters(['alice'], [], ['bob'], [])); + $this->assertSame( + ['label', 'user'], + WorkflowRunner::findDeadFilters(['alice'], [1], ['bob'], [2]), + ); + } + + public function testAnEmptyBoardKillsOnlyTheFiltersThatAreSet(): void { + $this->assertSame(['label'], WorkflowRunner::findDeadFilters([], [1], [], [])); + } }