test(imitation): X1 step-3 substitution + X3 source breakdown

This commit is contained in:
Дмитрий
2026-06-04 04:45:37 +03:00
parent d5e966eebc
commit 49ea46ab0e
@@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
/**
* Verification tests ScenarioX1X3_SubstitutionJournalTest (Task 12, Phase 1 imitation).
*
* PROVING tests against existing production routing code.
* Differences vs plan FINDINGS captured in test output and comments.
* NO production code is modified. Only this one file is created.
*
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md
* "Task 12: X1 — подмена региона на шаге 3 + журнал; X3 — сводка источника"
* Spec: §6.5 X1/X3 + §7 п.30/41
*
* ── KEY PRODUCTION CODE FACTS (verified from reading prod code, NOT guessing) ────
*
* RouteSupplierLeadJob::createDealCopyForProject() lines 433453:
* $dealSubjectCode = ($routingStep < 3)
* ? $resolution->subjectCode
* : (pickSubstituteRegion($snapshot->regions ?? '{}') ?? $resolution->subjectCode);
* Deal::create([
* 'subject_code' => $dealSubjectCode, // substituted (client's) on step 3
* 'city' => CODE_TO_NAME[$resolution->subjectCode] ?? null, // REAL lead region ALWAYS
* 'region_substituted' => ($routingStep === 3), // flag
* ]);
*
* RouteSupplierLeadJob::logRegionResolution() lines 558595:
* $substituted = ($routingStep === 3 && $first !== null)
* ? (pickSubstituteRegion($first->snapshot_regions ?? '{}') ?? $resolution->subjectCode)
* : null;
* INSERT lead_region_resolution_log {
* actual_subject_code => $resolution->actualSubjectCode // real resolved code
* substituted_subject_code=> $substituted // client's code on step 3, else null
* routing_step => $routingStep // step of FIRST selected project
* subject_code_resolved => $resolution->subjectCode // real resolved code (same as actual)
* }
*
* LeadRouter: phase 3 fires ONLY when combined(phase1+phase2) is EMPTY.
* To force step 3: the ONLY eligible client must have an exact region DIFFERENT
* from the lead's resolved region, AND no all-RF client (regions='{}').
* phase 1 empty (exact mismatch), phase 2 empty (no '{}' client), phase 3 fires.
*
* pickSubstituteRegion() picks the FIRST int from the PG INT[]-literal '{R_client}'.
* snapshot.regions column holds the client's subscribed regions.
*
* RegionResolution.actualSubjectCode = subjectCode at construction (RegionResolution::make()).
* They are equal at the resolver stage; substitution is a RouteSupplierLeadJob concern only.
*
* X3 region_source values:
* 'dadata' FakeDaData stub with qc=0
* 'rossvyaz' FakeDaData stubThrows + seeded phone_ranges row matching the phone
* 'tag' DaData disabled OR qc=2/7 + valid tag string (FINDING: qc=2 with valid tag 'tag')
* 'unknown' DaData disabled/fails + no phone_range + empty/null tag
*
* X3 uses direct LeadRegionResolver::resolve() calls (not full routing) to
* produce multiple resolution log rows cheaply via separate SupplierLead rows.
*/
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRegionResolver;
use App\Services\LeadRouter;
use App\Support\RussianRegions;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\LeadInjector;
use Tests\Support\Imitation\SnapshotForge;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// ── SUBJECT CODE CONSTANTS (порядковые, НЕ ГИБДД) ────────────────────────────
/** RussianRegions::CODE_TO_NAME[29] = 'Красноярский край' — real lead region */
const X1_LEAD_REGION = 29;
/** RussianRegions::CODE_TO_NAME[37] = 'Белгородская область' — client's subscribed region */
const X1_CLIENT_REGION = 37;
/** RussianRegions::CODE_TO_NAME[82] = 'Москва' */
const X1_MOSCOW = 82;
/** Domains for X1 and X3 tests (B1 site signal, unique per scenario to avoid snapshot collisions) */
const X1_DOMAIN = 'scenario-x1-substitution.ru';
const X3_DOMAIN = 'scenario-x3-source-breakdown.ru';
/** Deterministic seed for LeadRouter */
const X1_SEED = 42;
// ── SHARED beforeEach ──────────────────────────────────────────────────────────
beforeEach(function (): void {
// Pricing tiers required by LedgerService::chargeForDelivery.
$this->seed(PricingTierSeeder::class);
// Global RLS bypass for seeding (tenant context = 0).
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// DaData config defaults — individual tests override as needed.
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
// Deterministic LeadRouter — seeded Mt19937. With 1 candidate, weightedPick
// always returns it (pool ≤ cap=3 so no lottery needed), but seeded for stability.
app()->instance(LeadRouter::class, new LeadRouter(new Randomizer(new Mt19937(X1_SEED))));
});
// ══════════════════════════════════════════════════════════════════════════════
// SCENARIO X1 — step-3 substitution: subject_code, city, journal actual/substituted
// ══════════════════════════════════════════════════════════════════════════════
/**
* X1: One client subscribed to R_client (Белгородская обл., code 37).
* No all-RF client. Lead resolved to R_lead (Красноярский край, code 29) via DaData qc=0.
*
* Cascade:
* Phase 1 exact: 29 NOT in client's regions={37} empty.
* Phase 2 all-RF: no '{}' client empty.
* Phase 3 fallback: client eligible (any region) routing_step=3.
*
* Expected (prod spec §3.10 + §7 п.30/41):
* deals.subject_code = R_client = 37 (substituted to client's region)
* deals.city = name(R_lead) = 'Красноярский край' (REAL lead region)
* deals.region_substituted = true
* lead_region_resolution_log.actual_subject_code = R_lead = 29
* lead_region_resolution_log.substituted_subject_code = R_client = 37
* lead_region_resolution_log.subject_code_resolved = R_lead = 29
* lead_region_resolution_log.routing_step = 3
*/
it('X1: step-3 fallback substitutes subject_code to client region, preserves real region in city + journal', function (): void {
// ── ARRANGE ───────────────────────────────────────────────────────────────
// One supplier (B1 site).
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => X1_DOMAIN,
]);
// One client: subscribed to R_client=37 (Белгородская обл.) ONLY.
// No all-RF client → phases 1+2 both empty → phase 3 fires.
$tenant = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => X1_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [X1_CLIENT_REGION], // {37}
]);
linkProjectToSupplier($project, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: X1_DOMAIN,
dailyLimit: 10,
regions: '{' . X1_CLIENT_REGION . '}', // '{37}'
);
// Lead resolves to R_lead=29 (Красноярский край) via DaData qc=0.
// DaData region string must EXACTLY match RussianRegions::CODE_TO_NAME[29]
// for DaDataRegionMap::toSubjectCode() to return code 29.
$leadPhone = '79292900001';
$leadRegionName = RussianRegions::CODE_TO_NAME[X1_LEAD_REGION]; // 'Красноярский край'
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData->stub($leadPhone, qc: 0, region: $leadRegionName, provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ───────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$lead = $injector->site(
domain: X1_DOMAIN,
phone: $leadPhone,
tag: null,
platform: 'B1',
vid: 9_120_001_001,
);
// ── ASSERT ────────────────────────────────────────────────────────────────
// Retrieve the deal for our tenant.
$deal = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->first();
$logRow = DB::connection('pgsql_supplier')
->table('lead_region_resolution_log')
->where('supplier_lead_id', $lead->id)
->first();
// Diagnostic output.
fwrite(STDOUT, PHP_EOL . '=== X1 SUBSTITUTION ===' . PHP_EOL);
fwrite(STDOUT, "Lead phone: {$leadPhone}" . PHP_EOL);
fwrite(STDOUT, "R_lead (real resolved): " . X1_LEAD_REGION . " ({$leadRegionName})" . PHP_EOL);
fwrite(STDOUT, "R_client (client region): " . X1_CLIENT_REGION . " (" . RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION] . ")" . PHP_EOL);
fwrite(STDOUT, "deal found: " . ($deal !== null ? 'YES' : 'NO') . PHP_EOL);
if ($deal !== null) {
fwrite(STDOUT, "deals.subject_code: {$deal->subject_code}" . PHP_EOL);
fwrite(STDOUT, "deals.city: {$deal->city}" . PHP_EOL);
fwrite(STDOUT, "deals.region_substituted: {$deal->region_substituted}" . PHP_EOL);
}
if ($logRow !== null) {
fwrite(STDOUT, "log.routing_step: {$logRow->routing_step}" . PHP_EOL);
fwrite(STDOUT, "log.subject_code_resolved: {$logRow->subject_code_resolved}" . PHP_EOL);
fwrite(STDOUT, "log.actual_subject_code: {$logRow->actual_subject_code}" . PHP_EOL);
fwrite(STDOUT, "log.substituted_subject_code: {$logRow->substituted_subject_code}" . PHP_EOL);
fwrite(STDOUT, "log.region_source: {$logRow->region_source}" . PHP_EOL);
} else {
fwrite(STDOUT, "log row: NOT FOUND" . PHP_EOL);
}
fwrite(STDOUT, '=== END X1 ===' . PHP_EOL . PHP_EOL);
// A deal must have been created.
expect($deal)->not->toBeNull(
'FINDING: No deal was created for the tenant. ' .
'The phase-3 fallback (any region) may not be reaching this client, ' .
'or the snapshot is missing.'
);
if ($deal !== null) {
// deals.subject_code must be R_client (substituted to client's region on step 3).
expect((int) $deal->subject_code)->toBe(X1_CLIENT_REGION,
'FINDING: deals.subject_code should be ' . X1_CLIENT_REGION . ' (R_client, Белгородская обл.) ' .
'because routing_step=3 substitutes subject_code to the first code in snapshot.regions. ' .
'Got: ' . $deal->subject_code . '. ' .
'If got R_lead=' . X1_LEAD_REGION . ': substitution is not firing (routingStep capture or pickSubstituteRegion failed). ' .
'If got null: snapshot.regions may not be picked up correctly.'
);
// deals.city must be the name of R_lead (real resolved region), NOT R_client.
// Code §3.10 comment: «Город» = человекочитаемое имя НАСТОЯЩЕГО региона лида.
expect($deal->city)->toBe($leadRegionName,
'FINDING: deals.city should be "' . $leadRegionName . '" (name of R_lead=' . X1_LEAD_REGION . ', real lead region). ' .
'Got: "' . $deal->city . '". ' .
'city is ALWAYS set from $resolution->subjectCode name, NOT from $dealSubjectCode. ' .
'If city = "' . RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION] . '": ' .
'prod code erroneously uses $dealSubjectCode for city.'
);
// deals.region_substituted must be true.
// PostgreSQL boolean comes back as string '1'/'0'/'t'/'f' or bool depending on driver.
$regionSubstituted = filter_var($deal->region_substituted, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($regionSubstituted === null) {
// Raw value from DB — accept truthy string/int representations.
$regionSubstituted = in_array($deal->region_substituted, [true, 1, '1', 't', 'true'], true);
}
expect($regionSubstituted)->toBeTrue(
'FINDING: deals.region_substituted should be TRUE on routing_step=3. ' .
'Got raw value: "' . $deal->region_substituted . '". ' .
'Check RouteSupplierLeadJob line: \'region_substituted\' => $routingStep === 3.'
);
}
// lead_region_resolution_log must have a row.
expect($logRow)->not->toBeNull(
'FINDING: lead_region_resolution_log has no row for this lead. ' .
'logRegionResolution() may have failed silently (fail-safe wrapper suppresses exceptions). ' .
'Check for partition missing (received_at date not matching any partition).'
);
if ($logRow !== null) {
// routing_step = 3.
expect((int) $logRow->routing_step)->toBe(3,
'FINDING: log.routing_step should be 3 (phase-3 fallback). ' .
'Got: ' . $logRow->routing_step . '. ' .
'If 1: exact match fired (snapshot.regions may be wrong). ' .
'If 2: all-RF match fired (client has regions=\'{}\' or snapshot is wrong).'
);
// subject_code_resolved = R_lead (the actual resolved code, not substituted).
expect((int) $logRow->subject_code_resolved)->toBe(X1_LEAD_REGION,
'FINDING: log.subject_code_resolved should be ' . X1_LEAD_REGION . ' (R_lead, real resolved). ' .
'Got: ' . $logRow->subject_code_resolved . '.'
);
// actual_subject_code = R_lead.
// RegionResolution.actualSubjectCode = subjectCode at construction time (real resolved).
expect((int) $logRow->actual_subject_code)->toBe(X1_LEAD_REGION,
'FINDING: log.actual_subject_code should be ' . X1_LEAD_REGION . ' (R_lead, real lead region). ' .
'Got: ' . $logRow->actual_subject_code . '. ' .
'actualSubjectCode is set equal to subjectCode in RegionResolution::make().'
);
// substituted_subject_code = R_client (the first code from snapshot.regions).
// pickSubstituteRegion('{37}') → 37 = X1_CLIENT_REGION.
expect($logRow->substituted_subject_code)->not->toBeNull(
'FINDING: log.substituted_subject_code should be ' . X1_CLIENT_REGION . ' (R_client) on step 3. ' .
'Got null. ' .
'logRegionResolution() computes substituted only when routingStep===3 AND $first!==null. ' .
'Check that $first->snapshot_regions attribute is present (set by LeadRouter SQL SELECT).'
);
if ($logRow->substituted_subject_code !== null) {
expect((int) $logRow->substituted_subject_code)->toBe(X1_CLIENT_REGION,
'FINDING: log.substituted_subject_code should be ' . X1_CLIENT_REGION . ' (R_client=Белгородская обл.). ' .
'Got: ' . $logRow->substituted_subject_code . '. ' .
'pickSubstituteRegion() parses PG INT[]-literal \'{37}\' → [37] → first=37.'
);
}
}
})->group('imitation');
// ══════════════════════════════════════════════════════════════════════════════
// SCENARIO X3 — region_source breakdown (dadata / rossvyaz / tag / unknown)
// ══════════════════════════════════════════════════════════════════════════════
/**
* X3: Inject 4 leads with different region_source values, aggregate
* lead_region_resolution_log.region_source counts, assert they match.
*
* To avoid full routing overhead and snapshot complexity, X3 uses
* LeadRegionResolver::resolve() directly on SupplierLead rows,
* then reads region_source from the updated supplier_leads columns.
* The resolver writes region_source to supplier_leads.region_source
* (RouteSupplierLeadJob lines 159-164); the log is written by
* logRegionResolution() after routing. For X3 we inject via full
* LeadInjector (which fires RouteSupplierLeadJob) so logRegionResolution()
* also runs; however, without a client snapshot the routing loop produces
* no deals (no selected projects logRegionResolution called with empty $selected).
*
* FINDING note on log.routing_step when $selected is empty:
* logRegionResolution() line 561: $first = $selected->first() null.
* So $routingStep = null, $substituted = null.
* This is correct/expected for X3's source-breakdown scenario.
*
* Source classification (from LeadRegionResolver code):
* 'dadata' DaData enabled, qc=0 (good quality, map returns a code).
* 'rossvyaz' DaData disabled OR throws/qc=1, phone_ranges row seeded for phone.
* 'tag' DaData disabled/fails/qc=2, valid tag string (maps to a region code).
* 'unknown' DaData disabled/fails, no phone_range match, empty/null tag.
*
* We inject 1 lead per source type (4 total), then read supplier_leads.region_source.
* Counts: dadata=1, rossvyaz=1, tag=1, unknown=1.
*
* X3 uses a dedicated supplier + NO snapshot selected=empty no deals created.
* This avoids the routing infrastructure (no client setup, no snapshot needed).
* The region resolver still runs and writes region_source to supplier_leads.
*/
it('X3: leads with dadata/rossvyaz/tag/unknown sources produce correct region_source counts in supplier_leads', function (): void {
// ── ARRANGE ───────────────────────────────────────────────────────────────
// Supplier for X3 — NO project linked, NO snapshot → routing produces 0 deals.
// This means RouteSupplierLeadJob still runs LeadRegionResolver, updates
// supplier_leads.region_source, then calls logRegionResolution (with empty selected).
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => X3_DOMAIN,
]);
// ── Lead 1: source = 'dadata' ──────────────────────────────────────────────
// DaData returns qc=0 + valid region name → DaDataRegionMap maps to a code.
$phone1 = '79310000001';
$region1Name = RussianRegions::CODE_TO_NAME[X1_MOSCOW]; // 'Москва'
$fake1 = new FakeDaDataPhoneClient();
$fake1->stub($phone1, qc: 0, region: $region1Name, provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake1);
config(['services.dadata.enabled' => true]);
$injector = new LeadInjector();
$lead1 = $injector->site(domain: X3_DOMAIN, phone: $phone1, tag: null, platform: 'B1', vid: 9_130_003_001);
// ── Lead 2: source = 'rossvyaz' ────────────────────────────────────────────
// DaData throws → falls through to Россвязь lookup. Seed a phone_ranges row
// covering phone2's DEF+subscriber range, mapping to subject_code=X1_MOSCOW.
// Phone format: 7{defCode}{subscriber} = 7{931}{0000002} → DEF=931, sub=0000002.
$phone2 = '79310000002';
// DEF=931, subscriber=0000002. seed range from=0 to=9999999 covering it.
$importId2 = DB::table('phone_ranges_imports')->insertGetId([
'imported_at' => now(),
'source_url' => 'test://x3-rossvyaz',
'rows_inserted' => 1,
'rows_updated' => 0,
'checksum_sha256' => hash('sha256', 'x3-rossvyaz-931'),
'status' => 'completed',
'completed_at' => now(),
]);
DB::table('phone_ranges')->insert([
'def_code' => 931,
'from_num' => 0,
'to_num' => 9999999,
'operator' => 'test-op-x3',
'region' => RussianRegions::CODE_TO_NAME[X1_MOSCOW],
'region_normalized' => null,
'subject_code' => X1_MOSCOW,
'imported_at' => now(),
'import_id' => $importId2,
]);
$fake2 = new FakeDaDataPhoneClient();
$fake2->stubThrows($phone2); // DaData throws → cascade falls to Россвязь
app()->instance(DaDataPhoneClient::class, $fake2);
config(['services.dadata.enabled' => true]);
$lead2 = $injector->site(domain: X3_DOMAIN, phone: $phone2, tag: null, platform: 'B1', vid: 9_130_003_002);
// ── Lead 3: source = 'tag' ─────────────────────────────────────────────────
// DaData disabled → no HTTP call. No phone_range seeded for this phone.
// Tag = region name that RegionTagResolver recognises as a valid region code.
// RegionTagResolver maps tag text to a subject code. Use 'Москва' (maps to 82).
// FINDING note: qc=2 path calls tagFallback() which only returns 'tag' if tagCode != null.
// With DaData disabled, resolver falls directly to tag/rossvyaz cascade.
$phone3 = '79310000003';
config(['services.dadata.enabled' => false]);
// No DaData stub needed — disabled path skips the HTTP call entirely.
$lead3 = $injector->site(domain: X3_DOMAIN, phone: $phone3, tag: 'Москва', platform: 'B1', vid: 9_130_003_003);
// ── Lead 4: source = 'unknown' ─────────────────────────────────────────────
// DaData disabled. No phone_ranges row for this DEF. Empty tag.
// → no resolution possible → source='unknown'.
$phone4 = '79880000004'; // DEF=988, NOT seeded in phone_ranges
config(['services.dadata.enabled' => false]);
$lead4 = $injector->site(domain: X3_DOMAIN, phone: $phone4, tag: null, platform: 'B1', vid: 9_130_003_004);
// ── READ region_source from supplier_leads ────────────────────────────────
// RouteSupplierLeadJob updates supplier_leads.region_source after resolver runs.
$leadIds = [$lead1->id, $lead2->id, $lead3->id, $lead4->id];
$rows = DB::table('supplier_leads')
->whereIn('id', $leadIds)
->get(['id', 'region_source', 'resolved_subject_code', 'phone'])
->keyBy('id');
$source1 = $rows[$lead1->id]->region_source ?? 'MISSING';
$source2 = $rows[$lead2->id]->region_source ?? 'MISSING';
$source3 = $rows[$lead3->id]->region_source ?? 'MISSING';
$source4 = $rows[$lead4->id]->region_source ?? 'MISSING';
fwrite(STDOUT, PHP_EOL . '=== X3 SOURCE BREAKDOWN ===' . PHP_EOL);
fwrite(STDOUT, "Lead1 (dadata expected) region_source: {$source1} | resolved: " . ($rows[$lead1->id]->resolved_subject_code ?? 'null') . PHP_EOL);
fwrite(STDOUT, "Lead2 (rossvyaz expected) region_source: {$source2} | resolved: " . ($rows[$lead2->id]->resolved_subject_code ?? 'null') . PHP_EOL);
fwrite(STDOUT, "Lead3 (tag expected) region_source: {$source3} | resolved: " . ($rows[$lead3->id]->resolved_subject_code ?? 'null') . PHP_EOL);
fwrite(STDOUT, "Lead4 (unknown expected) region_source: {$source4} | resolved: " . ($rows[$lead4->id]->resolved_subject_code ?? 'null') . PHP_EOL);
// Aggregate counts from supplier_leads.
$actualSources = array_map(fn ($id) => $rows[$id]->region_source ?? 'MISSING', $leadIds);
$counts = array_count_values($actualSources);
fwrite(STDOUT, 'Source counts: ' . json_encode($counts, JSON_UNESCAPED_UNICODE) . PHP_EOL);
fwrite(STDOUT, '=== END X3 ===' . PHP_EOL . PHP_EOL);
// ── ASSERT ────────────────────────────────────────────────────────────────
// Lead 1: expect 'dadata'.
expect($source1)->toBe('dadata',
"FINDING: Lead1 (phone={$phone1}, qc=0, valid region from DaData) should be 'dadata'. " .
"Got: '{$source1}'. " .
"If 'rossvyaz': DaData response was not mapped (DaDataRegionMap may not find '{$region1Name}'). " .
"If 'unknown': DaData is disabled or threw despite stub."
);
// Lead 2: expect 'rossvyaz'.
expect($source2)->toBe('rossvyaz',
"FINDING: Lead2 (phone={$phone2}, DaData throws, phone_ranges seeded DEF=931→Москва) should be 'rossvyaz'. " .
"Got: '{$source2}'. " .
"If 'unknown': Россвязь lookup didn't match (check DEF extraction: phone 7{DEF}{7-digit} = 7|931|0000002 → DEF=931). " .
"If 'dadata': FakeDaDataPhoneClient stubThrows didn't fire (stub registration issue)."
);
// Lead 3: expect 'tag'.
expect($source3)->toBe('tag',
"FINDING: Lead3 (phone={$phone3}, DaData disabled, tag='Москва') should be 'tag'. " .
"Got: '{$source3}'. " .
"KNOWN FINDING (G5 test suite): with DaData disabled, LeadRegionResolver falls through " .
"Россвязь first. If no phone_ranges row for DEF=931 with this phone, then tagFallback() " .
"is called. tagFallback() returns 'tag' only when tagCode!=null (valid tag→region mapping). " .
"'Москва' should map to code 82 via RegionTagResolver. " .
"If 'unknown': tag 'Москва' did not map to a region code in RegionTagResolver."
);
// Lead 4: expect 'unknown'.
expect($source4)->toBe('unknown',
"FINDING: Lead4 (phone={$phone4}, DaData disabled, no phone_range for DEF=988, empty tag) should be 'unknown'. " .
"Got: '{$source4}'. " .
"If 'rossvyaz': there is an unexpected phone_ranges row covering DEF=988. " .
"If 'tag': empty/null tag somehow resolved to a code (check RegionTagResolver null-tag handling)."
);
// Aggregate assertion: 4 leads → exactly these 4 sources.
$expectedCounts = ['dadata' => 1, 'rossvyaz' => 1, 'tag' => 1, 'unknown' => 1];
expect($counts)->toEqual($expectedCounts,
'FINDING: The aggregate source counts do not match expected {dadata:1, rossvyaz:1, tag:1, unknown:1}. ' .
'Got: ' . json_encode($counts) . '. See individual source assertions above for details.'
);
})->group('imitation');