<?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\library\av_processing\Pvq;
use seekquarry\yioop\library\av_processing\RangeDecoder;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\UnitTest;
/**
* Checks that the shape of a band of sound is read back from its
* number exactly as the Opus specification numbers them.
*
* The number carries no clue about which shape it means beyond its
* position in an agreed order, so agreeing with that order is the
* whole of being right here. A reader off by one gives a shape that is
* a valid shape, just the wrong one, and the sound that comes out is
* noise rather than anything a later step would reject. Nothing downstream
* would catch it.
*
* For small bands every shape can be checked, and that is what these
* cases do: for each size, every number is turned into a shape, and
* the shape is turned back into a number by the calculation the
* specification gives. Six thousand shapes are covered that way, which
* is every shape of every band up to six slots and five pulses.
*
* @author Chris Pollett
*/
class PvqTest extends UnitTest
{
/**
* Band sizes small enough that every shape can be checked. Widen
* these two lists to sweep further; the case is written to cover
* whatever they hold, and the sizes here are chosen to keep it
* quick rather than because anything beyond them is untested. The
* wider real sizes are covered by the case below.
*/
const SMALL_SLOTS = [2, 3, 4, 5];
/**
* Pulse counts small enough that every shape can be checked
*/
const SMALL_PULSES = [1, 2, 3, 4];
/**
* A starting point for the made up numbers
*/
const SEED = 20260803;
/**
* Brings in the calculation the reader is checked against
*/
public function setUp()
{
if (!class_exists("seekquarry\\yioop\\tests\\PvqIndexer")) {
require_once C\PARENT_DIR . "/tests/test_files/PvqIndexer.php";
}
if (!class_exists("seekquarry\\yioop\\tests\\RangeEncoder")) {
require_once C\PARENT_DIR . "/tests/test_files/RangeEncoder.php";
}
mt_srand(self::SEED);
}
/**
* Nothing needs clearing away after these cases
*/
public function tearDown()
{
}
/**
* Counts every shape of a band by trying every possibility, which
* is the meaning the worked out count has to match
*
* @param int $slots how many slots the band has
* @param int $pulses how many pulses are spread across them
* @return int how many shapes there are
*/
public function countByTrying($slots, $pulses)
{
if ($slots == 0) {
return ($pulses == 0) ? 1 : 0;
}
$total = 0;
for ($here = -$pulses; $here <= $pulses; $here++) {
$total += $this->countByTrying($slots - 1, $pulses - abs($here));
}
return $total;
}
/**
* The worked out count of shapes should match what trying every
* possibility gives
*/
public function countMatchesTryingEverythingTestCase()
{
$wrong = 0;
foreach ([1, 2, 3, 4, 5, 6] as $slots) {
foreach ([0, 1, 2, 3, 4] as $pulses) {
if (Pvq::patternCount($slots, $pulses) !=
$this->countByTrying($slots, $pulses)) {
$wrong++;
}
}
}
$this->assertEqual($wrong, 0,
"every count matches trying every possibility");
}
/**
* Every shape of every small band should come back from its number
* as the shape the specification says that number stands for
*/
public function everySmallShapeTestCase()
{
$checked = 0;
$wrong_order = 0;
$wrong_pulses = 0;
$repeated = 0;
foreach (self::SMALL_SLOTS as $slots) {
foreach (self::SMALL_PULSES as $pulses) {
$total = Pvq::patternCount($slots, $pulses);
$seen = [];
for ($which = 0; $which < $total; $which++) {
$pattern = Pvq::patternFor($slots, $pulses, $which);
$spent = 0;
foreach ($pattern as $slot) {
$spent += abs($slot);
}
if ($spent != $pulses) {
$wrong_pulses++;
}
if (PvqIndexer::numberFor($pattern) != $which) {
$wrong_order++;
}
$key = implode(",", $pattern);
if (isset($seen[$key])) {
$repeated++;
}
$seen[$key] = true;
$checked++;
}
}
}
$this->assertTrue($checked > 1000, "every small shape was checked");
$this->assertEqual($wrong_pulses, 0,
"every shape spends exactly the pulses it was given");
$this->assertEqual($repeated, 0, "no shape comes back twice");
$this->assertEqual($wrong_order, 0,
"every shape is numbered as the specification numbers it");
}
/**
* A band of one slot holds all the pulses, and the number says
* only which way they point
*/
public function oneSlotHoldsEverythingTestCase()
{
$this->assertEqual(Pvq::patternCount(1, 5), 2,
"one slot and five pulses gives two shapes");
$this->assertEqual(Pvq::patternFor(1, 5, 0), [5],
"the first points one way");
$this->assertEqual(Pvq::patternFor(1, 5, 1), [-5],
"the second points the other");
}
/**
* Shapes of the sizes Opus really uses should still come back
* correctly, which is checked at a scattering of numbers since
* there are far too many to check all of them
*/
public function realBandSizesTestCase()
{
$wrong = 0;
$tried = 0;
foreach ([[16, 8], [24, 10], [36, 6], [44, 12], [176, 3]] as $band) {
$slots = $band[0];
$pulses = $band[1];
$total = Pvq::patternCount($slots, $pulses);
$this->assertTrue($total > 0, "$slots slots have shapes");
for ($try = 0; $try < 20; $try++) {
$which = ($try == 0) ? 0 :
(($try == 1) ? $total - 1 : mt_rand(0, $total - 1));
$pattern = Pvq::patternFor($slots, $pulses, $which);
$spent = 0;
foreach ($pattern as $slot) {
$spent += abs($slot);
}
if ($spent != $pulses ||
PvqIndexer::numberFor($pattern) != $which) {
$wrong++;
}
$tried++;
}
}
$this->assertEqual($wrong, 0,
"shapes at real band sizes come back correctly, $tried tried");
}
/**
* The counts grow very large for the wider bands, and have to stay
* exact rather than turning into rounded numbers
*/
public function largeCountsStayExactTestCase()
{
$count = Pvq::patternCount(176, 8);
$this->assertTrue(is_int($count), "the count is still a whole number");
$this->assertTrue($count > 1000000000,
"the count runs past a thousand million");
$pattern = Pvq::patternFor(176, 8, $count - 1);
$this->assertEqual(PvqIndexer::numberFor($pattern), $count - 1,
"the very last shape of a wide band comes back correctly");
}
/**
* A number no shape stands for should be refused rather than
* giving back some other band's shape
*/
public function impossibleNumberIsRefusedTestCase()
{
$refused = 0;
$total = Pvq::patternCount(4, 3);
foreach ([$total, $total + 1, -1] as $which) {
try {
Pvq::patternFor(4, 3, $which);
} catch (\Exception $problem) {
$refused++;
}
}
$this->assertEqual($refused, 3, "every impossible number refused");
}
/**
* A shape read out of a piece of sound should be the shape the
* number in that piece stands for
*/
public function readingFromAPieceTestCase()
{
$slots = 5;
$pulses = 3;
$total = Pvq::patternCount($slots, $pulses);
$wanted = 37 % $total;
/* A piece holding just that number, written the way a piece
holds a number every value of which was as likely as any
other. */
$writer = new RangeEncoder(64);
$writer->encodeNumber($wanted, $total);
$reader = new RangeDecoder($writer->finish());
$pattern = Pvq::readFrom($reader, $slots, $pulses);
$this->assertEqual($pattern, Pvq::patternFor($slots, $pulses,
$wanted), "the shape read matches the number written");
}
}