From 33dc39e8c8b7e4d312e1e33ba15d0ab2a7b26d79 Mon Sep 17 00:00:00 2001 From: prismiwi2015 Date: Fri, 21 Aug 2026 12:39:36 +0200 Subject: [PATCH] The demo survey learns what everyone spent the quarter on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pulse survey gains a repeater — the quarter's projects, one card each: a name, weekly hours, and where it landed — plus a total computed by sum( {projects.hours} ), so the demo exercises every per-row panel the report just learned to draw and runs the aggregate grammar through the real pipeline five hundred times. The population stays deliberate: unhappier people carry more projects at once, happy people shipped theirs, hours split a believable week across however many cards, and one project name in eight is blank so the sub-field's per-row response rate is visibly not 100. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0119U4sRRWGcTQdwYreTwpAp --- includes/demo-data.php | 155 ++++++++++++++++++++++++++++++- tests/phpunit/tests/demoData.php | 110 +++++++++++++++++++++- 2 files changed, 258 insertions(+), 7 deletions(-) diff --git a/includes/demo-data.php b/includes/demo-data.php index f310fb6..47be53e 100644 --- a/includes/demo-data.php +++ b/includes/demo-data.php @@ -107,6 +107,14 @@ * - **A multi-select**, where the percentages deliberately sum past 100 and a * report that divides by the number of answers instead of the number of people * is wrong in a way nobody notices. + * - **A repeater** (the quarter's projects: name, weekly hours, outcome), so the + * report has rows-per-entry to histogram, a numeric sub-field pooled across + * rows, a choice sub-field tallied per row, and a text sub-field whose per-row + * response rate is visibly under 100. + * - **A calculated total** — `sum( {projects.hours} )` — recomputed by the + * pipeline on every submission, so the aggregate grammar is exercised five + * hundred times and its histogram is a distribution the generator never wrote + * directly. * - **An optional free-text box**, answered by well under half — so response rate * is a number that varies per field instead of always being 100. * @@ -116,7 +124,7 @@ */ function atf_demo_survey_schema() { return array( - 'fields' => array( + 'fields' => array( array( 'id' => 'intro', 'type' => 'heading', @@ -192,6 +200,50 @@ function atf_demo_survey_schema() { __( 'More time off', 'allterrain-forms' ), ), ), + array( + 'id' => 'projects', + 'type' => 'repeater', + 'label' => __( 'What did you spend the quarter on?', 'allterrain-forms' ), + 'hint' => __( 'One card per project — add as many as you carried.', 'allterrain-forms' ), + 'required' => true, + 'itemLabel' => __( 'Project', 'allterrain-forms' ), + 'addLabel' => __( 'Add another project', 'allterrain-forms' ), + 'minRows' => 1, + 'maxRows' => 4, + 'fields' => array( + array( + 'id' => 'project_name', + 'type' => 'text', + 'label' => __( 'What was it called?', 'allterrain-forms' ), + ), + array( + 'id' => 'hours', + 'type' => 'number', + 'label' => __( 'Hours a week it took', 'allterrain-forms' ), + 'required' => true, + 'min' => 1, + 'max' => 40, + ), + array( + 'id' => 'outcome', + 'type' => 'select', + 'label' => __( 'Where did it land?', 'allterrain-forms' ), + 'choices' => array( + __( 'Shipped', 'allterrain-forms' ), + __( 'Still in flight', 'allterrain-forms' ), + __( 'Quietly shelved', 'allterrain-forms' ), + ), + ), + ), + ), + array( + 'id' => 'project_hours', + 'type' => 'total', + 'label' => __( 'Weekly hours across your projects', 'allterrain-forms' ), + 'hint' => __( 'Adds itself up as you fill the cards in.', 'allterrain-forms' ), + 'formula' => 'sum( {projects.hours} )', + 'decimals' => 0, + ), array( 'id' => 'comment', 'type' => 'textarea', @@ -428,9 +480,9 @@ function atf_demo_respondent( &$state ) { // unhappy ones left, which is the survivorship the chart should show. $tenure = atf_demo_weighted( array( - 'Less than a year' => 26, - '1 to 2 years' => 32, - '3 to 5 years' => 27, + 'Less than a year' => 26, + '1 to 2 years' => 32, + '3 to 5 years' => 27, 'More than 5 years' => 15, ), $state @@ -530,10 +582,105 @@ function atf_demo_respondent( &$state ) { 'recommend' => (string) $recommend, 'satisfaction' => (string) $satisfaction, 'improve' => $improve, + 'projects' => atf_demo_projects( $morale, $state ), 'comment' => $comment, ); } +/** + * The quarter's projects, as repeater rows. + * + * The shape is deliberate on every axis the report reads: + * + * - **How many** varies from one to four, and unhappier people carry more at + * once — which is one of the reasons they are unhappier, and gives the + * rows-per-entry histogram a slope instead of a spike. + * - **Hours** split a believable working week across the projects, so the + * computed total lands mostly in the twenties and thirties whatever the + * count — the pipeline recomputes `sum( {projects.hours} )` per entry, and + * its distribution has to look like a week, not like noise. + * - **The outcome** tracks morale: happy people shipped, unhappy people + * watched things get quietly shelved. That is a real correlation for the + * per-row choice tally to find. + * - **The name** is blank in about one row in eight, so the sub-field's + * per-row response rate is visibly not 100. + * + * @since 0.1.0 + * + * @param float $morale The respondent's latent morale, 0 to 1. + * @param int $state The generator state. + * @return array[] Rows, keyed by sub-field id. + */ +function atf_demo_projects( $morale, &$state ) { + $count = (int) atf_demo_weighted( + array( + '1' => 24 + $morale * 18, + '2' => 40, + '3' => 22 + ( 1 - $morale ) * 12, + '4' => 6 + ( 1 - $morale ) * 9, + ), + $state + ); + + $names = atf_demo_project_names(); + $projects = array(); + + for ( $i = 0; $i < $count; $i++ ) { + $hours = (int) max( 1, min( 40, round( ( 30 / $count ) * ( 0.6 + atf_demo_random( $state ) * 0.8 ) ) ) ); + + $outcome = atf_demo_weighted( + array( + 'Shipped' => 28 + $morale * 36, + 'Still in flight' => 45, + 'Quietly shelved' => 30 - $morale * 19, + ), + $state + ); + + $name = atf_demo_random( $state ) < 0.125 + ? '' + : $names[ (int) floor( atf_demo_random( $state ) * count( $names ) ) ]; + + $projects[] = array( + 'project_name' => $name, + 'hours' => (string) $hours, + 'outcome' => (string) $outcome, + ); + } + + return $projects; +} + +/** + * Names projects get, at the kind of company that runs pulse surveys. + * + * @since 0.1.0 + * + * @return string[] + */ +function atf_demo_project_names() { + return array( + 'Checkout rewrite', + 'Project Lighthouse', + 'The Big Migration', + 'Onboarding v2', + 'Search that works', + 'Billing cleanup', + 'Mobile parity', + 'Design system', + 'The dashboard', + 'API versioning', + 'Dark mode', + 'Notifications overhaul', + 'Data warehouse', + 'Accessibility pass', + 'Performance sprint', + 'Customer portal', + 'Internal tools', + 'The great renaming', + ); +} + /** * When a respondent answered. * diff --git a/tests/phpunit/tests/demoData.php b/tests/phpunit/tests/demoData.php index feeed64..ad00d48 100644 --- a/tests/phpunit/tests/demoData.php +++ b/tests/phpunit/tests/demoData.php @@ -112,6 +112,24 @@ public function test_survey_covers_the_report() { $nps = array_filter( $schema['fields'], 'atf_analytics_is_nps_field' ); $this->assertNotEmpty( $nps, 'No 0-10 question, so the NPS panel has nothing to draw.' ); + + // The repeater, with enough inside it to exercise every per-row report: + // a numeric sub-field to pool, a choice sub-field to tally, and a text + // sub-field whose response rate is under 100. + $this->assertContains( 'repeater', $types, 'Nothing repeats, so the per-row panels have nothing to draw.' ); + + $repeater = $schema['fields'][ array_search( 'repeater', $types, true ) ]; + $sub_types = wp_list_pluck( $repeater['fields'], 'type' ); + + $this->assertContains( 'number', $sub_types ); + $this->assertContains( 'select', $sub_types ); + $this->assertContains( 'text', $sub_types ); + + // And a total that aggregates the repeater, so the pipeline recomputes + // the new grammar on every one of five hundred submissions. + $total = $schema['fields'][ array_search( 'total', $types, true ) ]; + + $this->assertStringContainsString( '{projects.hours}', $total['formula'] ); } /** @@ -167,6 +185,56 @@ public function test_seeding_stores_entries() { $this->assertArrayHasKey( 'recommend', $values ); } + /** + * The repeater's rows survive the pipeline, and the total is the pipeline's. + * + * The generator never posts `project_hours` — the stored number can only + * have come from the server evaluating `sum( {projects.hours} )` against + * the sanitised rows. This is the aggregate grammar proven end to end, on + * every entry, not on a fixture. + * + * @covers ::atf_demo_projects + */ + public function test_projects_are_stored_and_their_total_is_computed() { + $status = atf_demo_seed( 10 ); + + $this->assertNotWPError( $status ); + + $entries = get_posts( + array( + 'post_type' => ATF_ENTRY_TYPE, + 'post_status' => atf_entry_statuses(), + 'posts_per_page' => -1, + 'meta_key' => ATF_META_FORM, + 'meta_value' => $status['formId'], + ) + ); + + $this->assertNotEmpty( $entries ); + + foreach ( $entries as $entry ) { + $values = json_decode( (string) get_post_meta( $entry->ID, ATF_META_VALUES, true ), true ); + + $this->assertIsArray( $values['projects'] ); + $this->assertGreaterThanOrEqual( 1, count( $values['projects'] ) ); + $this->assertLessThanOrEqual( 4, count( $values['projects'] ) ); + + $hours = 0; + + foreach ( $values['projects'] as $row ) { + $this->assertIsNumeric( $row['hours'], 'A row lost its hours in sanitisation.' ); + + $hours += (float) $row['hours']; + } + + $this->assertEquals( + $hours, + (float) $values['project_hours'], + 'The stored total is not the sum of the rows, so the server never computed it.' + ); + } + } + /** * The submissions are not filed as spam. * @@ -275,15 +343,15 @@ public function test_chunks_do_not_repeat_the_same_people() { * @covers ::atf_demo_respondent */ public function test_the_population_is_varied() { - $state = 12345; - $teams = array(); + $state = 12345; + $teams = array(); $scores = array(); for ( $i = 0; $i < 200; $i++ ) { $person = atf_demo_respondent( $state ); $teams[ $person['team'] ] = true; - $scores[] = (int) $person['recommend']; + $scores[] = (int) $person['recommend']; } $this->assertGreaterThan( 4, count( $teams ), 'Almost everybody is on one team.' ); @@ -301,6 +369,42 @@ public function test_the_population_is_varied() { $this->assertGreaterThan( 0, $nps['detractors'] ); } + /** + * The project rows vary on every axis the per-row report reads. + * + * @covers ::atf_demo_projects + */ + public function test_the_projects_are_varied() { + $state = 4242; + $counts = array(); + $outcomes = array(); + $blank = 0; + $rows = 0; + + for ( $i = 0; $i < 200; $i++ ) { + $projects = atf_demo_respondent( $state )['projects']; + + $counts[ count( $projects ) ] = true; + + foreach ( $projects as $row ) { + ++$rows; + $outcomes[ $row['outcome'] ] = true; + + if ( '' === $row['project_name'] ) { + ++$blank; + } + } + } + + $this->assertGreaterThan( 2, count( $counts ), 'Everybody carries the same number of projects, so the rows-per-entry histogram is a spike.' ); + $this->assertCount( 3, $outcomes, 'An outcome nobody ever reaches renders as a permanent zero.' ); + + // Some names blank, most not — that is what makes the sub-field's + // per-row response rate a number worth drawing. + $this->assertGreaterThan( 0, $blank ); + $this->assertLessThan( $rows / 2, $blank ); + } + /** * The same seed gives the same people. *