fix: detach meldet nicht „alles erledigt“, solange nicht lesbare Fundstellen Verweise enthalten können; PageRewriter::apply liefert hidden_count; Tests für Sperre durch nicht lesbare Seiten und post_ids über der Obergrenze

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 17:38:01 +02:00
co-authored by Claude Sonnet 5.5
parent b805ed64dd
commit 06398242d6
7 changed files with 132 additions and 11 deletions
@@ -223,8 +223,10 @@ final class DiviDesignDetachAbility implements Ability
$target['name'] $target['name']
); );
$done = self::finished($target, $preview['skipped'], $preview['hidden_count']);
return PageRewriteReport::nothingToDo( return PageRewriteReport::nothingToDo(
$summary . (self::finished($target, $preview['skipped']) ? ' ' . self::DELETE_HINT : ''), $summary . ($done ? ' ' . self::DELETE_HINT : ''),
$preview, $preview,
$notes $notes
); );
@@ -343,7 +345,8 @@ final class DiviDesignDetachAbility implements Ability
if ($wrotePresets) { if ($wrotePresets) {
$summary = $presets['summary'] . ' ' . $summary; $summary = $presets['summary'] . ' ' . $summary;
} }
if ($pages['remaining'] === 0 && self::finished($target, $pages['skipped'])) { $done = self::finished($target, $pages['skipped'], $pages['hidden_count']);
if ($pages['remaining'] === 0 && $done) {
$summary .= ' ' . self::DELETE_HINT; $summary .= ' ' . self::DELETE_HINT;
} }
@@ -354,6 +357,7 @@ final class DiviDesignDetachAbility implements Ability
->with('saved', $pages['saved']) ->with('saved', $pages['saved'])
->with('remaining', $pages['remaining']) ->with('remaining', $pages['remaining'])
->with('skipped', $pages['skipped']) ->with('skipped', $pages['skipped'])
->with('hidden_count', $pages['hidden_count'])
->with('diff', $presets['diff'] ?? []) ->with('diff', $presets['diff'] ?? [])
->with('design_rev', $presets['design_rev'] ?? null) ->with('design_rev', $presets['design_rev'] ?? null)
->toArray(); ->toArray();
@@ -399,14 +403,15 @@ final class DiviDesignDetachAbility implements Ability
} }
/** /**
* Whether nothing is left to detach before lit/divi-presets-delete: all usages, nothing skipped. * Whether nothing is left to detach before lit/divi-presets-delete: all usages, nothing skipped, and no usage
* this account may not read (delete would still answer in_use).
* *
* @param array<string, mixed> $target * @param array<string, mixed> $target
* @param list<array<string, mixed>> $skipped * @param list<array<string, mixed>> $skipped
*/ */
private static function finished(array $target, array $skipped): bool private static function finished(array $target, array $skipped, int $hidden): bool
{ {
return $target['post_ids'] === null && $skipped === []; return $target['post_ids'] === null && $skipped === [] && $hidden === 0;
} }
/** /**
@@ -147,6 +147,7 @@ final class DiviPresetsAssignAbility implements Ability
->with('saved', $result['saved']) ->with('saved', $result['saved'])
->with('remaining', $result['remaining']) ->with('remaining', $result['remaining'])
->with('skipped', $result['skipped']) ->with('skipped', $result['skipped'])
->with('hidden_count', $result['hidden_count'])
->toArray(); ->toArray();
} }
@@ -58,10 +58,22 @@ final class PageRewriteReport
if ($result['skipped'] !== []) { if ($result['skipped'] !== []) {
return $summary . sprintf(' Übersprungen: %d (siehe skipped).', count($result['skipped'])); return $summary . sprintf(' Übersprungen: %d (siehe skipped).', count($result['skipped']));
} }
if (($result['hidden_count'] ?? 0) > 0) {
return $summary . ' ' . self::hiddenNote($result['hidden_count']);
}
return $summary . ' Alle Fundstellen sind erledigt.'; return $summary . ' Alle Fundstellen sind erledigt.';
} }
/** The caveat when posts this account may not read could still carry references. */
public static function hiddenNote(int $hidden): string
{
return sprintf(
'Hinweis: %d für diesen Zugang nicht sichtbare Fundstellen können noch Verweise enthalten.',
$hidden
);
}
/** /**
* @param array<string, mixed> $preview * @param array<string, mixed> $preview
* @param list<string> $warnings * @param list<string> $warnings
@@ -97,6 +109,10 @@ final class PageRewriteReport
], ],
], ],
'remaining' => ['type' => 'integer'], 'remaining' => ['type' => 'integer'],
'hidden_count' => [
'type' => 'integer',
'description' => 'Fundstellen, die dieses Konto nicht lesen darf (nicht geändert).',
],
'skipped' => [ 'skipped' => [
'type' => 'array', 'type' => 'array',
'items' => [ 'items' => [
+12 -4
View File
@@ -123,8 +123,9 @@ class PageRewriter
* @param Closure(): NodeRewrite $rewrite A fresh strategy per post. * @param Closure(): NodeRewrite $rewrite A fresh strategy per post.
* @param list<int>|null $postIds Only these posts; null = every usage. * @param list<int>|null $postIds Only these posts; null = every usage.
* @return array{saved: list<array{post_id: int, revision_id: ?int, nodes: int}>, remaining: int, * @return array{saved: list<array{post_id: int, revision_id: ?int, nodes: int}>, remaining: int,
* warnings: list<string>, skipped: list<array{post_id: int, reason: string}>}|WP_Error * warnings: list<string>, skipped: list<array{post_id: int, reason: string}>, hidden_count: int}|WP_Error
* unsupported (above HARD_CAP), forbidden (blocked), partial_write (data.saved) * hidden_count = usages this account may not read (never rewritten, as in preview(); 0 with post_ids);
* errors: unsupported (above HARD_CAP), forbidden (blocked), partial_write (data.saved)
* @throws WriteFailed When saving the first post fails. * @throws WriteFailed When saving the first post fails.
* @throws DiviLocked When Divi is not released – before the first post is written. * @throws DiviLocked When Divi is not released – before the first post is written.
*/ */
@@ -136,7 +137,8 @@ class PageRewriter
string $summary string $summary
): array|WP_Error { ): array|WP_Error {
$ids = self::ids($postIds); $ids = self::ids($postIds);
$refusal = $this->refusal($this->usages->reportFor($query), $ids); $report = $this->usages->reportFor($query);
$refusal = $this->refusal($report, $ids);
if ($refusal !== null) { if ($refusal !== null) {
return $refusal; return $refusal;
} }
@@ -158,7 +160,13 @@ class PageRewriter
} }
} }
return ['saved' => $saved, 'remaining' => $remaining, 'warnings' => $warnings, 'skipped' => $skipped]; return [
'saved' => $saved,
'remaining' => $remaining,
'warnings' => $warnings,
'skipped' => $skipped,
'hidden_count' => $ids === null ? $report->hidden : 0,
];
} }
/** /**
@@ -303,6 +303,36 @@ final class DiviDesignDetachAbilityTest extends TestCase
); );
} }
public function testUnreadablePostsKeepTheDeleteHintBack(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('apply')->andReturn(PageRewriteReportTest::applied(1, 0, [], 3));
$result = $this->ability($pages)->execute([
'preset' => ['module' => 'button', 'preset' => 'CTA primär'],
'confirm' => true,
]);
self::assertStringNotContainsString('lit/divi-presets-delete', $result['summary']);
self::assertStringNotContainsString('erledigt', $result['summary']);
self::assertStringContainsString('3 für diesen Zugang nicht sichtbare Fundstellen', $result['summary']);
self::assertSame(3, $result['hidden_count']);
}
public function testNoUnreadablePostsKeepTheDeleteHint(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('apply')->andReturn(PageRewriteReportTest::applied(1, 0));
$result = $this->ability($pages)->execute([
'preset' => ['module' => 'button', 'preset' => 'CTA primär'],
'confirm' => true,
]);
self::assertStringContainsString('lit/divi-presets-delete', $result['summary']);
self::assertStringNotContainsString('nicht sichtbare', $result['summary']);
}
public function testRemainingPagesAskForAnotherCallWithoutTheDeleteHint(): void public function testRemainingPagesAskForAnotherCallWithoutTheDeleteHint(): void
{ {
$pages = Mockery::mock(PageRewriter::class); $pages = Mockery::mock(PageRewriter::class);
@@ -45,14 +45,20 @@ final class PageRewriteReportTest extends TestCase
* @param list<array{post_id: int, reason: string}> $skipped * @param list<array{post_id: int, reason: string}> $skipped
* @return array<string, mixed> * @return array<string, mixed>
*/ */
public static function applied(int $saved, int $remaining, array $skipped = []): array public static function applied(int $saved, int $remaining, array $skipped = [], int $hidden = 0): array
{ {
$rows = []; $rows = [];
for ($i = 0; $i < $saved; ++$i) { for ($i = 0; $i < $saved; ++$i) {
$rows[] = ['post_id' => 12 + $i, 'revision_id' => 900 + $i, 'nodes' => 2]; $rows[] = ['post_id' => 12 + $i, 'revision_id' => 900 + $i, 'nodes' => 2];
} }
return ['saved' => $rows, 'remaining' => $remaining, 'warnings' => [], 'skipped' => $skipped]; return [
'saved' => $rows,
'remaining' => $remaining,
'warnings' => [],
'skipped' => $skipped,
'hidden_count' => $hidden,
];
} }
public function testEmptyMeansNothingToWriteNothingBlockedNothingHidden(): void public function testEmptyMeansNothingToWriteNothingBlockedNothingHidden(): void
@@ -618,5 +618,60 @@ final class PageRewriterTest extends TestCase
self::assertSame(1, $preview['hidden_count']); self::assertSame(1, $preview['hidden_count']);
self::assertSame([[101, SaveScope::Page]], $this->saved); self::assertSame([[101, SaveScope::Page]], $this->saved);
self::assertSame([], $result['skipped']); self::assertSame([], $result['skipped']);
self::assertSame(1, $result['hidden_count'], 'detach must not say "all done" with unreadable usages');
}
/** Unreadable live usages the caller may not edit refuse the call (forbidden) as a count; preview lists them. */
public function testUnreadableBlockedLivePagesRefuseTheCallAsACount(): void
{
$report = new UsageReport(2, [], 1, 2, [102], 2, false);
$rewriter = $this->rewriter(
[
self::page(101, self::layout(self::BUTTON), ['post_status' => 'publish']),
self::page(102, self::layout(self::BUTTON), ['post_status' => 'publish']),
],
$report
);
$preview = $rewriter->preview(self::query(), self::assign(), null);
self::assertSame([102], $preview['blocked_ids']);
self::assertSame(2, $preview['blocked_hidden_count']);
$error = self::assertToolError(
$rewriter->apply(self::ABILITY, self::query(), self::assign(), null, self::SUMMARY),
'forbidden'
);
self::assertSame([102], $error['blocked_ids']);
self::assertSame(2, $error['blocked_hidden_count']);
self::assertStringContainsString('2 Einträge, die dieses Konto nicht lesen darf', $error['message']);
self::assertSame([], $this->saved);
self::assertSame([], $this->log->records);
}
/**
* Above HARD_CAP live usages the per-row edit check does not run (blocked stays empty): post_ids are not refused,
* and a named live page the caller may not edit is skipped by loadWritable() instead of forbidden.
*/
public function testPostIdsAboveTheHardCapAreNotRefusedAndAnUneditablePageIsSkipped(): void
{
$rewriter = $this->rewriter(
[
self::page(101, self::layout(self::BUTTON), ['post_status' => 'publish']),
self::page(102, self::layout(self::BUTTON), ['post_status' => 'publish']),
],
self::report(GlobalUsages::HARD_CAP + 1, [], GlobalUsages::HARD_CAP + 1, true)
);
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? null) === 101)
);
self::assertToolError($rewriter->preview(self::query(), self::assign(), null), 'unsupported');
$preview = $rewriter->preview(self::query(), self::assign(), [101, 102]);
$result = $rewriter->apply(self::ABILITY, self::query(), self::assign(), [101, 102], self::SUMMARY);
self::assertSame([], $preview['blocked_ids']);
self::assertSame([102], array_column($result['saved'], 'post_id'));
self::assertSame([101], array_column($result['skipped'], 'post_id'));
self::assertSame([[102, SaveScope::Page]], $this->saved);
} }
} }