allows('read')->andReturn(DiviFixtures::designSystem()); return $reader; } /** The stage 4 preset inventory (tests/Fixtures/divi/stage4/presets.json) as the design system reads it. */ private function designWithGroupPresets(): DesignSystemReader { $stored = json_decode(DiviFixtures::markup('stage4/presets.json'), true)['stored']; $reader = Mockery::mock(DesignSystemReader::class); $reader->allows('read')->andReturn(DesignSystem::fromDivi([], [], $stored, [])); return $reader; } public function testOutlineShowsGroupPresetsWithNames(): void { DiviFixtures::givenPost(DiviFixtures::markup('stage4/page--designsystem.html')); $ability = new DiviOutlineAbility(new DiviPageLoader(DiviFixtures::schema()), $this->designWithGroupPresets()); $result = $ability->execute(['post_id' => 7]); $byId = array_column($result['nodes'], null, 'id'); self::assertSame([], $byId['s0.r0.c0.m1']['group_presets']); self::assertSame(['[Fixture] Text Hinweis'], $byId['s0.r0.c0.m1']['presets']); self::assertSame(['font-body: [Fixture] Fließtext'], $byId['s0.r0.c0.m2']['group_presets']); self::assertSame(['spacing: [Fixture] Abstand Karte'], $byId['s0.r0.c0.m5']['group_presets']); self::assertStringContainsString( '{Preset: [Fixture] Blurb Leistung} {Gruppen-Presets: spacing: [Fixture] Abstand Karte}', $result['outline'] ); self::assertContains('group_presets', $ability->outputSchema()['properties']['nodes']['items']['required']); } public function testNodeGetListsGroupPresetsWithGroupAndNames(): void { DiviFixtures::givenPost(DiviFixtures::markup('stage4/page--designsystem.html')); $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->designWithGroupPresets()); $blurb = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c0.m5']); $plain = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c0.m3']); self::assertSame( [[ 'group_id' => 'module.decoration.spacing', 'group' => 'spacing', 'presets' => [['id' => 'apzj7e17ok', 'name' => '[Fixture] Abstand Karte']], ]], $blurb['group_presets'] ); self::assertSame([['id' => 'vdcl6msnuo', 'name' => '[Fixture] Blurb Leistung']], $blurb['presets']); self::assertSame([], $plain['group_presets']); self::assertArrayNotHasKey('groupPreset', $blurb['attrs']); } public function testOutlineListsNodesWithTreeRevAndPostRev(): void { $content = DiviFixtures::markup('synthetic-basic.html'); DiviFixtures::givenPost($content); $ability = new DiviOutlineAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); self::assertTrue($ability->permission(['post_id' => 7])); $result = $ability->execute(['post_id' => 7]); self::assertSame(substr(hash('sha256', $content), 0, 12), $result['tree_rev']); self::assertSame(12, strlen($result['rev'])); self::assertNotSame($result['tree_rev'], $result['rev']); self::assertCount(16, $result['nodes']); self::assertStringStartsWith('s0 section "Hero"', $result['outline']); self::assertFalse($result['truncated']); self::assertSame([], $result['hints']); self::assertTrue($ability->isDirect()); self::assertTrue($ability->annotations()['readonly']); } /** Ruling S18: the "limit" input caps children per parent; "truncated" reports the cut. */ public function testOutlineBreadthLimitTruncatesAndReportsIt(): void { DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html')); $ability = new DiviOutlineAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); $result = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c1', 'limit' => 2]); self::assertSame(['s0.r0.c1', 's0.r0.c1.m0', 's0.r0.c1.m1'], array_column($result['nodes'], 'id')); self::assertTrue($result['nodes'][0]['truncated']); self::assertTrue($result['truncated']); } public function testOutlineOfASubtreeAndUnknownNode(): void { DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html')); $ability = new DiviOutlineAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); $result = $ability->execute(['post_id' => 7, 'id' => 's1', 'depth' => 1]); self::assertSame(['s1', 's1.r0'], array_column($result['nodes'], 'id')); $missing = $ability->execute(['post_id' => 7, 'id' => 's7']); self::assertToolError($missing, 'not_found'); self::assertStringContainsString('tree_rev', $missing->get_error_message()); } public function testOutlineValidatesTheSubtreeId(): void { DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html')); $ability = new DiviOutlineAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); self::assertSame( 'lit_validation_failed', $ability->execute(['post_id' => 7, 'id' => 'hero'])->get_error_code() ); } public function testOutlineHintsForEmptyAndReadOnlyContent(): void { DiviFixtures::givenPost(''); $ability = new DiviOutlineAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); self::assertStringContainsString('lit-divi-insert', $ability->execute(['post_id' => 7])['hints'][0]); DiviFixtures::givenPost(DiviFixtures::markup('synthetic-mixed.html')); $hints = $ability->execute(['post_id' => 7])['hints']; self::assertStringContainsString('nur lesbar', $hints[0]); self::assertStringContainsString('nur als Ganzes', $hints[0]); } public function testNodeGetReturnsFullFormShortformAndPresets(): void { DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html')); $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); $heading = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c1.m0']); self::assertSame('heading', $heading['type']); self::assertSame('s0.r0.c1', $heading['parent']); self::assertSame(12, strlen($heading['rev'])); self::assertSame( ['desktop' => ['value' => ['headingLevel' => 'h1', 'color' => '#1a1a1a']], 'phone' => ['value' => ['size' => '28px']]], $heading['attrs']['title.decoration.font.font'] ); self::assertSame(['text' => 'Willkommen bei Müller & Söhne', 'level' => 'h1'], $heading['shortform']); self::assertSame([['id' => 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', 'name' => '']], $heading['presets']); self::assertSame('5.2.1', $heading['builder_version']); $accordion = $ability->execute(['post_id' => 7, 'id' => 's1.r0.c1.m0']); self::assertSame(['s1.r0.c1.m0.m0', 's1.r0.c1.m0.m1'], $accordion['children']); self::assertInstanceOf(stdClass::class, $accordion['shortform']); } public function testNodeGetValidatesTheId(): void { DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html')); $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); self::assertToolError($ability->execute(['post_id' => 7, 'id' => 'hero']), 'validation_failed'); self::assertToolError($ability->execute(['post_id' => 7, 'id' => 's5.r0']), 'not_found'); } public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void { DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html')); $ability = new DiviNodeGetAbility(LibraryFixtures::loader(), $this->design()); $global = $ability->execute(['post_id' => 7, 'id' => 'g0']); $plain = $ability->execute(['post_id' => 7, 'id' => 's0']); self::assertSame('1058', $global['global']['id']); self::assertSame('divi/section', $global['global']['block_name']); self::assertStringContainsString('post_id 1058', $global['global']['hint']); self::assertFalse($global['editable']); self::assertNull($plain['global']); } /** Ruling P13: without the library right node-get and outline name no library ID. */ public function testWithoutLibraryAccessNoLibraryIdIsNamed(): void { DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html')); $loaders = [ 'no divi_library right' => LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), 'group disabled' => LibraryFixtures::loader(new LibraryAccess( LibraryFixtures::store(), LibraryFixtures::roles(), null, static fn (): bool => false )), 'no library access at all' => new DiviPageLoader(DiviFixtures::schema()), ]; foreach ($loaders as $case => $loader) { $nodeGet = new DiviNodeGetAbility($loader, $this->design()); $global = $nodeGet->execute(['post_id' => 7, 'id' => 'g0']); $outline = (new DiviOutlineAbility($loader, $this->design()))->execute(['post_id' => 7]); self::assertNull($global['global']['id'], $case); self::assertSame('divi/section', $global['global']['block_name'], $case); self::assertStringContainsString('Divi-Bibliothek', $global['global']['hint'], $case); self::assertArrayNotHasKey('globalModule', $global['attrs'], $case); self::assertFalse($global['editable'], $case); self::assertStringNotContainsString('1058', (string) json_encode($global), $case); self::assertStringNotContainsString('1058', (string) json_encode($outline), $case); self::assertStringNotContainsString('1059', (string) json_encode($outline), $case); self::assertStringNotContainsString('[global', (string) json_encode($outline), $case); foreach (array_column($outline['nodes'], 'id') as $id) { $node = $nodeGet->execute(['post_id' => 7, 'id' => $id]); self::assertStringNotContainsString('1058', (string) json_encode($node), $case . ' ' . $id); self::assertStringNotContainsString('1059', (string) json_encode($node), $case . ' ' . $id); } } } /** Ruling P13: "nur lesen" (group read-only) does not hide library IDs. */ public function testAReadOnlyLibraryStillNamesLibraryIds(): void { DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html')); $loader = LibraryFixtures::loader(LibraryFixtures::access([], false)); $global = (new DiviNodeGetAbility($loader, $this->design()))->execute(['post_id' => 7, 'id' => 'g0']); $outline = (new DiviOutlineAbility($loader, $this->design()))->execute(['post_id' => 7]); self::assertSame('1058', $global['global']['id']); self::assertStringContainsString('[global 1058]', $outline['outline']); } public function testOutlineOfALibraryItem(): void { LibraryFixtures::given([LibraryFixtures::item(1059)]); $ability = new DiviOutlineAbility(LibraryFixtures::loader(), $this->design()); $result = $ability->execute(['post_id' => 1059]); self::assertSame(['m0'], array_column($result['nodes'], 'id')); self::assertStringStartsWith('m0 text "Globales Modul Öffnungszeiten"', $result['outline']); self::assertSame('https://kunde.test/wp-admin/post.php?post=1059&action=edit', $result['edit_url']); } public function testNodeGetOfALibraryItemAndNotFoundWithoutTheLibraryRight(): void { LibraryFixtures::given([LibraryFixtures::item(1057, ['post_status' => 'private', 'post_title' => 'Geheim'])]); $ability = new DiviNodeGetAbility(LibraryFixtures::loader(), $this->design()); $result = $ability->execute(['post_id' => 1057, 'id' => 'm0']); self::assertSame('m0', $result['id']); $denied = new DiviNodeGetAbility(LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), $this->design()); $refusal = $denied->permission(['post_id' => 1057, 'id' => 'm0']); self::assertToolError($refusal, 'not_found'); self::assertStringNotContainsString('Geheim', $refusal->get_error_message()); $outline = new DiviOutlineAbility(LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), $this->design()); self::assertToolError($outline->execute(['post_id' => 1057]), 'not_found'); } }