OrmTester.php

<?php

namespace Tlf\BigDb;

/**
 * Base class for testing:
 *
 * - Create, set props directly, and save
 * - Create Orm, set props from db, modify, save
 * - Delete a saved article
 * - Create, set props from form, and save
 * - Fail when setting from form with invalid Data
 * - Fail when setting from form without appropriate access level.
 * - Phad form can be loaded & printed
 * - Phad view can be loaded & printed
 * - Cannot be read if access is forbidden (such as articles that are private)
 * - Search-related entries are created/deleted/etc
 * - Cannot be created when invalid state exists (i.e. can't CREATE author page for a user who already has one, but CAN modify existing author entry)
 * - Data validation via phad form (crud should inadvertently test this, but we should also make this an explicit test)
 *
 */
abstract class OrmTester extends \Tlf\Tester {


    /**
     * Active test information
     * TODO: Make this into a struct (value object bc PHP doesn't have structs!!!)
     */
    protected array $active_test = [];

    public string $orm_class;
    /**
     * Rows to insert before running this class's tests. Each table will be emptied before inserts
     * array<string table_name, array meta_data>
     * meta_data array <columns: array of strings, rows: array of arrays>
     * columns array<int index, string col_name> array of column names
     * rows array<int row_num, array row> array of rows (row_num is not used)
     * row array<int index, mixed col_value> 
     *
     */
    public array $create_before_test = [];
    public array $valid_form_data;
    public array $invalid_form_data;
    /**
     * array<int index, string method_name> Methods to call after Orm is initialized from database. 
     * These should initialize non-database properties like an article body that is stored on disk and lazy-loaded.
     */
    public array $call_after_dbload = [];

    abstract public function get_bigdb(\PDO $pdo): \Tlf\BigDb;

    /**
     * The seed to use for mt_srand(), which determines the random datasets generated
     */
    public int $seed;

    /**
     * The number of times mt_rand() has been called in total during this execution AFTER setting the seed
     */
    public int $rand_called_count = 0;

    /**
     * Set true to add more verbose debug output
     */
    public bool $verbose = false;

    public function prepare(){
        if (is_file($this->file('test/bootstrap.php'))){
            require($this->file('test/bootstrap.php'));
        }
        
        $this->create_before_test($this->create_before_test);

        if (!isset($this->options['seed'])){
            $this->options['seed'] = mt_rand();
        }

        $this->seed = (int)$this->options['seed'];

        mt_srand($this->options['seed']);

        if (isset($this->options['rand_count'])){
            $c = (int)$this->options['rand_count'];
            while ($c-- > 0){
                $this->rand_called_count++;
                mt_rand();
            }
        }

        if (isset($this->options['verbose'])){
            $this->verbose = true;
        }
    }

    /**
     * @param $table_rows_to_insert see self::create_before_test
     */
    protected function create_before_test(array $table_rows_to_insert){
        $pdo = $this->get_pdo();
        foreach ($table_rows_to_insert as $table => $metadata){
            $column_names = $metadata['columns'];
            $table_rows = $metadata['rows'];
            $pdo->exec("DELETE FROM `$table`");
            if (count($table_rows)==0 || count($column_names) == 0)continue;
            $keyed_rows = array_map(function($row) use ($column_names){return array_combine($column_names, $row);}, $table_rows);
            $this->dbInsertAll($pdo, $table, $keyed_rows);
        }
    } 


    public function get_pdo(): \PDO {
        return $this->getPdoFromSettingsFile($this->file('config/secret.json'));
    }
    public function get_orm(\PDO $pdo, \Tlf\BigDb $db): \Tlf\BigOrm {
        $orm_class = $this->orm_class;
        $orm = new $orm_class($db);
        return $orm;
    }


    /**
     * Get a random set of data from an array of data-options.
     *
     *
     * @param $data_options array<string key, array options> 
     * @param $data_indices array<string key, int index_within_options>
     *
     *
     * @var options array<int index, mixed value_option> 
     * @var value_option mixed any value
     *
     * @return array<string key, mixed value_option>
     */
    public function get_random_data(array $data_options, ?array &$data_indices): array {

        $data_indices = [];

        $out_data = [];
        foreach ($data_options as $key=>$options){
            $start = 0;
            $end = count($options) - 1;
            $this->rand_called_count++;
            $index = mt_rand($start,$end);
            $out_data[$key] = $options[$index];
            $data_indices[$key] = $index;
        }

        return $out_data;
    }

    public function get_data_entries(array $data_options, array $data_indices){

    }
    /**
     *
     * @param $data_options array<string key, array data_options>
     * @param $dataset_indices string like "key:index, key2:index2, key3:index3"
     *
     * @return array<string key, array data_options> except data_options will have only one entry for each key, as determined by dataset_indices
     */
    public function get_data_options_from_dataset_string(array $data_options, string $dataset_indices): array {
        $list = explode(", ",$dataset_indices);
        $return_data = [];
        foreach ($list as $entry){
            $parts = explode(":", $entry);
            $key = $parts[0];
            $index = $parts[1];
            $return_data[$key] = [$data_options[$key][$index]];
        }

        return $return_data;
    }

    /**
     * Run tests on a semi-random set of data, of the given size, built from $valid_data_options, and tested by $test_function.
     * Outputs useful error information
     *
     * CLI Option -num_tests will overwrite $default_num_datasets_to_test
     * CLI Option -data "..." will cause only one test to run, for the given dataset only
     * When you run tests, failed datasets will print their -data option. 
     *
     * The passed $test_function should call `$this->pass_if(statement_that_should_be_true, "Message Describing Test")`
     *
     * @param $valid_data_options array<string key, array value_options> an options set of data to build individual semi-random datasets from.
     * @param $default_num_datasets_to_test The number of datasets to generate. Can be overridden with cli option -num_tests
     * @param $test_function function(\Tlf\BigDb $db, \Tlf\BigFormOrm $orm, array $data_row_to_test, string $id_column_name)
     */
    public function run_valid_data_tests(array $valid_data_options, int $default_num_datasets_to_test, callable $test_function){

        if (empty($valid_data_options)){
            throw new \Exception("Your valid test data array is empty. property \$valid_form_data should be set when using default tests.");
        }

        $num_datasets_to_test = $this->options['num_tests'] ?? $default_num_datasets_to_test;

        if (isset($this->options['data'])){
            $num_datasets_to_test = 1;
            $valid_data_options = $this->get_data_options_from_dataset_string($valid_data_options, $this->options['data']);
        }

        $pdo = $this->get_pdo();
        $db = $this->get_bigdb($pdo);

        //$pass_count = 0;
        //$fail_count = 0;
        $dataset_results = [];

        $i=-1;
        while (++$i < $num_datasets_to_test){

            $failed_assertions_count = $this->assertions['fail'];

            $sample_dataset_rand_count = $this->rand_called_count;
            $dataset = $this->get_random_data($valid_data_options, $dataset_indices);


            $sample_dataset_info = '"'.implode(", ",
                array_map(
                    function(string $k, string $v): string { return "$k:$v"; },
                    array_keys($dataset_indices),
                    $dataset_indices
                )
            ).'"';

            echo "\n### Test $i ###"
                ."\n  Dataset: ".$sample_dataset_info;

            if ($this->verbose){
                echo "\n\n Dataset being tested: \n";
                print_r($dataset);
                echo "\n\n\n --- END dataset being tested --- \n\n\n";
            }

            echo "\n\n";

            $this->active_test = 
            [
                'data' => $dataset,
                'starting_rand_count' => $sample_dataset_rand_count,
                'indices' => $dataset_indices,
                'status' => 'initialized',
                'messages'=> '',
                'test_num'=> $i,
                'passed_tests'=>[],
                'failed_tests'=>[],
            ];

            try {
                echo "\nTest Loop $i";
                $orm = $this->get_orm($pdo, $db); 
                $test_function($db, $orm, $dataset, 'id');
            } catch (\Exception $e){
                $this->active_test['failed_tests'][] = 'Exception: '.$e->getMessage()."\n\n".$e->getTraceAsString();
            }

            if ($failed_assertions_count != $this->assertions['fail']
                //&& count($this->active_test['failed_tests']) == 0
            ){
                $this->active_test['failed_tests'][] = 'ASSERTION FAILURE';
            }

            $testresult = $this->active_test;
            unset($testresult['data']);
            $dataset_results[] = $this->active_test;
        }

        $pass_count = 0;
        $fail_count = 0;

        $failure_descriptions_list = [];
        $failed_sets_list = [];
        foreach ($dataset_results as $dataset_test){
            $passed = false;
            if (count($dataset_test['failed_tests']) > 0){
                $passed = false;
            } else if (count($dataset_test['passed_tests'])==0){
                $passed = false;
            } else {
                $passed = true;
            }
            if ($passed)$pass_count++;
            else $fail_count++;

            $this->handleDidPass($passed);

            if (!$passed){
                $indices = [];
                foreach ($dataset_test['indices'] as $key=>$index){
                    $indices[] = "$key:$index";
                }
                $test_num = $dataset_test['test_num'];
                $sample_dataset_rand_count = $dataset_test['starting_rand_count'];
                $sample_dataset_info = '"'.implode(", ", $indices).'"';
                $failed_sets_list[] = 'Test '.$test_num.': '.$sample_dataset_info. " -rand_count ".$sample_dataset_rand_count; 
                //print_r($dataset_test['failed_tests']);
                $failure_descriptions_list[] = 
                    'Test '.$test_num.': '
                        .implode(", ", $dataset_test['failed_tests']);
            }

        }
        
        echo "\n\n ### RESULTS ###"
            . "\n  Random Dataset Seed: ".$this->seed
            . "\n  Random Called Count: ".$this->rand_called_count
            . "\n  Datasets Tested: ".count($dataset_results)
            . "\n  Num Passed: ". $pass_count
            . "\n  Num Failed: ". $fail_count
            . "\n  Failed sets: "
                . "\n    " . implode("\n    ",$failed_sets_list)
            . "\n  Failed Test Descriptions: "
                . "\n    " . implode("\n    ", $failure_descriptions_list)
            ."\n\n";



        $current_test_name = substr($this->current_test,4); // remove 'test' from the beginning
        $current_test_class = basename(str_replace("\\","/",get_class($this)));

        echo "\n\nRe-run a specific dataset with: \n";
        echo "    phptest -class $current_test_class -test $current_test_name -data $sample_dataset_info";
        echo "\nRe-run this exact test with: \n";
        echo "    phptest -class $current_test_class -test $current_test_name -seed ".$this->seed;
        echo "\nRe-run a specific dataset WITH a specified seed: \n";
        echo "    phptest -class $current_test_class -test $current_test_name -data $sample_dataset_info -seed ".$this->seed." -rand_count ".$sample_dataset_rand_count;
        echo "\n";

    }

    public function pass_if(bool $condition_is_true, string $test_description){
        if ($condition_is_true){
            $this->active_test['passed_tests'][] = $test_description;
        } else {
            $this->active_test['failed_tests'][] = $test_description;
        }
    }

    /**
     * For the created_at and updated_at properties on `$orm`, set the milliseconds to zero, if those properties are DateTime objects.
     */
    public function remove_datetime_milliseconds(\Tlf\BigOrm $orm){
        // remove millisecondss from DateTime properties bc MySql throws away millis.
        if (isset($orm->created_at)&&$orm->created_at instanceof \DateTime){
            $ca = $orm->created_at;
            $ca->setTime($ca->format('H'), $ca->format('i'), $ca->format('s'), 0);
        }
        if (isset($orm->updated_at)&&$orm->updated_at instanceof \DateTime){
            $ca = $orm->updated_at;
            $ca->setTime($ca->format('H'), $ca->format('i'), $ca->format('s'), 0);
        }
    }




    public function testDeleteRowViaOrm(){
        $this->disable();
        echo "\n\nThis test has not been implemented\n\n";
    }
    public function testDeleteRowCreatedFromFormData(){
        $pdo = $this->get_pdo();
        $db = $this->get_bigdb($pdo);


        $this->run_valid_data_tests(
            $this->valid_form_data,
            $default_test_count = 60,
            function(\Tlf\BigDb $db, \Tlf\BigFormOrm $orm, array $data_row_to_test, string $id_column_name) {

                $orm->set_from_form($data_row_to_test);
                $orm->save();
                $id = $orm->$id_column_name;

                $loaded_orm_list = $db->get($orm->table(),[$id_column_name=>$id]);

                $this->pass_if(($count=count($loaded_orm_list))==1, "Load 1 ORM after save. (Loaded $count)");

                $orm->delete();

                $this->pass_if(!$orm->is_saved(), "Deleted ORM->is_saved() should return false");

                $loaded_orm_list = $db->get($orm->table(),[$id_column_name=>$id]);

                $this->pass_if(($count=count($loaded_orm_list))==0, "Load zero ORMs after delete. (Loaded $count)");
            }
        );
    }

    public function testUpdateRowFromForm(){
        $pdo = $this->get_pdo();
        $db = $this->get_bigdb($pdo);

        $row_index = -1;
        $this->run_valid_data_tests(
            $this->valid_form_data,
            $default_test_count = 60,
            function(\Tlf\BigDb $db, \Tlf\BigFormOrm $orm, array $data_row_to_test, string $id_column_name) use ($row_index) {

                do {
                    $random_data_for_creation = $this->get_random_data($this->valid_form_data, $random_data_indice);
                }
                while ($random_data_for_creation == $data_row_to_test);


                if ($this->verbose){
                    echo "\n\n Random Dataset used to create initial ORM: 
                        \n   (Note: The Dataset being tested (above), will MODIFY the orm/row created by this random dataset)
                        \n";
                    print_r($random_data_for_creation);
                    echo "\n\n\n --- END Random Dataset --- \n\n\n";
                }

                $orm->set_from_form($random_data_for_creation);
                $orm->save();

                $this->remove_datetime_milliseconds($orm);

                $id = $orm->$id_column_name;

                $loaded_orm_list = $db->get($orm->table(),[$id_column_name=>$id]);
                $loaded_orm = $loaded_orm_list[0];


                foreach ($this->call_after_dbload as $method){
                    $loaded_orm->$method();
                }

                if ($this->verbose){
                    echo "\n\n The database row created from the Random Dataset
                        \n   (Note: This data comes from calling \$orm->get_db_row(). The \$orm is hydraded from the actual database row that was created from the random data.) 
                        \n";
                    print_r($random_data_for_creation);
                    echo "\n\n\n --- END Random dataset's database row --- \n\n\n";
                }
                
                $this->test("Check if orm instantiated from array matches orm loaded from database");
                $orms_match = $this->compare_object_properties($orm, $loaded_orm);
                $this->pass_if($orms_match, "Initial ORM (from random data) is identical to loaded orm after it is saved");

                $loaded_orm->set_from_form($data_row_to_test);
                $loaded_orm->save();

                $this->remove_datetime_milliseconds($loaded_orm);

                $loaded_orm_list2 = $db->get($orm->table(),[$id_column_name=>$id]);
                $loaded_orm2 = $loaded_orm_list2[0];

                foreach ($this->call_after_dbload as $method){
                    $loaded_orm2->$method();
                }


                if ($this->verbose){
                    echo "\n\n The final database row after UPDATE. 
                        \n   (Note: Random data creates the row. Test Data modifies the row. THIS is the end result after modification)
                        \n   (Note: This data comes from calling \$orm->get_db_row(). The \$orm is hydraded from the actual database row that was created from the random data.) 
                        \n";
                    print_r($random_data_for_creation);
                    echo "\n\n\n --- END Modified Database Row --- \n\n\n";
                }

                $this->test("Initial ORM (from random data) is DIFFERENT from the Orm loaded after UPDATE");

                $this->invert();
                $orms_are_same = $this->compare_object_properties($orm, $loaded_orm2);
                $this->invert();

                $this->pass_if(!$orms_are_same, "Initial ORM (from random data) is DIFFERENT from the Orm loaded after UPDATE");

                $this->pass_if($this->compare_object_properties($loaded_orm, $loaded_orm2), "Loaded Orm 1 (which was modified by form data) is identical to Loaded Orm 2 (which was loaded from the database after UPDATE)");

            }
        );
    }
    public function testUpdateRowBySettingOrmProperties(){
        $this->disable();
    }

    public function testReadIntoOrmFromDb(){
        $this->disable();
    }
    public function testReadIntoFormFromDb(){
        $this->disable();
    }


    public function testCreateRowFromForm(){

        $pdo = $this->get_pdo();
        $db = $this->get_bigdb($pdo);

        $this->run_valid_data_tests(
            $this->valid_form_data,
            $default_test_count = 60,
            function(\Tlf\BigDb $db, \Tlf\BigFormOrm $orm, array $data_row_to_test, string $id_column_name) {

                //print_r($data_row_to_test);


                $orm->set_from_form($data_row_to_test);
                $orm->save();

                $this->pass_if($orm->is_saved(), 'Orm Saved');

                $loaded_orm_list = $db->get($orm->table(),[$id_column_name=>$orm->$id_column_name]);

                $this->pass_if(($count=count($loaded_orm_list))==1, "Load 1 ORM after save. (Loaded $count)");

                $this->remove_datetime_milliseconds($orm);

                foreach ($this->call_after_dbload as $method){
                    if (!isset($loaded_orm_list[0]))break;
                    $loaded_orm_list[0]->$method();
                }

                $this->pass_if(
                    $this->compare_object_properties($orm, $loaded_orm_list[0]),
                    "Loaded object properties match saved object properties",
                );

            }
        );
    }



    public function testCreateRowBySettingOrmProperties(){
        $this->disable();
        // HOW? Some properties MUST be coerced, like DateTimes
            // Perhaps I can have an interface for get_property_value_options() 
            // Then the default implementation could just get them from this->good_property_values
            // But if you need something non-static, then you can override get_property_value_options()
            // Or I could support some automatic coersion.

        // get valid orm properties
        // instantiate orms
        // set properties directly
        // save to db
        // READ from db
        // Load DB row into Orm
        // Read same properties from Orm
        // Ensure they match original properties
    }




}