/ tests / PvqTest.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\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");
    }
}
X