File: /home/xedaptot/be.naniguide.com/tests/Feature/AdsR14AbTestRedesignTest.php
<?php
/**
* Ads Phase R14 — A/B Test Redesign with statistical winner detection.
*
* Verifies:
* 1. erf approximation accuracy at known reference points (vs. textbook
* values) — locks Abramowitz & Stegun 7.1.26 implementation
* 2. Two-proportion z-test on CTR — direction, magnitude, edge cases
* (zero impressions, identical rates, all-or-nothing)
* 3. Welch's t-test on CPA — direction, sign convention (positive →
* lower CPA / better variant), edge cases (zero conversions)
* 4. Min-sample gate — INSUFFICIENT_CONVERSIONS when < 100 conv AND
* < 7 days; PASSED when either condition is met
* 5. Evaluator writes one AdAbTestEvaluationLog per call (audit trail
* always captured, even for skipped tests)
* 6. Winner declaration uses confidence from latest evaluation log
* (NOT hardcoded 95.0 — the R14 invariant)
* 7. Auto-pause-losers calls AdPublishingService::pause() on the parent
* campaign when winner is declared
* 8. EvaluateAdAbTests job evaluates every running test + skips
* non-running tests
*/
use App\Jobs\Ads\EvaluateAdAbTests;
use App\Model\AdAbTest;
use App\Model\AdAbTestEvaluationLog;
use App\Model\AdAbVariant;
use App\Model\AdCampaign;
use App\Services\Ads\AdAbTestEvaluator;
use App\Services\Ads\AdAbTestService;
use App\Services\Ads\AdPublishingService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(Tests\TestCase::class);
uses(DatabaseTransactions::class);
/**
* Build a fresh campaign + A/B test in STATUS_RUNNING with two variants.
*
* @return array{customerId:int, campaign:AdCampaign, abTest:AdAbTest, vA:AdAbVariant, vB:AdAbVariant}
*/
function r14Scenario(array $abTestOverrides = [], array $variantA = [], array $variantB = []): array
{
$customerId = (int) \DB::table('customers')->insertGetId([
'uid' => 'r14cus_' . uniqid(),
'timezone' => 'UTC',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
$campaign = AdCampaign::create([
'uid' => 'r14camp_' . uniqid(),
'customer_id' => $customerId,
'name' => 'R14 Campaign',
'objective' => 'traffic',
'budget_currency' => 'USD',
]);
$abTest = AdAbTest::create(array_merge([
'uid' => 'r14ab_' . uniqid(),
'ad_campaign_id' => $campaign->id,
'status' => AdAbTest::STATUS_RUNNING,
'confidence_threshold' => 95.00,
'isolated_variable' => 'headline',
'started_at' => now()->subDays(2),
'settings' => ['metric' => 'ctr', 'duration_days' => 7, 'split' => [50, 50]],
], $abTestOverrides));
$vA = AdAbVariant::create(array_merge([
'uid' => 'r14va_' . uniqid(),
'ad_ab_test_id' => $abTest->id,
'name' => 'Variant A',
'weight' => 50,
'impressions' => 0,
'clicks' => 0,
'spend' => 0,
'conversions' => 0,
], $variantA));
$vB = AdAbVariant::create(array_merge([
'uid' => 'r14vb_' . uniqid(),
'ad_ab_test_id' => $abTest->id,
'name' => 'Variant B',
'weight' => 50,
'impressions' => 0,
'clicks' => 0,
'spend' => 0,
'conversions' => 0,
], $variantB));
return compact('customerId', 'campaign', 'abTest', 'vA', 'vB');
}
// ============================================================
// 1. erf approximation accuracy
// ============================================================
test('erfApprox — matches textbook values within 1.5e-7', function () {
$eval = new AdAbTestEvaluator();
// Reference values from Wolfram Alpha / standard math tables:
expect($eval->erfApprox(0.0))->toEqualWithDelta(0.0, 1.5e-7);
expect($eval->erfApprox(1.0))->toEqualWithDelta(0.842700793, 1.5e-7);
expect($eval->erfApprox(2.0))->toEqualWithDelta(0.995322265, 1.5e-7);
expect($eval->erfApprox(0.5))->toEqualWithDelta(0.520499878, 1.5e-7);
// Odd-symmetry: erf(-x) = -erf(x)
expect($eval->erfApprox(-1.0))->toEqualWithDelta(-0.842700793, 1.5e-7);
});
test('standardNormalCdf — matches z-table values', function () {
$eval = new AdAbTestEvaluator();
// Phi(0) = 0.5
expect($eval->standardNormalCdf(0.0))->toEqualWithDelta(0.5, 1.0e-6);
// Phi(1) ≈ 0.8413
expect($eval->standardNormalCdf(1.0))->toEqualWithDelta(0.8413447, 1.0e-6);
// Phi(1.96) ≈ 0.975 (the 95% one-tail cutoff)
expect($eval->standardNormalCdf(1.96))->toEqualWithDelta(0.975, 1.0e-3);
// Phi(-1.96) ≈ 0.025
expect($eval->standardNormalCdf(-1.96))->toEqualWithDelta(0.025, 1.0e-3);
});
// ============================================================
// 2. Two-proportion z-test (CTR)
// ============================================================
test('z-test CTR — significant winner gives high confidence', function () {
$s = r14Scenario(
variantA: ['impressions' => 10000, 'clicks' => 500], // 5.0% CTR
variantB: ['impressions' => 10000, 'clicks' => 300], // 3.0% CTR
);
$eval = new AdAbTestEvaluator();
$result = $eval->twoProportionZCtr($s['vA'], $s['vB']);
// Direction: A's rate is HIGHER → positive z-stat
expect($result['test_statistic'])->toBeGreaterThan(0);
// 5% vs 3% with 10k/each is overwhelmingly significant
expect($result['p_value'])->toBeLessThan(0.0001);
});
test('z-test CTR — identical rates give p ≈ 1', function () {
$s = r14Scenario(
variantA: ['impressions' => 1000, 'clicks' => 50],
variantB: ['impressions' => 1000, 'clicks' => 50],
);
$eval = new AdAbTestEvaluator();
$result = $eval->twoProportionZCtr($s['vA'], $s['vB']);
expect($result['test_statistic'])->toBe(0.0);
expect($result['p_value'])->toBe(1.0);
});
test('z-test CTR — zero impressions gives no signal (p=1)', function () {
$s = r14Scenario(
variantA: ['impressions' => 0, 'clicks' => 0],
variantB: ['impressions' => 1000, 'clicks' => 50],
);
$eval = new AdAbTestEvaluator();
$result = $eval->twoProportionZCtr($s['vA'], $s['vB']);
expect($result['p_value'])->toBe(1.0);
});
test('z-test CTR — direction flips when B is winner', function () {
$s = r14Scenario(
variantA: ['impressions' => 10000, 'clicks' => 100], // 1.0%
variantB: ['impressions' => 10000, 'clicks' => 500], // 5.0%
);
$eval = new AdAbTestEvaluator();
$result = $eval->twoProportionZCtr($s['vA'], $s['vB']);
// B is better → negative z-stat
expect($result['test_statistic'])->toBeLessThan(0);
});
// ============================================================
// 3. Welch's t-test (CPA)
// ============================================================
test('welch t-test CPA — A has lower CPA → positive t-stat (better variant)', function () {
$s = r14Scenario(
variantA: ['impressions' => 10000, 'clicks' => 500, 'conversions' => 100, 'spend' => 100.00], // CPA=$1
variantB: ['impressions' => 10000, 'clicks' => 300, 'conversions' => 100, 'spend' => 500.00], // CPA=$5
);
$eval = new AdAbTestEvaluator();
$result = $eval->welchTTestCpa($s['vA'], $s['vB']);
// A's CPA is LOWER (better) → positive sign per our convention
expect($result['test_statistic'])->toBeGreaterThan(0);
});
test('welch t-test CPA — zero conversions returns no signal', function () {
$s = r14Scenario(
variantA: ['conversions' => 0, 'spend' => 0],
variantB: ['conversions' => 50, 'spend' => 500],
);
$eval = new AdAbTestEvaluator();
$result = $eval->welchTTestCpa($s['vA'], $s['vB']);
expect($result['p_value'])->toBe(1.0);
});
// ============================================================
// 4. Min-sample gate
// ============================================================
test('gate — < 100 conv AND < 7 days → INSUFFICIENT_CONVERSIONS', function () {
$s = r14Scenario(
['started_at' => now()->subDays(3)],
variantA: ['impressions' => 100, 'clicks' => 5, 'conversions' => 50],
variantB: ['impressions' => 100, 'clicks' => 5, 'conversions' => 30],
);
$eval = new AdAbTestEvaluator();
$variants = $s['abTest']->variants()->get();
expect($eval->checkMinSampleGate($s['abTest'], $variants))
->toBe(AdAbTestEvaluationLog::GATE_INSUFFICIENT_CONVERSIONS);
});
test('gate — ≥ 100 conv per variant → PASSED early', function () {
$s = r14Scenario(
['started_at' => now()->subDays(1)], // < 7 days
variantA: ['impressions' => 10000, 'clicks' => 500, 'conversions' => 150],
variantB: ['impressions' => 10000, 'clicks' => 300, 'conversions' => 120],
);
$eval = new AdAbTestEvaluator();
$variants = $s['abTest']->variants()->get();
expect($eval->checkMinSampleGate($s['abTest'], $variants))
->toBe(AdAbTestEvaluationLog::GATE_PASSED);
});
test('gate — 7+ days elapsed → PASSED even with thin data', function () {
$s = r14Scenario(
['started_at' => now()->subDays(8)],
variantA: ['impressions' => 1000, 'clicks' => 30, 'conversions' => 5],
variantB: ['impressions' => 1000, 'clicks' => 30, 'conversions' => 5],
);
$eval = new AdAbTestEvaluator();
$variants = $s['abTest']->variants()->get();
expect($eval->checkMinSampleGate($s['abTest'], $variants))
->toBe(AdAbTestEvaluationLog::GATE_PASSED);
});
test('gate — zero impressions on any variant → INSUFFICIENT_CONVERSIONS', function () {
$s = r14Scenario(
['started_at' => now()->subDays(10)],
variantA: ['impressions' => 0],
variantB: ['impressions' => 1000, 'clicks' => 50, 'conversions' => 200],
);
$eval = new AdAbTestEvaluator();
$variants = $s['abTest']->variants()->get();
expect($eval->checkMinSampleGate($s['abTest'], $variants))
->toBe(AdAbTestEvaluationLog::GATE_INSUFFICIENT_CONVERSIONS);
});
// ============================================================
// 5. Evaluator writes audit log
// ============================================================
test('evaluator — writes one log row when min-sample not met', function () {
$s = r14Scenario(
['started_at' => now()->subDays(1)],
variantA: ['impressions' => 100, 'clicks' => 5, 'conversions' => 10],
variantB: ['impressions' => 100, 'clicks' => 5, 'conversions' => 10],
);
$log = (new AdAbTestEvaluator())->evaluate($s['abTest']);
expect($log)->toBeInstanceOf(AdAbTestEvaluationLog::class);
expect($log->method)->toBe(AdAbTestEvaluationLog::METHOD_MIN_SAMPLE_NOT_MET);
expect($log->gate_result)->toBe(AdAbTestEvaluationLog::GATE_INSUFFICIENT_CONVERSIONS);
expect($log->winner_declared)->toBeFalse();
expect($log->test_statistic)->toBeNull();
expect($log->p_value)->toBeNull();
expect($log->variants_snapshot)->toHaveCount(2);
expect($log->variants_snapshot[0]['name'])->toBe('Variant A');
});
test('evaluator — winner declared when confidence ≥ threshold', function () {
$s = r14Scenario(
['confidence_threshold' => 95.00],
variantA: ['impressions' => 10000, 'clicks' => 500, 'conversions' => 200], // 5%
variantB: ['impressions' => 10000, 'clicks' => 300, 'conversions' => 150], // 3%
);
$log = (new AdAbTestEvaluator())->evaluate($s['abTest']);
expect($log->method)->toBe(AdAbTestEvaluationLog::METHOD_TWO_PROPORTION_Z);
expect($log->gate_result)->toBe(AdAbTestEvaluationLog::GATE_PASSED);
expect($log->winner_declared)->toBeTrue();
expect((int) $log->winner_variant_id)->toBe((int) $s['vA']->id);
expect((float) $log->confidence)->toBeGreaterThan(95.0);
});
test('evaluator — winner NOT declared when confidence < threshold', function () {
$s = r14Scenario(
['confidence_threshold' => 99.00],
variantA: ['impressions' => 10000, 'clicks' => 500, 'conversions' => 200],
variantB: ['impressions' => 10000, 'clicks' => 480, 'conversions' => 200], // tiny gap
);
$log = (new AdAbTestEvaluator())->evaluate($s['abTest']);
expect($log->winner_declared)->toBeFalse();
expect($log->gate_result)->toBe(AdAbTestEvaluationLog::GATE_INCONCLUSIVE);
expect((float) $log->confidence)->toBeLessThan(99.0);
});
// ============================================================
// 6. declareWinner uses real confidence (R14 invariant)
// ============================================================
test('service.declareWinner — pulls confidence from latest evaluation log (NOT hardcoded 95)', function () {
$s = r14Scenario(
variantA: ['impressions' => 10000, 'clicks' => 500, 'conversions' => 200],
variantB: ['impressions' => 10000, 'clicks' => 300, 'conversions' => 150],
);
// Run evaluator first to write the log row.
(new AdAbTestEvaluator())->evaluate($s['abTest']);
$service = app(AdAbTestService::class);
$service->declareWinner($s['abTest'], $s['vA']);
$s['abTest']->refresh();
expect($s['abTest']->status)->toBe(AdAbTest::STATUS_COMPLETED);
expect((float) $s['abTest']->confidence)->toBeGreaterThan(95.0);
expect((float) $s['abTest']->confidence)->not->toBe(95.0); // proves it's not hardcoded
expect((int) $s['abTest']->winner_variant_id)->toBe((int) $s['vA']->id);
});
test('service.declareWinner — confidence falls back to 0 when no winner-declared log exists', function () {
$s = r14Scenario(
variantA: ['impressions' => 100, 'clicks' => 5],
variantB: ['impressions' => 100, 'clicks' => 5],
);
// Don't run evaluator → no log row.
$service = app(AdAbTestService::class);
$service->declareWinner($s['abTest'], $s['vA']);
$s['abTest']->refresh();
expect((float) $s['abTest']->confidence)->toBe(0.0);
});
// ============================================================
// 7. Auto-pause path
// ============================================================
test('service.declareWinner — calls AdPublishingService::pause on parent campaign', function () {
$s = r14Scenario(
variantA: ['impressions' => 10000, 'clicks' => 500, 'conversions' => 200],
variantB: ['impressions' => 10000, 'clicks' => 300, 'conversions' => 150],
);
(new AdAbTestEvaluator())->evaluate($s['abTest']);
// Spy on AdPublishingService.
$publishingSpy = new class extends AdPublishingService {
public ?int $pausedCampaignId = null;
public function __construct() {} // bypass parent ctor
public function pause(\App\Model\AdCampaign $campaign): void
{
$this->pausedCampaignId = (int) $campaign->id;
}
};
$service = new AdAbTestService($publishingSpy);
$service->declareWinner($s['abTest'], $s['vA']);
expect($publishingSpy->pausedCampaignId)->toBe((int) $s['campaign']->id);
});
// ============================================================
// 8. Job sweep
// ============================================================
test('job — evaluates running tests, skips draft / completed tests', function () {
$s1 = r14Scenario([
'status' => AdAbTest::STATUS_RUNNING,
], variantA: ['impressions' => 10000, 'clicks' => 500, 'conversions' => 200],
variantB: ['impressions' => 10000, 'clicks' => 300, 'conversions' => 150]);
$s2 = r14Scenario(['status' => AdAbTest::STATUS_DRAFT]);
$s3 = r14Scenario(['status' => AdAbTest::STATUS_COMPLETED]);
$job = new EvaluateAdAbTests();
$job->handle(app(AdAbTestEvaluator::class), app(AdAbTestService::class));
expect($s1['abTest']->evaluationLogs()->count())->toBe(1);
expect($s2['abTest']->evaluationLogs()->count())->toBe(0);
expect($s3['abTest']->evaluationLogs()->count())->toBe(0);
});