HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
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);
});