<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\tests;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\library\wiki\WikiPagesFile;
use seekquarry\yioop\library as L;
use seekquarry\yioop\models\ImpressionModel;
use seekquarry\yioop\models\Model;
use seekquarry\yioop\models\WikiModel;
use seekquarry\yioop\models\datasources\Sqlite3Manager;
/**
* WikiPagesFileTest checks that the WikiPagesFile class, which reads and
* writes files of exported wiki pages, reads such a file the way PHP would
* if the file were run. createdb, import-public-help and GroupWikiTool's
* yioop import all read pages this way now, so a page the reader split
* or cut short would reach every new site wrong.
*
* @author Chris Pollett
*/
class WikiPagesFileTest extends UnitTest
{
/**
* TEST_FILE is the file of pages written and read back by these cases
*/
const TEST_FILE = __DIR__ . '/test_files/wiki_pages_file_test.php';
/**
* MANY_PAGES is the number of pages eachPageHoldsOnePageAtATimeTestCase
* writes, enough that reading them all at once would show in memory.
*/
const MANY_PAGES = 400;
/**
* PAGE_BYTES is about how many bytes long each of those pages is.
*/
const PAGE_BYTES = 25000;
/**
* setUp does nothing, since nothing needs making before these cases.
*/
public function setUp()
{
}
/**
* tearDown removes the file of pages a case wrote.
*/
public function tearDown()
{
if (file_exists(self::TEST_FILE)) {
unlink(self::TEST_FILE);
}
}
/**
* arraysFromRunning gives the arrays of pages that PHP makes when it
* runs a file of pages, so a case can compare them with what the reader
* finds.
*
* @param string $file_name the file to run
* @return array the arrays the file sets, array name first, keeping
* only the arrays of pages
*/
public function arraysFromRunning($file_name)
{
$public_pages = [];
$help_pages = [];
require $file_name;
return ["public_pages" => $public_pages,
"help_pages" => $help_pages];
}
/**
* shippedFileReadsAsRunTestCase reads the file of public and help
* pages that Yioop ships, src/configs/PublicHelpPages.php, both as text
* and by running it, and checks the two give the same pages. Any page whose
* text the reader ends early or runs past would differ here.
*/
public function shippedFileReadsAsRunTestCase()
{
$file_name = C\BASE_DIR . "/configs/PublicHelpPages.php";
$ran = $this->arraysFromRunning($file_name);
$read = WikiPagesFile::read($file_name);
$this->assertTrue(count($ran["help_pages"], COUNT_RECURSIVE) > 0,
"the shipped file holds help pages");
$this->assertEqual($read["public_pages"], $ran["public_pages"],
"the public pages read as text match running the file");
$this->assertEqual($read["help_pages"], $ran["help_pages"],
"the help pages read as text match running the file");
}
/**
* writtenPagesReadBackTestCase writes pages whose text looks like the
* file's own lines and reads them back. A line that looks like the start of
* a page stays in the page it is in, as does a line where EOD begins a
* longer word. A page with a line PHP would take as its end, the end line
* itself or EOD after spaces and before other text, as PHP 7.3 and later
* end on, is written with another end mark and reads back whole.
*/
public function writtenPagesReadBackTestCase()
{
$looks_like_start = "Before\n\$pages[\"en-US\"][\"Other\"] = " .
"<<< 'EOD'\nAfter\nEODS; are fine";
$pages = ["en-US" => ["Main" => "=Main=\n\nText.",
"Lookalike" => $looks_like_start,
"Holds_End" => "Before\nEOD;\nAfter",
"Holds_Both" => "Before\nEOD;\n EOD_1\nAfter",
"Indented_End" => "Before\n EOD and more\nAfter"],
"fr-FR" => ["Main" => "=Principale=\n"]];
WikiPagesFile::write(self::TEST_FILE,
["pages" => ["Test pages", $pages]], "Test Pages",
"WikiPagesFileTest");
$read = WikiPagesFile::read(self::TEST_FILE);
$this->assertEqual($read, ["pages" => $pages],
"every page reads back as written, those holding an end line too");
$this->assertTrue(@token_get_all(file_get_contents(self::TEST_FILE),
TOKEN_PARSE) !== false, "the file is still valid PHP");
}
/**
* eachPageHoldsOnePageAtATimeTestCase writes a file of 400 pages of
* 25,000 bytes each, 10 MB in all, as a site's export of many pages
* can be, and goes through it with eachPage. Every page comes out
* whole and in order, and the memory in use never grows by more than
* a few pages' worth, since import-public-help, which reads such a
* file, ran out of memory when the whole file was held at once.
*/
public function eachPageHoldsOnePageAtATimeTestCase()
{
$pages = [];
for ($i = 0; $i < self::MANY_PAGES; $i++) {
$pages["en-US"]["Page_$i"] = str_repeat("line $i\n",
intdiv(self::PAGE_BYTES, strlen("line $i\n")));
}
WikiPagesFile::write(self::TEST_FILE,
["public_pages" => ["Test pages", $pages]], "Test Pages",
"WikiPagesFileTest");
$expected_names = array_keys($pages["en-US"]);
$pages = null;
$start = memory_get_usage();
$largest = 0;
$names = [];
$whole = true;
foreach (WikiPagesFile::eachPage(self::TEST_FILE) as [$array_name,
$locale_tag, $page_name, $page]) {
$names[] = $page_name;
$whole = $whole && str_starts_with($page, "line ") &&
strlen($page) > self::PAGE_BYTES - strlen("line 999\n");
$largest = max($largest, memory_get_usage() - $start);
}
$this->assertEqual([$names, $whole], [$expected_names, true],
"every page comes out whole and in order");
$this->assertTrue($largest < 8 * self::PAGE_BYTES,
"the memory in use grows by a few pages, not by the file");
}
/**
* pageTimesReadBackTestCase checks that the time written for a page
* comes back with that page from eachPage, that a page written with no
* time comes back with -1, and that a time is not handed to the page
* after the one it was written for. An import uses the time to date
* each page as the exporting site had it dated.
*/
public function pageTimesReadBackTestCase()
{
$pages = ["en-US" => ["Dated" => "text a", "Undated" => "text b",
"repo$$1" => "an issue"]];
$pubdates = ["en-US" => ["Dated" => 1100000000,
"repo$$1" => 1200000000]];
WikiPagesFile::write(self::TEST_FILE,
["public_pages" => ["Test pages", $pages, $pubdates]],
"Test Pages", "WikiPagesFileTest");
$read = [];
foreach (WikiPagesFile::eachPage(self::TEST_FILE) as [, , $page_name,
$page, $pubdate]) {
$read[$page_name] = [$page, $pubdate];
}
$this->assertEqual($read, ["Dated" => ["text a", 1100000000],
"Undated" => ["text b", -1], "repo$$1" => ["an issue",
1200000000]], "each page comes back with its own time or -1");
}
/**
* pageCommentsReadBackTestCase checks that the posts written for a
* page's thread come back with that page from eachPage, together with
* the page's time, that a page written with no posts comes back with
* null, and that the posts are not handed out as a page of their own.
*/
public function pageCommentsReadBackTestCase()
{
$pages = ["en-US" => ["Talked" => "text a", "Quiet" => "text b"]];
$pubdates = ["en-US" => ["Talked" => 1100000000]];
$posts = [["user_name" => "writer", "title" => "a title",
"description" => "two\nlines and EOD;", "pubdate" => 5]];
$comments = ["en-US" => ["Talked" => $posts]];
WikiPagesFile::write(self::TEST_FILE, ["public_pages" =>
["Test pages", $pages, $pubdates, $comments]], "Test Pages",
"WikiPagesFileTest");
$read = [];
foreach (WikiPagesFile::eachPage(self::TEST_FILE) as [$array_name,
, $page_name, $page, $pubdate, $page_comments]) {
$read["$array_name $page_name"] = [$page, $pubdate,
$page_comments];
}
$this->assertEqual($read, [
"public_pages Talked" => ["text a", 1100000000, $posts],
"public_pages Quiet" => ["text b", -1, null]],
"each page comes back with its own posts or null");
}
/**
* historyAndPartsReadBackTestCase checks that the revisions written for
* a page come back with that page from eachPage, that the kinds of data
* a file is written as carrying come back from exportedParts, that a
* file saying nothing of them gives just issue_pages, as files written
* before they were said carried, and that an array whose pages are
* handed to write by a generator, as a group's discussions are, is
* written and read like any other.
*/
public function historyAndPartsReadBackTestCase()
{
$revisions = [["pubdate" => 1, "user_name" => "writer",
"edit_comment" => "first", "page" => "old\ntext"]];
$threads = (function () {
yield ["thread", "7", '[{"description":"a post"}]'];
})();
WikiPagesFile::write(self::TEST_FILE, [
"pages" => ["Test pages", ["en-US" => ["Kept" => "new text"]],
[], [], fn($locale_tag, $page_name) => $revisions],
"discussions" => ["Test threads", $threads]], "Test Pages",
"WikiPagesFileTest", ["history", "discussions"]);
$read = [];
foreach (WikiPagesFile::eachPage(self::TEST_FILE) as [$array_name,
, $page_name, $page, , , $history]) {
$read["$array_name $page_name"] = [$page, $history];
}
$this->assertEqual($read, ["pages Kept" => ["new text", $revisions],
"discussions 7" => ['[{"description":"a post"}]', null]],
"the page comes back with its revisions, the thread as written");
$this->assertEqual(WikiPagesFile::exportedParts(self::TEST_FILE),
["history", "discussions"], "the file says what it carries");
WikiPagesFile::write(self::TEST_FILE, ["pages" => ["Test pages",
["en-US" => ["Kept" => "text"]]]], "Test Pages",
"WikiPagesFileTest");
$this->assertEqual(WikiPagesFile::exportedParts(self::TEST_FILE),
["issue_pages"], "a file that says nothing carried issue pages");
}
/**
* importedPageLinksReachTheirPagesTestCase checks that a page written
* by the import, upgradePublicHelpWiki, is saved with the link address
* WikiModel::WIKI_LINK_BASE in the Help group as well as in the Public
* group. A page saved with no address, as the import did, drew a link
* such as [[Demos]] as href="Demos", which reaches no page; with the
* address, the link is drawn to the page Demos of the page's own group
* whichever controller draws it. The case writes one page into each of
* the two groups of a copy, in the tests folder, of the database Yioop
* ships in src/data, and deletes them again.
*/
public function importedPageLinksReachTheirPagesTestCase()
{
require_once C\BASE_DIR . "/library/UpgradeFunctions.php";
$page_name = "Zz_Link_Test";
WikiPagesFile::write(self::TEST_FILE, [
"public_pages" => ["Test pages",
["en-US" => [$page_name => "[[Demos]]"]]],
"help_pages" => ["Test pages",
["en-US" => [$page_name => "[[Demos]]"]]]],
"Test Pages", "WikiPagesFileTest");
$folder = C\TEST_DIR . "/test_files/wiki_pages_file_import";
$model = new Model();
$model->db->unlinkRecursive($folder);
L\makePath($folder);
$database = "$folder/public" . uniqid() . ".db";
copy(C\BASE_DIR . "/data/public_default.db", $database);
$wiki_model = new WikiModel();
$wiki_model->db = new Sqlite3Manager();
$wiki_model->db->connect("", "", "", $database);
$wiki_model->resource_path = "$folder/resources";
ImpressionModel::$log_folder = "$folder/impressions";
L\upgradePublicHelpWiki($wiki_model->db, true, self::TEST_FILE);
$linked = [];
foreach ([C\PUBLIC_GROUP_ID, C\HELP_GROUP_ID] as $group_id) {
$result = $wiki_model->db->execute("SELECT PAGE FROM " .
"GROUP_PAGE WHERE GROUP_ID = ? AND TITLE = ? AND " .
"LOCALE_TAG = 'en-US'", [$group_id, $page_name]);
$row = ($result) ? $wiki_model->db->fetchArray($result) : false;
$wiki_model->deleteGroupPage($group_id, $page_name, "en-US");
$linked[] = str_contains($row['PAGE'] ?? "",
WikiModel::WIKI_LINK_BASE . "Demos");
}
$wiki_model->db->disconnect();
ImpressionModel::$log_folder = C\LOG_DIR . "/" .
ImpressionModel::IMPRESSION_FOLDER;
$model->db->unlinkRecursive($folder);
$this->assertEqual($linked, [true, true],
"the link is built from the link address in both groups");
}
/**
* oneGroupImportLeavesTheOtherGroupTestCase checks that a file of just
* the Public group's pages, as an export with -help writes, declares
* only that group, and that importing it with wipe leaves the Help
* group's pages as they were while the Public group keeps just the
* file's page. Before, the wipe deleted every Help page. The shipped
* file declares both groups. The case works on a copy, in the tests
* folder, of the database Yioop ships in src/data.
*/
public function oneGroupImportLeavesTheOtherGroupTestCase()
{
require_once C\BASE_DIR . "/library/UpgradeFunctions.php";
$this->assertEqual(WikiPagesFile::exportedGroups(C\BASE_DIR .
"/configs/PublicHelpPages.php"), ["public_pages", "help_pages"],
"the shipped file declares both groups");
$page_name = "Zz_One_Group_Test";
WikiPagesFile::write(self::TEST_FILE, [
"public_pages" => ["Test pages",
["en-US" => [$page_name => "Only public"]]]],
"Test Pages", "WikiPagesFileTest", ["comments", "history"]);
$this->assertEqual(WikiPagesFile::exportedGroups(self::TEST_FILE),
["public_pages"], "a one group file declares that group");
$folder = C\TEST_DIR . "/test_files/wiki_pages_file_one_group";
$model = new Model();
$model->db->unlinkRecursive($folder);
L\makePath($folder);
$database = "$folder/public" . uniqid() . ".db";
copy(C\BASE_DIR . "/data/public_default.db", $database);
$wiki_model = new WikiModel();
$wiki_model->db = new Sqlite3Manager();
$wiki_model->db->connect("", "", "", $database);
$wiki_model->resource_path = "$folder/resources";
ImpressionModel::$log_folder = "$folder/impressions";
$count = function ($group_id) use ($wiki_model) {
$result = $wiki_model->db->execute("SELECT COUNT(*) AS NUM " .
"FROM GROUP_PAGE WHERE GROUP_ID = ?", [$group_id]);
$row = ($result) ? $wiki_model->db->fetchArray($result) : [];
return intval($row['NUM'] ?? -1);
};
$help_before = $count(C\HELP_GROUP_ID);
L\upgradePublicHelpWiki($wiki_model->db, true, self::TEST_FILE, true);
$help_after = $count(C\HELP_GROUP_ID);
$public_after = $count(C\PUBLIC_GROUP_ID);
$wiki_model->db->disconnect();
ImpressionModel::$log_folder = C\LOG_DIR . "/" .
ImpressionModel::IMPRESSION_FOLDER;
$model->db->unlinkRecursive($folder);
$this->assertTrue($help_before > 0, "the copy holds Help pages");
$this->assertEqual($help_after, $help_before,
"the Help group is left as it was");
$this->assertEqual($public_after, 1,
"the Public group holds just the file's page");
}
}