/ tests / WikiParserCorpusTest.php
<?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\controllers\SearchController;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\UnitTest;

/**
 * Locks in what the wiki parser produces today for a broad set of wiki
 * source, so the coming tokenizer-and-parser rewrite can be checked for
 * parity page by page before it ships. Each corpus case has a name, an
 * engine (0 for the mediawiki-style syntax, 1 for the markdown syntax),
 * and its source. Running the parser on the source is compared against a
 * saved reference file under tests/test_files/wiki_corpus.
 *
 * A few cases are marked known-bad in their comment: the parser gets them
 * wrong today (a link inside a heading, a fourth list level, a table
 * inside a list item), and the reference simply records that wrong output
 * so the rewrite's improvement shows up as a reviewed change rather than a
 * silent one.
 *
 * To regenerate the reference files after an intended parser change, run
 * this file's writeReferences method once and review the git diff.
 *
 * @author Chris Pollett
 */
class WikiParserCorpusTest extends UnitTest
{
    /**
     * Folder holding the saved reference output, one file per case.
     */
    const CORPUS_DIR = __DIR__ . "/test_files/wiki_corpus";
    /**
     * No set up being done for the time being
     */
    public function setUp()
    {
    }
    /**
     * No tear down being done for the time being
     */
    public function tearDown()
    {
    }
    /**
     * The corpus: a name-keyed list of cases, each giving the render
     * engine to use and the wiki source to feed the parser. Kept as a
     * static method so the reference-writing tool can read the same list
     * the test reads.
     *
     * @return array the corpus cases keyed by name
     */
    public static function corpus()
    {
        return [
            "mw_headings" => ["engine" => 0, "source" =>
                "= One =\n== Two ==\n=== Three ===\n==== Four ====\n" .
                "===== Five =====\n====== Six ======\n"],
            "mw_format" => ["engine" => 0, "source" =>
                "''italic'' and '''bold''' and '''''both'''''\n"],
            "mw_htmltags" => ["engine" => 0, "source" =>
                "<u>u</u> <sub>sub</sub> <sup>sup</sup> <del>del</del> " .
                "<ins>ins</ins> <s>s</s> <tt>tt</tt>\n"],
            "mw_ulist" => ["engine" => 0, "source" =>
                "* a\n** b\n** c\n*** d\n* e\n"],
            "mw_olist" => ["engine" => 0, "source" =>
                "# a\n## b\n## c\n# d\n"],
            "mw_dlist" => ["engine" => 0, "source" =>
                ";Term 1: Definition 1\n;Term 2: Definition 2\n"],
            "mw_table" => ["engine" => 0, "source" =>
                "{| class=\"wikitable\"\n|+ Caption\n!a!!b\n|-\n" .
                "|c||d\n|}\n"],
            "mw_links" => ["engine" => 0, "source" =>
                "[[Page|text]] and [[http://example.com/|ext]] and " .
                "[[group@Page|inter]] and [[#anchor|jump]]\n"],
            "mw_style" => ["engine" => 0, "source" =>
                "{{center|middle}}\n\n{{style=\"color:red\" red text}}\n"],
            "mw_pre_nowiki" => ["engine" => 0, "source" =>
                "<pre>preformatted '''not bold'''</pre>\n"],
            /* known-bad: the fourth list level renders as a literal star */
            "mw_nest_list4" => ["engine" => 0, "source" =>
                "* a\n** b\n*** c\n**** d\n"],
            /* known-bad: a table inside a list item stays literal text */
            "mw_nest_table_in_list" => ["engine" => 0, "source" =>
                "* before\n* {|\n|a||b\n|}\n* after\n"],
            /* known-bad: a link inside a heading is not stripped for the
               table of contents and the heading fails to render */
            "mw_toc_link" => ["engine" => 0, "source" =>
                "== [[Page|Linked Heading]] ==\nb1\n\n== Second ==\nb2\n" .
                "\n== Third ==\nb3\n\n== Fourth ==\nb4\n"],
            "md_basic" => ["engine" => 1, "source" =>
                "# One\n## Two\n\n**bold** and *italic*\n\n- a\n- b\n"],
            "md_fence" => ["engine" => 1, "source" =>
                "text\n\n```php\n\$x = 1;\n```\n\nmore `inline` code\n"],
            "md_blockquote" => ["engine" => 1, "source" =>
                "> quoted line one\n> quoted line two\n"],
        ];
    }
    /**
     * Parses one corpus case the way the wiki controller would show it. A
     * wiki case is cleaned and then parsed, the way stored wiki text is; a
     * markdown case is parsed straight from its source, the way a git readme
     * blob is, since that path does no cleaning of the text beforehand.
     *
     * @param array $case one corpus entry with engine and source keys
     * @return string the parser's html output for that case
     */
    public static function renderCase($case)
    {
        $parser = new L\WikiParser();
        if ($case["engine"] == 1) {
            return $parser->parse($case["source"], false, false, 1);
        }
        $controller = new SearchController();
        $cleaned = $controller->clean($case["source"], "string");
        return $parser->parse($cleaned, false, false, $case["engine"]);
    }
    /**
     * Writes the reference file for every corpus case. This is a tool for
     * regenerating the saved output after an intended parser change, not a
     * test; the test reads what this writes. Review the git diff of the
     * reference folder afterward.
     *
     * @return void the reference files are written to CORPUS_DIR
     */
    public static function writeReferences()
    {
        if (!is_dir(self::CORPUS_DIR)) {
            mkdir(self::CORPUS_DIR, 0777, true);
        }
        foreach (self::corpus() as $name => $case) {
            file_put_contents(self::CORPUS_DIR . "/" . $name . ".html",
                self::renderCase($case));
        }
    }
    /**
     * Checks that every corpus case still parses to its saved reference
     * output. A difference means the parser's output changed for that
     * case, which is exactly what the coming rewrite must account for.
     */
    public function corpusParityTestCase()
    {
        foreach (self::corpus() as $name => $case) {
            $reference_file = self::CORPUS_DIR . "/" . $name . ".html";
            $expected = is_file($reference_file) ?
                file_get_contents($reference_file) : "MISSING REFERENCE";
            $actual = self::renderCase($case);
            $this->assertEqual($actual, $expected,
                "corpus case '$name' matches its saved reference");
        }
    }
}
X