<?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<b>", $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");
}
}