/ tests / SocialComponentTest.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\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\controllers\components\SocialComponent;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\models\WikiModel;
use seekquarry\yioop\library\wiki\WikiParser;
use seekquarry\yioop\controllers\StaticController;

/**
 * StubControllerForSocial is what a case hands a component in place of
 * the controller the component would otherwise live on. A component
 * reaches its controller for two things: the cleaning of values that
 * came in with a request, and the models. The cleaning here is a real
 * controller's, taken from one built without its constructor, since a
 * copy written in this file would check the copy rather than what Yioop
 * runs. The model is whichever stand-in the case put in place, and only
 * the model that knows groups is ever asked for. Cases about the link a
 * witness follows ask for neither and are given neither.
 *
 * @author Chris Pollett
 */
class StubControllerForSocial
{
    /**
     * $group_model is the stand-in handed back when a component asks for
     * the model that knows groups. It stays null until a case that needs
     * one puts it here.
     * @var object
     */
    public $group_model = null;
    /**
     * $cleaner is a real controller, built without running its
     * constructor, whose cleaning the calls below are passed to. A copy
     * of that cleaning written here would be checking the copy rather
     * than what Yioop runs.
     * @var object
     */
    public $cleaner = null;
    /**
     * __construct builds the stand-in, taking the real cleaning from a
     * controller made without its constructor, since the constructor
     * wants a web site and a session that a case does not have.
     */
    public function __construct()
    {
        $reflection = new \ReflectionClass(StaticController::class);
        $this->cleaner = $reflection->newInstanceWithoutConstructor();
    }
    /**
     * clean hands a value to the cleaning a controller does, so a case
     * sees the same result a request would.
     *
     * @param mixed $value what came in with the request
     * @param mixed $type the kind of value it is meant to be
     * @param mixed $default what to give back where the value does not
     *     fit that kind
     * @return mixed the cleaned value
     */
    public function clean($value, $type, $default = null)
    {
        return $this->cleaner->clean($value, $type, $default);
    }
    /**
     * model gives back whichever stand-in model a case put here. Only
     * the model that knows groups is asked for, so the name is not read.
     *
     * @param string $name which model a component is asking for
     * @return object the stand-in model
     */
    public function model($name)
    {
        return $this->group_model;
    }
}
/**
 * StubGroupNamesForSocial stands in for the model that looks a group up
 * by the name a request wrote, so a case can check that naming a group by
 * its name reaches the same group as naming it by its number.
 *
 * @author Chris Pollett
 */
class StubGroupNamesForSocial
{
    /**
     * $names holds the group name each group id answers to, and nothing
     * else is known.
     * @var array
     */
    public $names = ["a-newsroom" => 17];
    /**
     * getGroupId gives the number of the group with this name, and zero
     * where no group answers to it.
     *
     * @param string $name what the request called the group
     * @return int the group's number, or 0
     */
    public function getGroupId($name)
    {
        return $this->names[$name] ?? 0;
    }
}
/**
 * StubGroupModelForSocial is handed to a component in place of the model
 * that knows wiki pages. It knows one page and nothing else, so a case
 * can ask about a page that is there and a page that is not without a
 * database standing behind either answer.
 *
 * @author Chris Pollett
 */
class StubGroupModelForSocial
{
    /**
     * $known_id is the number of the one page this stand-in knows about.
     * A case asking about any other number is answered with false.
     * @var int
     */
    public $known_id = 1055;
    /**
     * $known_name is what the one page this stand-in knows is called,
     * and is what comes back in the record it gives.
     * @var string
     */
    public $known_name = "test2";
    /**
     * getPageInfoByPageId gives back the record for the one page this
     * stand-in knows, and false for any other number.
     *
     * @param int $page_id which page is asked for
     * @return array|bool the record, or false
     */
    public function getPageInfoByPageId($page_id)
    {
        if ($page_id != $this->known_id) {
            return false;
        }
        return ["PAGE_NAME" => $this->known_name, "GROUP_ID" => 8];
    }
}
/**
 * StubSigninModelForSocial is handed to a component in place of the model
 * that holds a round of a secret ballot. It writes down whose parts it
 * was asked to keep and seals nothing, so a case can see which witnesses
 * a round reached without any sealing being done.
 *
 * @author Chris Pollett
 */
class StubSigninModelForSocial
{
    /**
     * $already holds the names of the witnesses whose parts were in the
     * round before the form was sent, which a case sets to say how far
     * along the round is.
     * @var array
     */
    public $already = [];
    /**
     * $kept holds the names of the witnesses this stand-in was asked to
     * keep a part for, which is what a case reads to see who was
     * reached.
     * @var array
     */
    public $kept = [];
    /**
     * witnessRoundKept says the round is open and gives the key its
     * parts would be sealed with. The key is a word rather than a real
     * one, since nothing here seals anything.
     *
     * @param string $round_filepath the file the round waits in
     * @return array what the round holds
     */
    public function witnessRoundKept($round_filepath)
    {
        return ["public" => "a-public-key"];
    }
    /**
     * witnessSharesKept says which witnesses already have a part in the
     * round, which is whatever the case put in $already.
     *
     * @param string $round_filepath the file the round waits in
     * @return array witness name => what they gave
     */
    public function witnessSharesKept($round_filepath)
    {
        return $this->already;
    }
    /**
     * $seeds holds what each witness drew when the ballot began, by
     * their place in the list. Closing a poll reads these back, so a
     * case sets them to say what the ballot began with.
     * @var array
     */
    public $seeds = [];
    /**
     * witnessSeedKept answers with the value drawn at the start of the
     * ballot for whichever place in the list of witnesses is asked for.
     * Where the case put nothing at that place, false comes back, which
     * is how a witness the ballot holds nothing for is set up.
     *
     * @param string $secrets_filepath the ballot's secrets file
     * @param int $at which place in the list of witnesses
     * @return string|bool the value drawn, or false
     */
    public function witnessSeedKept($secrets_filepath, $at)
    {
        return $this->seeds[$at] ?? false;
    }
    /**
     * keepWitnessShare writes down that a part was kept for a witness.
     * The real model seals the part; this one keeps only the name, since
     * what a case checks is which witnesses were reached.
     *
     * @param string $round_filepath the file the round waits in
     * @param string $witness whose part it is
     * @param string $public the key it is sealed with
     * @param string $random the value drawn for them
     * @param string $hash what their password makes
     * @return void nothing is handed back
     */
    public function keepWitnessShare($round_filepath, $witness, $public,
        $random, $hash)
    {
        $this->kept[] = $witness;
    }
}
/**
 * SocialComponentTest holds the cases for the group and wiki component.
 * Four kinds of thing that component does can be looked at without a
 * database or a browser behind them, and those are what is here: the
 * address a witness follows to take their part in a secret ballot, the
 * reading of a vote once one is cast, the marks a front page is built
 * out of, and the fields the wiki and the feed read out of a request.
 *
 * @author Chris Pollett
 */
class SocialComponentTest extends UnitTest
{
    /**
     * $component is the group and wiki component the cases call, built
     * fresh for each one with a stand-in controller behind it.
     * @var object
     */
    public $component;
    /**
     * $parent is the stand-in controller the component holds, kept here
     * so a case can put a model on it before calling the component.
     * @var object
     */
    public $parent;
    /**
     * $clean_array names each wiki field a request may carry and the
     * kind of value it holds, the way the wiki activity names them. The
     * first two are the ones the page cannot do without.
     * @var array
     */
    public $clean_array = ["group_id" => "int", "page_name" => "string",
        "group_name" => 'string', "limit" => 'int', "page" => "string",
        "show" => 'int'];
    /**
     * $strings_array says how long each string field among the wiki
     * fields may be, in characters.
     * @var array
     */
    public $strings_array = [];
    /**
     * setUp builds the component with a stand-in for the controller it
     * would otherwise live on, and names the wiki fields the cases about
     * a request work from. It runs before each case.
     */
    public function setUp()
    {
        /* One case reads wording out of the locale, and the wording is
           only there once a language has been chosen. */
        L\setLocaleObject(C\DEFAULT_LOCALE);
        $this->parent = new StubControllerForSocial();
        $this->component = new SocialComponent($this->parent);
        $this->strings_array = ["page_name" => C\TITLE_LEN,
            "page" => C\MAX_GROUP_PAGE_LEN];
    }
    /**
     * tearDown has nothing to do, since these cases leave nothing behind:
     * each one puts back whatever it changed in the request or the
     * session before it ends.
     */
    public function tearDown()
    {
    }
    /**
     * voteHoldsOnlyTheQuestionsAskedTestCase checks that a vote holds
     * the answers to the questions asked and the names of those
     * questions, made together so the two cannot fall out of step. The
     * sign-in check and the picture puzzle are not questions and are
     * left out of both, so what is read back at the end holds only what
     * people were asked.
     */
    public function voteHoldsOnlyTheQuestionsAskedTestCase()
    {
        list($questions, $answers) = $this->component->voteAnswers(
            ["user_captcha_text", "require_signin", "Bob_Q1_Teach",
            "Bob_Q2_Research"], ["a-puzzle", "a-hash", "3", "5"]);
        $this->assertEqual(["Bob_Q1_Teach", "Bob_Q2_Research"],
            $questions, "only the questions are named");
        $this->assertEqual(["3", "5"], $answers,
            "and the answers beside them are the ones given");
        list($questions, $answers) = $this->component->voteAnswers(
            ["Bob_Q1_Teach"], []);
        $this->assertEqual(["Bob_Q1_Teach"], $questions,
            "a question nobody answered is still named");
        $this->assertEqual([""], $answers,
            "with nothing beside it, so the two stay the same length");
    }
    /**
     * twoHashesAreNamedApartTestCase checks the two hashes a counted
     * ballot writes. A vote carries the hash of the ballot it was cast
     * against, and the results name that column apart from the hash of
     * the ballot as it finished, so a reader can hold the two up against
     * each other.
     */
    public function twoHashesAreNamedApartTestCase()
    {
        $model = new \seekquarry\yioop\models\SigninModel();
        $stem = C\WORK_DIRECTORY . "/temp/hash_names_" . getmypid();
        $round_filepath = $stem . "_round.txt";
        $secrets_filepath = $stem . "_secrets.txt";
        $csv_filepath = $stem . "_votes.csv";
        $form_hash = "names" . getmypid();
        $witnesses = ["bob"];
        file_put_contents($round_filepath, "");
        $public = $model->openWitnessRound($round_filepath, $form_hash);
        $drawn = random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES);
        $model->keepWitnessShare($round_filepath, "bob", $public, $drawn,
            hash('sha256', $drawn . "bob" . "bob-word", true));
        $made = $model->finishWitnessRound($round_filepath, $form_hash,
            $witnesses);
        $model->createBallotFile($secrets_filepath, $form_hash,
            $witnesses, [], $made[0], $made[4]);
        $model->closeWitnessRound($round_filepath, $form_hash);
        $_SESSION['USER_NAME'] = "cate";
        $model->addVote($secrets_filepath, $form_hash, ["Q1"], ["4"]);
        $model->countBallotFile($secrets_filepath, $csv_filepath,
            $form_hash, $witnesses, ["bob-word"]);
        $written = file_get_contents($csv_filepath);
        $this->assertTrue(strpos($written,
            "VOTE_BALLOT_HASH,RECEIPT,ISSUE_Q1") === 0,
            "each vote names the ballot it was cast against");
        $this->assertTrue(strpos($written, "FINAL_BALLOT_HASH") !== false,
            "and the ballot as it finished is named apart from it");
        foreach ([$round_filepath, $secrets_filepath, $csv_filepath] as
            $leftover) {
            if (file_exists($leftover)) {
                unlink($leftover);
            }
        }
    }
    /**
     * troubleIsMarkedToBeShownInRedTestCase checks that something which
     * went wrong is marked to be shown in red, so a refusal does not
     * read like a receipt.
     */
    public function troubleIsMarkedToBeShownInRedTestCase()
    {
        $said = $this->component->ballotTrouble("no good");
        $this->assertTrue(strpos($said, "red") !== false,
            "the marking says red");
        $this->assertTrue(strpos($said, "no good") !== false,
            "and what happened is still there to read");
    }
    /**
     * writingToOneWitnessNamesThemTestCase checks the line the screen
     * shows after invitations go out. Writing to one witness names them,
     * since the screen offers a way of writing to each in turn and a
     * count of one says nothing about which of them it was. Writing to
     * everybody counts them, and writing to nobody says so.
     */
    public function writingToOneWitnessNamesThemTestCase()
    {
        $this->assertTrue(strpos(
            $this->component->ballotInviteSaying(1, "bob"), "bob") !==
            false, "writing to one witness names them");
        $this->assertTrue(strpos(
            $this->component->ballotInviteSaying(3, "*"), "3") !== false,
            "writing to everybody says how many were written to");
        $said = $this->component->ballotInviteSaying(0, "bob");
        $this->assertTrue(strpos($said, "bob") === false && $said !== "",
            "writing to nobody says that rather than naming anybody");
    }
    /**
     * witnessesAreNumberedFromTheFirstTestCase checks the numbering of
     * the witnesses a page names. They are read from the page as it is
     * stored, in order and numbered from the first. Their place in that
     * list says which of the ballot's values belongs to each, so a list
     * numbered from one instead of nought worked every witness out from
     * somebody else's value and left the ballot unable to be counted.
     */
    public function witnessesAreNumberedFromTheFirstTestCase()
    {
        $named = $this->component->witnessesNamedOnPage(
            "<p>A vote.</p>[{secret-ballot|bob|root}]<p>more</p>");
        $this->assertEqual([0 => "bob", 1 => "root"], $named,
            "the two witnesses are numbered from the first");
        $spaced = $this->component->witnessesNamedOnPage(
            "[{secret-ballot| bob | root | cate }]");
        $this->assertEqual(["bob", "root", "cate"], $spaced,
            "spaces around a name are not part of it");
        $this->assertEqual([],
            $this->component->witnessesNamedOnPage("<p>no ballot</p>"),
            "a page with no ballot names nobody");
        $this->assertEqual([],
            $this->component->witnessesNamedOnPage(
            "{{secret-ballot|bob|root}}"),
            "the form somebody types is not the form a page is stored " .
            "in, and is not read as one");
    }
    /**
     * anUntouchedListCountsAsNothingGivenTestCase checks what an
     * untouched list of choices counts as. It counts as nothing given,
     * so a form asking for one is held back rather than accepted. Such a
     * list comes back carrying the value that stands for nothing picked,
     * not an empty one, and a vote page whose questions were all lists
     * was taking an untouched form and giving a receipt.
     */
    public function anUntouchedListCountsAsNothingGivenTestCase()
    {
        foreach (["", "empty", SocialComponent::NOTHING_PICKED] as
            $nothing) {
            $this->assertTrue(
                $this->component->formFieldIsEmpty($nothing),
                "a field carrying " . var_export($nothing, true) .
                " has nothing in it");
        }
        foreach (["3", "0", "a written answer", "choices"] as $given) {
            $this->assertFalse(
                $this->component->formFieldIsEmpty($given),
                "a field carrying " . var_export($given, true) .
                " has been answered");
        }
    }
    /**
     * onlyTypedPasswordsKeepAPartTestCase checks which witnesses a
     * submitted start form keeps a part for. A password typed there
     * keeps that witness's part and only that witness's. A witness who
     * has already acted, at the form or by a mailed link, has no box to
     * type in, and reading a password for them turned every submission
     * into a failed sign-in that said nothing at all.
     */
    public function onlyTypedPasswordsKeepAPartTestCase()
    {
        $model = new StubSigninModelForSocial();
        $model->already = ["root" => ["hash" => "already-in"]];
        $kept = $this->component->keepTypedWitnessParts($model,
            "/tmp/round.txt", ["root", "cate", "dana"],
            [1 => "cate-word"], ["FORM_HASH" => "a-hash"]);
        $this->assertEqual(1, $kept, "one part was kept");
        $this->assertEqual(["cate"], $model->kept,
            "the part kept is the one whose password was typed");
        $model = new StubSigninModelForSocial();
        $kept = $this->component->keepTypedWitnessParts($model,
            "/tmp/round.txt", ["root", "cate"], [],
            ["FORM_HASH" => "a-hash"]);
        $this->assertEqual(0, $kept,
            "typing nothing keeps nothing, which the screen says");
        $this->assertEqual([], $model->kept,
            "and nobody's part is written");
    }
    /**
     * closingAPollUsesTheValueDrawnAtTheStartTestCase checks which
     * value a closing poll works from. It uses the value each witness
     * drew when the ballot began, since the key is rebuilt from those,
     * and passes over a witness the ballot holds nothing for rather than
     * writing a part that could never rebuild it.
     */
    public function closingAPollUsesTheValueDrawnAtTheStartTestCase()
    {
        $model = new StubSigninModelForSocial();
        $model->seeds = [0 => "root-drew-this"];
        $kept = $this->component->keepTypedWitnessParts($model,
            "/tmp/round.txt", ["root", "cate"],
            ["root-word", "cate-word"], ["FORM_HASH" => "a-hash"],
            "/tmp/secrets.txt");
        $this->assertEqual(1, $kept,
            "the witness the ballot has a value for is kept");
        $this->assertEqual(["root"], $model->kept,
            "and the one it has nothing for is passed over");
    }
    /**
     * ballotIdComesFromThePageRecordTestCase checks where the name a
     * ballot's invitations are signed against comes from. It comes from
     * the page's own record, so sending an invitation and following it
     * work out the same name. Signing one name and checking another
     * turned every link away as not one this site had sent.
     */
    public function ballotIdComesFromThePageRecordTestCase()
    {
        $model = new StubGroupModelForSocial();
        $this->assertEqual("test2",
            $this->component->ballotIdForPage($model, 1055),
            "the name is the one the page is stored under");
        $this->assertEqual("",
            $this->component->ballotIdForPage($model, 999),
            "a page that is not there gives nothing to sign against");
    }
    /**
     * ballotInviteLinkNamesTheMachineTestCase checks that the address a
     * witness is mailed names the machine it is on. Such a link is read
     * in somebody's mail rather than on the site, so one beginning at a
     * slash takes the witness nowhere, however right the rest of it
     * is.
     */
    public function ballotInviteLinkNamesTheMachineTestCase()
    {
        $link = $this->component->ballotInviteLink(
            "https://www.pollett.org/", 8, 1055, "root", "start",
            1786063686, "X8QQHDIyRdW5esCGXtV");
        $this->assertTrue(strpos($link,
            "https://www.pollett.org/?c=group") === 0,
            "the address begins with the machine it is on");
        foreach (["group_id=8", "page_id=1055", "ballot_stage=start",
            "ballot_witness=root", "ballot_expires=1786063686",
            "ballot_token=X8QQHDIyRdW5esCGXtV"] as $part) {
            $this->assertTrue(strpos($link, $part) !== false,
                "the address carries $part");
        }
    }
    /**
     * ballotInviteLinkKeepsPortAndPathTestCase checks an invitation
     * sent from a site reached on a port of its own, or living under a
     * path rather than at the root: both are kept in the address. A name
     * that has to be written differently in a web address, such as one
     * holding a space, is written that way.
     */
    public function ballotInviteLinkKeepsPortAndPathTestCase()
    {
        $link = $this->component->ballotInviteLink(
            "http://example.com:8080/yioop/", 2, 7, "ann lee", "count",
            17, "a+token/with=marks");
        $this->assertTrue(strpos($link,
            "http://example.com:8080/yioop/?c=group") === 0,
            "the port and the path are both kept");
        $this->assertTrue(strpos($link,
            "ballot_witness=ann+lee") !== false,
            "a name with a space in it is written for a web address");
        $this->assertTrue(strpos($link,
            "ballot_token=a%2Btoken%2Fwith%3Dmarks") !== false,
            "and so are the marks in a token");
    }
    /**
     * readAs hands a piece of markup to the parser with one engine or
     * the other chosen for it. The cases about front page marks each ask
     * for the same piece both ways, and doing that through here saves
     * every one of them building a group to carry the choice.
     *
     * @param string $markup the markup to read
     * @param int $render_engine which markup to read it as
     * @return string the html that comes out
     */
    public function readAs($markup, $render_engine)
    {
        $parser = new WikiParser("", false);
        return $parser->parse($markup, false, false, $render_engine);
    }
    /**
     * cutStoryClosesWhatItOpenedTestCase checks a story cut short after
     * a number of paragraphs: it closes whatever the cut left open, so
     * what follows it on a front page is not swallowed into it.
     */
    public function cutStoryClosesWhatItOpenedTestCase()
    {
        $said = "<p>One.</p><div class=\"float\"><p>Two.</p>" .
            "<img src=\"a\"><p>Three.</p></div><p>Four.</p>";
        $shown = SocialComponent::htmlUpToParagraphCount($said, 2);
        $this->assertTrue(strpos($shown, "Two.") !== false,
            "the second paragraph is shown");
        $this->assertTrue(strpos($shown, "Three.") === false,
            "the third paragraph is not");
        $this->assertEqual(substr_count($shown, "<div"),
            substr_count($shown, "</div>"),
            "every box the cut left open is closed");
    }
    /**
     * anUncutStoryIsLeftAloneTestCase checks that a story with nothing
     * left open comes back untouched, since closing what is already
     * closed would add marks a writer never wrote.
     */
    public function anUncutStoryIsLeftAloneTestCase()
    {
        $said = "<p>One.</p><p>Two.</p>";
        $this->assertEqual($said,
            SocialComponent::htmlUpToParagraphCount($said, 2),
            "a story shown whole is not added to");
    }
    /**
     * undefinedPlaceIsAHeadingInEitherMarkupTestCase checks a place left
     * empty: it names itself as a heading, and it has to be a heading in
     * the markup the group reads rather than the other one.
     */
    public function undefinedPlaceIsAHeadingInEitherMarkupTestCase()
    {
        $classes = "front-page-lead front-page-undefined";
        $wiki = "{{class=\"" . $classes . "\"\n=Lead Page Undefined=\n}}";
        $markdown = "{{class=\"" . $classes .
            "\"\n# Lead Page Undefined\n}}";
        $from_wiki = $this->readAs($wiki, C\MEDIAWIKI_ENGINE);
        $from_markdown = $this->readAs($markdown, C\MARKDOWN_ENGINE);
        $this->assertTrue(strpos($from_wiki, "<h1") !== false,
            "a wiki heading read as wiki gives a heading");
        $this->assertTrue(strpos($from_markdown, "<h1") !== false,
            "a markdown heading read as markdown gives a heading");
        $this->assertTrue(strpos($from_markdown, "#") === false,
            "the markdown heading leaves no hash for a reader to see");
    }
    /**
     * wrongMarkupShowsItselfToAReaderTestCase checks what a group sees
     * when a place is written in the markup the group does not read: the
     * spelling shows itself rather than being drawn, which is the fault
     * these cases stand guard over.
     */
    public function wrongMarkupShowsItselfToAReaderTestCase()
    {
        $classes = "front-page-lead front-page-undefined";
        $wiki_in_markdown = $this->readAs("{{class=\"" . $classes .
            "\"\n=Lead Page Undefined=\n}}", C\MARKDOWN_ENGINE);
        $this->assertTrue(strpos($wiki_in_markdown, "<h1") === false,
            "a wiki heading read as markdown gives no heading");
        $this->assertTrue(
            strpos($wiki_in_markdown, "=Lead Page Undefined=") !== false,
            "a wiki heading read as markdown shows its equals signs");
    }
    /**
     * articlePlaceIsALinkInEitherMarkupTestCase checks a place holding
     * one article: it is a link to that page, and a page name with a
     * space in it has to survive being made into one.
     */
    public function articlePlaceIsALinkInEitherMarkupTestCase()
    {
        $named = "Some Page";
        $wiki = "{{class=\"front-page-lead\"\n[[" . $named . "]]\n}}";
        $markdown = "{{class=\"front-page-lead\"\n[" . $named . "](" .
            rawurlencode($named) . ")\n}}";
        $from_wiki = $this->readAs($wiki, C\MEDIAWIKI_ENGINE);
        $from_markdown = $this->readAs($markdown, C\MARKDOWN_ENGINE);
        $this->assertTrue(strpos($from_wiki, "<a href=") !== false,
            "a wiki link read as wiki gives a link");
        $this->assertTrue(strpos($from_markdown, "<a href=") !== false,
            "a markdown link read as markdown gives a link");
        $this->assertTrue(strpos($from_markdown, "title=") === false,
            "the space in the page name is not read as a link title");
        $this->assertTrue(strpos($from_markdown, ">Some Page<") !== false,
            "the reader sees the page's own name");
    }
    /**
     * sameUnitHoldsWholeBlocksTestCase checks what a same-unit mark
     * lets a meaning hold: a list and a paragraph rather than only what
     * fits on one line, with the mark itself never shown to a reader.
     */
    public function sameUnitHoldsWholeBlocksTestCase()
    {
        $said = ";'''Voting''': {{same-unit Whether members may vote.\n" .
            "* '''No Voting''' - posts carry no vote.\n" .
            "* '''Vote Up''' - a member may vote up only. }}\n";
        $out = $this->readAs($said, C\MEDIAWIKI_ENGINE);
        $this->assertEqual(1, substr_count($out, "<dt>"),
            "the run gives one term");
        $this->assertEqual(1, substr_count($out, "<dd>"),
            "the run gives one meaning");
        $this->assertTrue(strpos($out, "<ul>") !== false,
            "the meaning holds a list");
        $this->assertTrue(strpos($out, "same-unit") === false,
            "the mark itself is not shown to a reader");
    }
    /**
     * withoutTheMarkAMeaningIsOneLineTestCase checks that a meaning
     * written without the same-unit mark is only what fits on its own
     * line, which is the behavior everything already written depends
     * on.
     */
    public function withoutTheMarkAMeaningIsOneLineTestCase()
    {
        $said = ";Term: just the one line\n* a list of its own\n";
        $out = $this->readAs($said, C\MEDIAWIKI_ENGINE);
        $this->assertTrue(strpos($out, "<dd>") !== false,
            "the term still gives a meaning");
        $this->assertTrue(strpos($out, "<dd><ul>") === false,
            "the list after it is not swallowed into the meaning");
    }
    /**
     * sameUnitStandingAloneIsReadTestCase checks the mark standing on
     * its own, with no enclosing thing to be the content of: it holds
     * what is inside it where it stands rather than being shown to a
     * reader as markup.
     */
    public function sameUnitStandingAloneIsReadTestCase()
    {
        $alone = "{{same-unit Several lines held together. }}\n";
        foreach ([C\MEDIAWIKI_ENGINE, C\MARKDOWN_ENGINE] as $engine) {
            $out = $this->readAs($alone, $engine);
            $this->assertTrue(strpos($out, "same-unit Several") === false,
                "the mark is not shown to a reader");
            $this->assertTrue(
                strpos($out, "Several lines held together.") !== false,
                "what the mark holds is read where it stood");
        }
    }
    /**
     * sameUnitWorksInEveryLineAtATimePlaceTestCase checks that the mark
     * works wherever a piece of the page reads a line at a time, not
     * only beside a term, so a writer need not learn where it may be
     * used.
     */
    public function sameUnitWorksInEveryLineAtATimePlaceTestCase()
    {
        $held = "{{same-unit A line.\n* one }}";
        $places = ["a list item" => "* " . $held . "\n",
            "a table cell" => "{|\n|-\n| " . $held . "\n|}\n",
            "an indent" => ": " . $held . "\n",
            "on its own" => $held . "\n"];
        foreach ($places as $where => $text) {
            $out = $this->readAs($text, C\MEDIAWIKI_ENGINE);
            $this->assertTrue(strpos($out, "same-unit A") === false,
                "the mark is not shown in " . $where);
            $this->assertTrue(strpos($out, "<li>one</li>") !== false,
                "what the mark holds is read in " . $where);
        }
    }
    /**
     * sameUnitMayFollowWordsOnItsLineTestCase checks that the mark may
     * come after words of the writer's own on the same line, as when a
     * list item names what it is about and then holds the rest.
     */
    public function sameUnitMayFollowWordsOnItsLineTestCase()
    {
        $said = "##'''Do this.''' {{same-unit How to do it.\n" .
            "* first\n* second }}\n";
        $out = $this->readAs($said, C\MEDIAWIKI_ENGINE);
        $this->assertTrue(strpos($out, "same-unit How") === false,
            "the mark is not shown to a reader");
        $this->assertTrue(strpos($out, "<b>Do this.</b>") !== false,
            "the writer's own words are kept");
        $this->assertTrue(strpos($out, "<li>first</li>") !== false,
            "what the mark holds is read");
        $this->assertTrue(strpos($out, "<ol>") !== false,
            "the numbered item is still a numbered item");
    }
    /**
     * markdownDefinitionListTakesASameUnitTestCase checks a definition
     * list written in markdown, where the term stands on its own line
     * and the meaning sits beneath it after a colon. Such a meaning may
     * open a same-unit mark just as a wiki one may.
     */
    public function markdownDefinitionListTakesASameUnitTestCase()
    {
        $said = "**Voting**\n: {{same-unit Whether members may vote.\n" .
            "- **No Voting** - posts carry no vote. }}\n\n" .
            "**Feed**\n: The group's posts themselves.\n";
        $out = $this->readAs($said, C\MARKDOWN_ENGINE);
        $this->assertEqual(2, substr_count($out, "<dt>"),
            "both terms give a term");
        $this->assertEqual(2, substr_count($out, "<dd>"),
            "both terms give a meaning");
        $this->assertTrue(strpos($out, "<ul>") !== false,
            "the marked meaning holds a list");
        $this->assertTrue(strpos($out, "same-unit") === false,
            "the mark itself is not shown to a reader");
        $this->assertTrue(strpos($out, "<dd>The group") !== false,
            "the plain meaning is still just its line");
    }
    /**
     * markdownWithoutATermIsNotADefinitionTestCase checks a line opening
     * with a colon under no term: it is not a definition, so ordinary
     * markdown that happens to start that way is left alone.
     */
    public function markdownWithoutATermIsNotADefinitionTestCase()
    {
        $out = $this->readAs("just a paragraph\n\nand another\n",
            C\MARKDOWN_ENGINE);
        $this->assertTrue(strpos($out, "<dl>") === false,
            "plain paragraphs give no definition list");
    }
    /**
     * wrapperAndCategoryListReadTheSameTestCase checks two pieces of a
     * front page against both markups. What wraps a place in its
     * classes, and what asks a category for its articles, are read the
     * same by both, so neither needs a spelling of its own.
     */
    public function wrapperAndCategoryListReadTheSameTestCase()
    {
        $wrapper = "{{class=\"front-page-lead\"\nsome words\n}}";
        foreach ([C\MEDIAWIKI_ENGINE, C\MARKDOWN_ENGINE] as $engine) {
            $out = $this->readAs($wrapper, $engine);
            $this->assertTrue(
                strpos($out, "class=\"front-page-lead\"") !== false,
                "the wrapper gives its classes whichever markup is read");
        }
        $asked = "{{category-list|Sports|front-page-below}}";
        foreach ([C\MEDIAWIKI_ENGINE, C\MARKDOWN_ENGINE] as $engine) {
            $out = $this->readAs($asked, $engine);
            $this->assertTrue(
                strpos($out, "[{category-list|Sports|") !== false,
                "the category ask survives whichever markup is read");
        }
    }
    /**
     * settingsAWriterChoseAreReadTestCase checks that the settings a
     * writer picks on the wiki page settings screen reach the values
     * that go at the head of the page. Splitting the wiki editor left
     * the method that reads them with nobody calling it, so every
     * setting a writer chose was dropped on save while the screen still
     * drew as though it had worked.
     */
    public function settingsAWriterChoseAreReadTestCase()
    {
        $held = $_REQUEST;
        $_REQUEST = ['page_type' => "media_list",
            'page_border' => 'none', 'author' => "Ada Lovelace"];
        $data = ['page_types' => ['standard' => "", 'media_list' => "",
            'presentation' => ""],
            'page_borders' => ['solid' => "", 'none' => ""]];
        $reach = new \ReflectionClass($this->component);
        $read = $reach->getMethod("editWikiHeadVars");
        list($head_vars, $write_head, $set_path, $resource_path_error) =
            $read->invokeArgs($this->component,
            [&$data, [], [], null, ""]);
        $this->assertEqual("media_list", $head_vars['page_type'],
            "the kind of page the writer picked is read");
        $this->assertEqual("none", $head_vars['page_border'],
            "and so is the border");
        $this->assertEqual("Ada Lovelace", $head_vars['author'],
            "and so is a setting that is free text");
        $this->assertTrue($write_head,
            "and the head is marked as needing to be written");
        $_REQUEST = $held;
    }
    /**
     * settingThatIsNotOfferedIsNotTakenTestCase checks that a value the
     * screen does not offer for a setting leaves that setting as the page
     * already had it, so a form sending anything else cannot change it.
     */
    public function settingThatIsNotOfferedIsNotTakenTestCase()
    {
        $held = $_REQUEST;
        $_REQUEST = ['page_type' => "no such kind of page"];
        $data = ['page_types' => ['standard' => "", 'media_list' => ""],
            'page_borders' => ['solid' => "", 'none' => ""]];
        $reach = new \ReflectionClass($this->component);
        $read = $reach->getMethod("editWikiHeadVars");
        list($head_vars, $write_head, $set_path, $resource_path_error) =
            $read->invokeArgs($this->component,
            [&$data, ['page_type' => "standard"], [], null, ""]);
        $this->assertEqual("standard", $head_vars['page_type'],
            "a kind the screen does not offer leaves the page as it was");
        $_REQUEST = $held;
    }
    /**
     * postsTitleAndDescriptionAreCutToLengthTestCase checks that the
     * title and the description a writer typed for a post are read
     * together and each is cut to the length a post may hold. A field
     * the request does not carry comes back as an empty string, since
     * the methods that write a post test for exactly that before they
     * refuse.
     */
    public function postsTitleAndDescriptionAreCutToLengthTestCase()
    {
        $held = $_REQUEST;
        $_REQUEST = ["title" => str_repeat("a", C\TITLE_LEN + 40),
            "description" => str_repeat("b", C\MAX_GROUP_POST_LEN + 40)];
        list($title, $description) = $this->component->feedPostFields();
        $this->assertEqual(C\TITLE_LEN, strlen($title),
            "a title past the length a post may hold is cut to it");
        $this->assertEqual(C\MAX_GROUP_POST_LEN, strlen($description),
            "and so is a description");
        $_REQUEST = [];
        list($title, $description) = $this->component->feedPostFields();
        $this->assertEqual("", $title,
            "a title the request does not carry is an empty string");
        $this->assertEqual("", $description,
            "and so is a description");
        $_REQUEST = $held;
    }
    /**
     * wikiFieldsAreCleanedAndNamedTestCase checks that a wiki request's
     * fields are read into an array of their own. A
     * page name is written with underscores rather than spaces, and the
     * dollar sign is dropped, since both would otherwise reach the name
     * a page is stored under. The wiki activity reads every field it
     * uses out of this array, so what is absent has to be absent rather
     * than left undefined.
     */
    public function wikiFieldsAreCleanedAndNamedTestCase()
    {
        $held = $_REQUEST;
        $held_session = $_SESSION;
        $_SESSION = [];
        $_REQUEST = ["group_id" => "3", "page_name" => "My Page\$Two",
            "limit" => "20"];
        list($fields, $missing) =
            $this->component->cleanWikiRequestFields($this->clean_array,
            $this->strings_array, "admin", C\ROOT_ID);
        $this->assertEqual(3, $fields['group_id'],
            "the group the request names is read as a number");
        $this->assertEqual("My_PageTwo", $fields['page_name'],
            "a space becomes an underscore and the dollar sign goes");
        $this->assertEqual(20, $fields['limit'],
            "a field past the first two is read where it is there");
        $this->assertTrue(!isset($fields['show']),
            "and is absent from the array where it is not");
        $this->assertTrue(!$missing,
            "nothing the page cannot do without is absent");
        $_REQUEST = $held;
        $_SESSION = $held_session;
    }
    /**
     * twoFieldsTheWikiNeedsAreReportedTestCase checks the group and
     * the page name, which are the two fields the wiki cannot do
     * without, so each is reported when it is absent while a field after
     * them is not. This is what sends a writer to the message saying
     * which fields are missing rather than to a page that is not there.
     */
    public function twoFieldsTheWikiNeedsAreReportedTestCase()
    {
        $held = $_REQUEST;
        $held_session = $_SESSION;
        $_SESSION = [];
        $_REQUEST = ["limit" => "10"];
        list($fields, $missing) =
            $this->component->cleanWikiRequestFields($this->clean_array,
            $this->strings_array, "admin", C\ROOT_ID);
        $this->assertTrue($missing,
            "a request naming neither the group nor the page says so");
        $this->assertTrue($fields['group_id'] === false,
            "the group reads as false rather than as a number");
        $this->assertTrue($fields['page_name'] === false,
            "and so does the page name");
        $_REQUEST = $held;
        $_SESSION = $held_session;
    }
    /**
     * groupNamedByNameIsLookedUpTestCase checks that a request may name
     * a group by its name instead of its number, and
     * the name is looked up so that either reaches the same group. A
     * name that answers to a group also settles the report of what is
     * absent, since the group is no longer missing once it is found.
     */
    public function groupNamedByNameIsLookedUpTestCase()
    {
        $held = $_REQUEST;
        $held_session = $_SESSION;
        $_SESSION = [];
        $this->parent->group_model = new StubGroupNamesForSocial();
        $_REQUEST = ["page_name" => "Front", "group_name" => "a-newsroom"];
        list($fields, $missing) =
            $this->component->cleanWikiRequestFields($this->clean_array,
            $this->strings_array, "admin", C\ROOT_ID);
        $this->assertEqual(17, $fields['group_id'],
            "the group the name answers to is the one read");
        $this->assertTrue(!$missing,
            "and the group no longer counts as absent");
        $_REQUEST = ["page_name" => "Front", "group_name" => "no-such"];
        list($fields, $missing) =
            $this->component->cleanWikiRequestFields($this->clean_array,
            $this->strings_array, "admin", C\ROOT_ID);
        $this->assertTrue($fields['group_id'] === false,
            "a name no group answers to leaves the group absent");
        $this->assertTrue($missing,
            "which is still reported");
        $_REQUEST = $held;
        $_SESSION = $held_session;
    }
    /**
     * templatesBoxesAreCleanedOneByOneTestCase checks that a page filled
     * in from a template arrives as a list of boxes rather
     * than as one piece of text, and each box is cleaned and cut on its
     * own. Reading the whole list as one string would have thrown the
     * boxes away.
     */
    public function templatesBoxesAreCleanedOneByOneTestCase()
    {
        $held = $_REQUEST;
        $held_session = $_SESSION;
        $_SESSION = [];
        $_REQUEST = ["group_id" => "3", "page_name" => "Front",
            "page" => ["who" => "a<b>", "what" => "plain"]];
        list($fields, $missing) =
            $this->component->cleanWikiRequestFields($this->clean_array,
            $this->strings_array, "admin", C\ROOT_ID);
        $this->assertEqual("a&lt;b&gt;", $fields['page']["who"],
            "each box is made safe on its own");
        $this->assertEqual("plain", $fields['page']["what"],
            "and a box needing nothing done to it is left as it was");
        $_REQUEST = $held;
        $_SESSION = $held_session;
    }
    /**
     * saveWithNoNameKeepsTheOpenFilesNameTestCase checks that a save
     * of a file kept beside a wiki page writes back to the file that
     * was opened where the writer named none. The Save As dialog fills
     * in the save_as_name field, and an ordinary save leaves it empty,
     * so an empty field has to mean the name the file already had.
     */
    public function saveWithNoNameKeepsTheOpenFilesNameTestCase()
    {
        $held = $_REQUEST;
        unset($_REQUEST['save_as_name']);
        $under = $this->component->saveAsResourceName($this->parent,
            "notes.txt");
        $this->assertEqual($under, "notes.txt",
            "a save naming no file of its own writes back to the one open");
        $_REQUEST['save_as_name'] = "   ";
        $under = $this->component->saveAsResourceName($this->parent,
            "notes.txt");
        $this->assertEqual($under, "notes.txt",
            "and so does one whose name is nothing but spaces");
        $_REQUEST = $held;
    }
    /**
     * saveAsNameCannotLeaveItsFolderTestCase checks that the name a
     * writer types into the Save As dialog is reduced to its last part
     * before anything is written. A name carrying folder marks would
     * otherwise write a file outside the folder the writer is looking
     * at, since the name is joined to that folder's path.
     */
    public function saveAsNameCannotLeaveItsFolderTestCase()
    {
        $held = $_REQUEST;
        $tries = ["report.sh" => "report.sh",
            "  spaced.txt  " => "spaced.txt",
            "../../secrets.txt" => "secrets.txt",
            "sub/deeper/notes.txt" => "notes.txt",
            "..\\..\\windows.txt" => "windows.txt"];
        foreach ($tries as $typed => $wanted) {
            $_REQUEST['save_as_name'] = $typed;
            $under = $this->component->saveAsResourceName($this->parent,
                "notes.txt");
            $this->assertEqual($under, $wanted,
                "a save named $typed writes the file $wanted");
        }
        $_REQUEST = $held;
    }
    /**
     * videoFiguresAreReadThroughThisComponentTestCase checks that
     * videoFiguresFromLibrary is reachable on SocialComponent itself and
     * reads a real video's width, height and running time. The method
     * had sat only on the resource component, a child of this one, while
     * videoProbeInfo here called it as its own, so viewing a video from
     * a media list died on an undefined method.
     */
    public function videoFiguresAreReadThroughThisComponentTestCase()
    {
        $held = file_get_contents(C\PARENT_DIR .
            "/tests/test_files/video_white_mp4.txt");
        $where = C\WORK_DIRECTORY . "/temp/social_video_" . getmypid() .
            ".mp4";
        file_put_contents($where, base64_decode($held));
        $figures = SocialComponent::videoFiguresFromLibrary($where);
        @unlink($where);
        $this->assertTrue(!empty($figures['width']) &&
            $figures['width'] > 0,
            "the video's frame width is read through this component");
        $this->assertTrue(!empty($figures['height']) &&
            $figures['height'] > 0, "and its frame height");
        $this->assertTrue(!empty($figures['duration']) &&
            $figures['duration'] > 0, "and its running time");
    }

    /**
     * newPictureIsMadeAtTheSizeAskedForTestCase checks the bytes the
     * model writes for a new picture.
     *
     * A writer asking the file list for a picture gives a width and a
     * height, and the bytes written have to be a picture a browser can
     * read at that size. A size of nothing, or one past what is allowed,
     * is brought inside the limit rather than refused.
     */
    public function newPictureIsMadeAtTheSizeAskedForTestCase()
    {
        $wiki_model = new WikiModel();
        $bytes = $wiki_model->blankImageBytes(220, 140);
        $picture = imagecreatefromstring($bytes);
        $this->assertTrue($picture !== false,
            "the bytes written are a picture a browser can read");
        $this->assertEqual(imagesx($picture), 220,
            "the picture is as wide as was asked for");
        $this->assertEqual(imagesy($picture), 140,
            "and as tall");
        $small = imagecreatefromstring($wiki_model->blankImageBytes(0, 0));
        $this->assertEqual(imagesx($small), 1,
            "a width of nothing is brought up to one pixel");
        $huge = imagecreatefromstring($wiki_model->blankImageBytes(
            C\MAX_NEW_IMAGE_SIDE + 500, 10));
        $this->assertEqual(imagesx($huge), C\MAX_NEW_IMAGE_SIDE,
            "a width past the limit is brought back to it");
    }
}
X