Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 151 additions & 4 deletions includes/demo-data.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -116,7 +124,7 @@
*/
function atf_demo_survey_schema() {
return array(
'fields' => array(
'fields' => array(
array(
'id' => 'intro',
'type' => 'heading',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down
110 changes: 107 additions & 3 deletions tests/phpunit/tests/demoData.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] );
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.' );
Expand All @@ -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.
*
Expand Down
Loading