Files
portal/app/tests/Feature/Imitation/ScenarioX1X3_SubstitutionJournalTest.php
T

513 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');