= e(trans('backend::lang.form.concurrency_file_changed_description')) ?>
-About content
', $contents); + } + + public function testParseSyntaxFieldsFallsBackOnInvalidContent() + { + $content = '{invalid syntax'; + $this->assertEquals($content, Controller::instance()->parseSyntaxFields($content)); + } +} diff --git a/tests/MenuItemTest.php b/tests/MenuItemTest.php new file mode 100644 index 00000000..667212b5 --- /dev/null +++ b/tests/MenuItemTest.php @@ -0,0 +1,69 @@ + 'Parent', + 'type' => 'static-page', + 'reference' => 'about', + 'viewBag' => ['cssClass' => 'top'], + 'items' => [ + ['title' => 'Child', 'type' => 'url', 'url' => '/child'], + ], + ], + ]); + + $this->assertCount(1, $items); + $this->assertEquals('Parent', $items[0]->title); + $this->assertEquals(['cssClass' => 'top'], $items[0]->viewBag); + $this->assertCount(1, $items[0]->items); + $this->assertEquals('Child', $items[0]->items[0]->title); + $this->assertEquals('/child', $items[0]->items[0]->url); + } + + public function testToArrayIncludesFillableProperties() + { + $item = new MenuItem; + $item->title = 'Example'; + $item->type = 'url'; + $item->url = '/example'; + + $result = $item->toArray(); + + $this->assertEquals('Example', $result['title']); + $this->assertEquals('url', $result['type']); + $this->assertEquals('/example', $result['url']); + $this->assertArrayHasKey('viewBag', $result); + $this->assertArrayNotHasKey('items', $result); + } + + public function testGetTypeOptionsIncludesRegisteredTypes() + { + $options = (new MenuItem)->getTypeOptions(); + + $this->assertArrayHasKey('url', $options); + $this->assertArrayHasKey('header', $options); + $this->assertArrayHasKey('static-page', $options); + $this->assertArrayHasKey('all-static-pages', $options); + } + + public function testGetTypeInfoForStaticPage() + { + $info = MenuItem::getTypeInfo('static-page'); + + $this->assertTrue($info['nesting']); + $this->assertTrue($info['dynamicItems']); + $this->assertArrayHasKey('index', $info['references']); + $this->assertArrayHasKey('about', $info['references']); + } +} diff --git a/tests/MenuTest.php b/tests/MenuTest.php new file mode 100644 index 00000000..d0dde885 --- /dev/null +++ b/tests/MenuTest.php @@ -0,0 +1,88 @@ +theme, 'main-menu.yaml'); + + $this->assertNotNull($menu); + $this->assertEquals('Main Menu', $menu->name); + $this->assertEquals('main-menu', $menu->code); + $this->assertCount(3, $menu->items); + $this->assertEquals('Home', $menu->items[0]->title); + $this->assertEquals('static-page', $menu->items[0]->type); + } + + public function testGenerateReferences() + { + $menu = Menu::load($this->theme, 'main-menu.yaml'); + $references = $menu->generateReferences($this->makeCmsPage()); + + $this->assertCount(3, $references); + + [$home, $about, $external] = $references; + + $this->assertEquals('Home', $home->title); + $this->assertEquals(url('/'), $home->url); + + $this->assertEquals('About', $about->title); + $this->assertEquals(url('/about'), $about->url); + $this->assertCount(1, $about->items, 'Nested item should include the subpage'); + $this->assertEquals('Team', $about->items[0]->title); + + $this->assertEquals('External', $external->title); + $this->assertEquals('https://example.com', $external->url); + } + + public function testGenerateReferencesWithReplaceExpandsGeneratedItems() + { + $menu = Menu::load($this->theme, 'all-pages.yaml'); + $references = $menu->generateReferences($this->makeCmsPage()); + + $titles = array_map(function($reference) { + return $reference->title; + }, $references); + + $this->assertContains('Home', $titles); + $this->assertContains('About', $titles); + $this->assertNotContains('All pages', $titles, 'Replaced item should not appear itself'); + $this->assertNotContains('Hidden', $titles, 'Pages hidden from navigation should be excluded'); + } + + public function testSetCodeRenamesFile() + { + $menu = new Menu; + $menu->code = 'footer'; + + $this->assertEquals('footer.yaml', $menu->fileName); + } + + public function testValidationRejectsInvalidCode() + { + $menu = Menu::inTheme($this->theme); + $menu->fill([ + 'name' => 'Bad Menu', + 'code' => 'bad code!', + ]); + + $this->expectException(\October\Rain\Halcyon\Exception\ModelException::class); + $menu->save(); + } + + /** + * makeCmsPage returns a host page for reference generation + */ + protected function makeCmsPage() + { + return CmsPage::inTheme($this->theme); + } +} diff --git a/tests/PageListTest.php b/tests/PageListTest.php new file mode 100644 index 00000000..821bfd26 --- /dev/null +++ b/tests/PageListTest.php @@ -0,0 +1,75 @@ +theme); + $pages = $pageList->listPages(); + + $fileNames = $pages->map(function($page) { + return $page->getBaseFileName(); + })->all(); + + sort($fileNames); + $this->assertEquals(['about', 'about-team', 'hidden-page', 'index', 'sidebar-page'], $fileNames); + } + + public function testGetPageTree() + { + $pageList = new PageList($this->theme); + $tree = $pageList->getPageTree(); + + $this->assertCount(4, $tree); + $this->assertEquals('index', $tree[0]->page->getBaseFileName()); + $this->assertEquals('about', $tree[1]->page->getBaseFileName()); + $this->assertCount(1, $tree[1]->subpages); + $this->assertEquals('about-team', $tree[1]->subpages[0]->page->getBaseFileName()); + } + + public function testGetPageParent() + { + $pageList = new PageList($this->theme); + + $child = Page::load($this->theme, 'about-team'); + $this->assertEquals('about', $pageList->getPageParent($child)); + + $root = Page::load($this->theme, 'about'); + $this->assertNull($pageList->getPageParent($root)); + } + + public function testGetPageSubTree() + { + $pageList = new PageList($this->theme); + + $page = Page::load($this->theme, 'about'); + $subTree = $pageList->getPageSubTree($page); + + $this->assertArrayHasKey('about-team', $subTree); + } + + public function testUpdateStructure() + { + $pageList = new PageList($this->theme); + + $pageList->updateStructure([ + 'index' => [], + 'about' => [ + 'about-team' => [], + 'sidebar-page' => [], + ], + 'hidden-page' => [], + ]); + + $page = Page::load($this->theme, 'sidebar-page'); + $this->assertEquals('about', $pageList->getPageParent($page)); + } +} diff --git a/tests/PageLocaleTest.php b/tests/PageLocaleTest.php new file mode 100644 index 00000000..50f18908 --- /dev/null +++ b/tests/PageLocaleTest.php @@ -0,0 +1,37 @@ +theme, 'about'); + $mirror = PageLocale::findLocale('fr', $page); + + $this->assertNotNull($mirror); + $this->assertEquals('À propos', $mirror->getViewBag()->property('title')); + $this->assertEquals('Contenu à propos
', trim($mirror->markup)); + } + + public function testFindLocaleReturnsNullWhenMissing() + { + $page = Page::load($this->theme, 'index'); + + $this->assertNull(PageLocale::findLocale('fr', $page)); + $this->assertNull(PageLocale::findLocale('de', $page)); + } + + public function testTranslatableUrlFallsBackToBaseUrl() + { + $page = Page::load($this->theme, 'about'); + + $this->assertEquals('/a-propos', array_get($page->attributes, 'viewBag.localeUrl.fr')); + } +} diff --git a/tests/PagesPluginTestCase.php b/tests/PagesPluginTestCase.php new file mode 100644 index 00000000..4620dead --- /dev/null +++ b/tests/PagesPluginTestCase.php @@ -0,0 +1,126 @@ +theme = Theme::load('test'); + $this->resetPluginCaches(); + $this->snapshotThemeFiles(); + } + + /** + * tearDown the test case + */ + public function tearDown(): void + { + $this->restoreThemeFiles(); + $this->resetPluginCaches(); + + parent::tearDown(); + } + + /** + * resetPluginCaches clears static caches held between requests + */ + protected function resetPluginCaches() + { + (new Router($this->theme))->clearCache(); + Page::clearMenuCache($this->theme); + \RainLab\Pages\Classes\Controller::forgetInstance(); + + $this->resetStaticProperty(Page::class, 'menuTreeCache', null); + $this->resetStaticProperty(PageList::class, 'configCache', false); + } + + /** + * resetStaticProperty forces a static property back to its initial value + */ + protected function resetStaticProperty(string $class, string $property, $value) + { + $reflection = new ReflectionProperty($class, $property); + $reflection->setAccessible(true); + $reflection->setValue(null, $value); + } + + /** + * snapshotThemeFiles records the fixture theme contents + */ + protected function snapshotThemeFiles() + { + $this->themeSnapshot = []; + + foreach (File::allFiles($this->theme->getPath()) as $file) { + $this->themeSnapshot[$file->getPathname()] = file_get_contents($file->getPathname()); + } + } + + /** + * restoreThemeFiles reverts the fixture theme to its recorded state + */ + protected function restoreThemeFiles() + { + if (!$this->themeSnapshot) { + return; + } + + foreach (File::allFiles($this->theme->getPath()) as $file) { + if (!array_key_exists($file->getPathname(), $this->themeSnapshot)) { + $this->deleteWithRetry($file->getPathname()); + } + } + + foreach ($this->themeSnapshot as $path => $contents) { + if (!File::exists($path) || file_get_contents($path) !== $contents) { + File::put($path, $contents); + } + } + } + + /** + * deleteWithRetry deletes a file, retrying while external processes hold a lock + */ + protected function deleteWithRetry(string $path) + { + for ($attempt = 0; $attempt < 5; $attempt++) { + File::delete($path); + clearstatcache(true, $path); + + if (!File::exists($path)) { + return; + } + + usleep(100000); + } + } +} diff --git a/tests/RouterTest.php b/tests/RouterTest.php new file mode 100644 index 00000000..a0149a7e --- /dev/null +++ b/tests/RouterTest.php @@ -0,0 +1,42 @@ +theme); + + $page = $router->findByUrl('/'); + $this->assertNotNull($page); + $this->assertEquals('index', $page->getBaseFileName()); + + $page = $router->findByUrl('/about'); + $this->assertEquals('about', $page->getBaseFileName()); + + $page = $router->findByUrl('/about/team'); + $this->assertEquals('about-team', $page->getBaseFileName()); + } + + public function testFindByUrlIsCaseInsensitive() + { + $router = new Router($this->theme); + + $page = $router->findByUrl('/About'); + $this->assertNotNull($page); + $this->assertEquals('about', $page->getBaseFileName()); + } + + public function testFindByUrlReturnsNullForUnknownUrl() + { + $router = new Router($this->theme); + + $this->assertNull($router->findByUrl('/missing')); + } +} diff --git a/tests/StaticPageTest.php b/tests/StaticPageTest.php new file mode 100644 index 00000000..a41f7a43 --- /dev/null +++ b/tests/StaticPageTest.php @@ -0,0 +1,218 @@ +theme, 'about'); + + $this->assertNotNull($page); + $this->assertEquals('About', $page->getViewBag()->property('title')); + $this->assertEquals('/about', $page->getViewBag()->property('url')); + $this->assertEquals('About content
', trim($page->markup)); + } + + public function testUrlHelper() + { + $this->assertEquals(url('/about'), Page::url('about')); + $this->assertNull(Page::url('missing-page')); + $this->assertNull(Page::url('')); + } + + public function testGetParentAndChildren() + { + $page = Page::load($this->theme, 'about'); + $children = $page->getChildren(); + + $this->assertCount(1, $children); + $this->assertEquals('about-team', $children[0]->getBaseFileName()); + + $child = Page::load($this->theme, 'about-team'); + $parent = $child->getParent(); + + $this->assertNotNull($parent); + $this->assertEquals('about', $parent->getBaseFileName()); + } + + public function testGetLayoutOptions() + { + $page = Page::inTheme($this->theme); + $options = $page->getLayoutOptions(); + + $this->assertArrayHasKey('default', $options); + $this->assertArrayHasKey('sidebar', $options); + $this->assertArrayNotHasKey('plain', $options); + $this->assertEquals('Default layout', $options['default']); + } + + public function testListLayoutPlaceholders() + { + $page = Page::load($this->theme, 'sidebar-page'); + $placeholders = $page->listLayoutPlaceholders(); + + $this->assertArrayHasKey('sidebar', $placeholders); + $this->assertEquals('Sidebar', $placeholders['sidebar']['title']); + $this->assertEquals('html', $placeholders['sidebar']['type']); + + $this->assertArrayHasKey('notes', $placeholders); + $this->assertEquals('text', $placeholders['notes']['type']); + } + + public function testGetPlaceholdersAttribute() + { + $page = Page::load($this->theme, 'sidebar-page'); + $placeholders = $page->placeholders; + + $this->assertArrayHasKey('sidebar', $placeholders); + $this->assertEquals('Sidebar placeholder content
', trim($placeholders['sidebar'])); + } + + public function testSetPlaceholdersRendersPutBlocks() + { + $page = Page::load($this->theme, 'sidebar-page'); + + $page->placeholders = [ + 'sidebar' => 'Updated
', + 'unknown' => 'Not defined by the layout
', + ]; + + $this->assertStringContainsString('{% put sidebar %}', $page->code); + $this->assertStringContainsString('Updated
', $page->code); + $this->assertStringNotContainsString('unknown', $page->code); + } + + public function testCreatePageAppendsToMeta() + { + $page = Page::inTheme($this->theme); + $page->fill([ + 'settings' => [ + 'viewBag' => [ + 'title' => 'New Page', + 'url' => '/new-page', + 'layout' => 'default', + ], + ], + 'markup' => 'New page content
', + ]); + $page->save(); + + $this->assertEquals('new-page.htm', $page->fileName); + $this->assertFileExists($this->theme->getPath().'/content/static-pages/new-page.htm'); + + $pageList = new PageList($this->theme); + $tree = $pageList->getPageTree(true); + $names = array_map(function($node) { + return $node->page->getBaseFileName(); + }, $tree); + + $this->assertContains('new-page', $names); + } + + public function testDeletePageRemovesChildrenAndMeta() + { + $page = Page::load($this->theme, 'about'); + $deleted = $page->delete(); + + sort($deleted); + $this->assertEquals(['about', 'about-team'], $deleted); + $this->assertFileDoesNotExist($this->theme->getPath().'/content/static-pages/about.htm'); + $this->assertFileDoesNotExist($this->theme->getPath().'/content/static-pages/about-team.htm'); + $this->assertFileDoesNotExist($this->theme->getPath().'/content/static-pages-fr/about.htm'); + } + + public function testValidationRequiresTitleAndUrl() + { + $page = Page::inTheme($this->theme); + $page->fill([ + 'settings' => [ + 'viewBag' => [ + 'title' => '', + 'url' => '/valid-url', + ], + ], + ]); + + $this->expectException(\October\Rain\Halcyon\Exception\ModelException::class); + $page->save(); + } + + public function testValidationRejectsMalformedUrl() + { + $page = Page::inTheme($this->theme); + $page->fill([ + 'settings' => [ + 'viewBag' => [ + 'title' => 'Bad URL', + 'url' => 'no-leading-slash', + ], + ], + ]); + + $this->expectException(\October\Rain\Halcyon\Exception\ModelException::class); + $page->save(); + } + + public function testValidationRejectsDuplicateUrl() + { + $page = Page::inTheme($this->theme); + $page->fill([ + 'settings' => [ + 'viewBag' => [ + 'title' => 'Duplicate', + 'url' => '/about', + ], + ], + ]); + + $this->expectException(\October\Rain\Halcyon\Exception\ModelException::class); + $page->save(); + } + + public function testResolveMenuItemForStaticPage() + { + $item = new \RainLab\Pages\Classes\MenuItem; + $item->type = 'static-page'; + $item->reference = 'about'; + $item->nesting = true; + + $result = Page::resolveMenuItem($item, url('/'), $this->theme); + + $this->assertEquals(url('/about'), $result['url']); + $this->assertFalse($result['isActive']); + $this->assertCount(1, $result['items']); + $this->assertEquals('Team', $result['items'][0]['title']); + } + + public function testResolveMenuItemForAllPagesSkipsHiddenNavigation() + { + $item = new \RainLab\Pages\Classes\MenuItem; + $item->type = 'all-static-pages'; + + $result = Page::resolveMenuItem($item, url('/'), $this->theme); + $titles = array_column($result['items'], 'title'); + + $this->assertContains('Home', $titles); + $this->assertContains('About', $titles); + $this->assertNotContains('Hidden', $titles); + } + + public function testGetMenuTypeInfo() + { + $info = Page::getMenuTypeInfo('static-page'); + + $this->assertTrue($info['nesting']); + $this->assertTrue($info['dynamicItems']); + $this->assertArrayHasKey('about', $info['references']); + + $info = Page::getMenuTypeInfo('all-static-pages'); + $this->assertTrue($info['dynamicItems']); + } +} diff --git a/tests/fixtures/themes/test/content/static-pages-fr/about.htm b/tests/fixtures/themes/test/content/static-pages-fr/about.htm new file mode 100644 index 00000000..5c8a3a38 --- /dev/null +++ b/tests/fixtures/themes/test/content/static-pages-fr/about.htm @@ -0,0 +1,5 @@ +## +[viewBag] +title = "À propos" +== +Contenu à propos
diff --git a/tests/fixtures/themes/test/content/static-pages/about-team.htm b/tests/fixtures/themes/test/content/static-pages/about-team.htm new file mode 100644 index 00000000..465022e6 --- /dev/null +++ b/tests/fixtures/themes/test/content/static-pages/about-team.htm @@ -0,0 +1,9 @@ +## +[viewBag] +title = "Team" +url = "/about/team" +layout = "default" +is_hidden = 0 +navigation_hidden = 0 +== +Team content
diff --git a/tests/fixtures/themes/test/content/static-pages/about.htm b/tests/fixtures/themes/test/content/static-pages/about.htm new file mode 100644 index 00000000..834912f9 --- /dev/null +++ b/tests/fixtures/themes/test/content/static-pages/about.htm @@ -0,0 +1,10 @@ +## +[viewBag] +title = "About" +url = "/about" +layout = "default" +is_hidden = 0 +navigation_hidden = 0 +localeUrl[fr] = "/a-propos" +== +About content
diff --git a/tests/fixtures/themes/test/content/static-pages/hidden-page.htm b/tests/fixtures/themes/test/content/static-pages/hidden-page.htm new file mode 100644 index 00000000..db89aa25 --- /dev/null +++ b/tests/fixtures/themes/test/content/static-pages/hidden-page.htm @@ -0,0 +1,9 @@ +## +[viewBag] +title = "Hidden" +url = "/hidden" +layout = "default" +is_hidden = 0 +navigation_hidden = 1 +== +Hidden from navigation
diff --git a/tests/fixtures/themes/test/content/static-pages/index.htm b/tests/fixtures/themes/test/content/static-pages/index.htm new file mode 100644 index 00000000..49f6c14b --- /dev/null +++ b/tests/fixtures/themes/test/content/static-pages/index.htm @@ -0,0 +1,9 @@ +## +[viewBag] +title = "Home" +url = "/" +layout = "default" +is_hidden = 0 +navigation_hidden = 0 +== +Home content
diff --git a/tests/fixtures/themes/test/content/static-pages/sidebar-page.htm b/tests/fixtures/themes/test/content/static-pages/sidebar-page.htm new file mode 100644 index 00000000..10447681 --- /dev/null +++ b/tests/fixtures/themes/test/content/static-pages/sidebar-page.htm @@ -0,0 +1,13 @@ +## +[viewBag] +title = "Sidebar Page" +url = "/sidebar-page" +layout = "sidebar" +is_hidden = 0 +navigation_hidden = 0 +== +{% put sidebar %} +Sidebar placeholder content
+{% endput %} +== +Sidebar page content
diff --git a/tests/fixtures/themes/test/content/welcome.htm b/tests/fixtures/themes/test/content/welcome.htm new file mode 100644 index 00000000..2de60c79 --- /dev/null +++ b/tests/fixtures/themes/test/content/welcome.htm @@ -0,0 +1 @@ +Welcome content block
diff --git a/tests/fixtures/themes/test/layouts/default.htm b/tests/fixtures/themes/test/layouts/default.htm new file mode 100644 index 00000000..90ff1172 --- /dev/null +++ b/tests/fixtures/themes/test/layouts/default.htm @@ -0,0 +1,10 @@ +description = "Default layout" + +[staticPage] +default = 1 +== + + + {% page %} + + diff --git a/tests/fixtures/themes/test/layouts/plain.htm b/tests/fixtures/themes/test/layouts/plain.htm new file mode 100644 index 00000000..11c93d2b --- /dev/null +++ b/tests/fixtures/themes/test/layouts/plain.htm @@ -0,0 +1,5 @@ +description = "Plain layout without the staticPage component" +== + + {% page %} + diff --git a/tests/fixtures/themes/test/layouts/sidebar.htm b/tests/fixtures/themes/test/layouts/sidebar.htm new file mode 100644 index 00000000..51a5cc2e --- /dev/null +++ b/tests/fixtures/themes/test/layouts/sidebar.htm @@ -0,0 +1,11 @@ +description = "Sidebar layout" + +[staticPage] +== + + += e(trans($this->noRecordsMessage)) ?>
- - - - - diff --git a/widgets/menulist/partials/_menus.htm b/widgets/menulist/partials/_menus.htm deleted file mode 100644 index c5f03acd..00000000 --- a/widgets/menulist/partials/_menus.htm +++ /dev/null @@ -1,10 +0,0 @@ -= trans($this->noRecordsMessage) ?>
- - - \ No newline at end of file diff --git a/widgets/pagelist/partials/_pages.htm b/widgets/pagelist/partials/_pages.htm deleted file mode 100644 index 4748886c..00000000 --- a/widgets/pagelist/partials/_pages.htm +++ /dev/null @@ -1,12 +0,0 @@ -= e(trans($this->noRecordsMessage)) ?>
- - - - - diff --git a/widgets/templatelist/partials/_sorting-options.htm b/widgets/templatelist/partials/_sorting-options.htm deleted file mode 100644 index 4aacc073..00000000 --- a/widgets/templatelist/partials/_sorting-options.htm +++ /dev/null @@ -1,7 +0,0 @@ -sortingProperties as $propertyName=>$propertyTitle): ?> -