queries; } }; } function fakeResearcher(array $answers): ResearcherClient { return new class($answers) implements ResearcherClient { public function __construct(private array $answers) {} public function research(string $system, string $user): string { static $i = 0; return $this->answers[$i++] ?? '[]'; } }; } function noAiAssembler(): FindCompetitorsAssembler { $nullClassifier = new class implements AggregatorClassifier { public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool { return null; // без ИИ — никого не выкидываем } }; $zeroEmbedder = new class implements Embedder { public function embed(array $texts): array { return array_map(fn () => [0.0], $texts); } }; return new FindCompetitorsAssembler( new AggregatorFilter($nullClassifier), new AutopodborDedup(new AutopodborNormalizer), new EmbeddingRelevance($zeroEmbedder), ); } it('сквозной: канал А (2ГИС-карточка) + канал В (федерал с сайтом из EXA) попадают в результат', function () { Config::set('services.exa', ['key' => 'k', 'base_url' => 'https://api.exa.ai', 'timeout_sec' => 30]); $http = app(HttpFactory::class); $http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://carmoney.ru/']]], 200)]); $pages = stubPages([ '2gis.ru/krasnoyarsk/search/' => autopodborFixture('2gis-search-kraslombard.html'), '/firm/' => autopodborFixture('2gis-firm-kraslombard.html'), // Яндекс по «CarMoney …» вернёт КрасЛомбарда — резолвер отвергнет по несовпадению имени → федерал. 'maps/?text=' => autopodborFixture('yandex-search-kraslombard.html'), '/maps/org/' => autopodborFixture('yandex-org-kraslombard.html'), ]); $find = new LiveFindCompetitors( fakeAnalyzer(['ломбард']), new CategoryScraper($pages, new SearchResultsParser, maxPages: 2), new CompetitorResolver($pages), new ChannelBSearch(fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]', '[]']), new ResearcherParser), new ExaSiteFinder($http), noAiAssembler(), ); $res = $find->find(new FindCompetitorsRequest( regionCode: 29, examples: ['automoney-krsk.ru'], aboutSelf: ['займы под залог авто', 'lkomega.ru'], includeFederal: true, maxCompetitors: 20, )); $names = array_column($res->competitors, 'name'); expect($names)->toContain('КрасЛомбард'); // канал А $carmoney = collect($res->competitors)->firstWhere('name', 'CarMoney'); expect($carmoney)->not->toBeNull() ->and($carmoney['is_federal'])->toBeTrue() ->and($carmoney['site_url'])->toBe('carmoney.ru'); // сайт из EXA }); it('includeFederal=false → канал В выключен, остаётся только местная А', function () { Config::set('services.exa.key', 'k'); $http = app(HttpFactory::class); $http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://carmoney.ru/']]], 200)]); $pages = stubPages([ '2gis.ru/krasnoyarsk/search/' => autopodborFixture('2gis-search-kraslombard.html'), '/firm/' => autopodborFixture('2gis-firm-kraslombard.html'), ]); $find = new LiveFindCompetitors( fakeAnalyzer(['ломбард']), new CategoryScraper($pages, new SearchResultsParser, maxPages: 2), new CompetitorResolver($pages), new ChannelBSearch(fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]']), new ResearcherParser), new ExaSiteFinder($http), noAiAssembler(), ); $res = $find->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], false, 20)); $names = array_column($res->competitors, 'name'); expect($names)->toContain('КрасЛомбард') ->and($names)->not->toContain('CarMoney'); }); it('пусто везде → пустой список, без падения', function () { Config::set('services.exa.key', ''); $find = new LiveFindCompetitors( fakeAnalyzer(['ничего']), new CategoryScraper(stubPages([]), new SearchResultsParser, maxPages: 2), new CompetitorResolver(stubPages([])), new ChannelBSearch(fakeResearcher(['[]', '[]']), new ResearcherParser), new ExaSiteFinder(app(HttpFactory::class)), noAiAssembler(), ); $res = $find->find(new FindCompetitorsRequest(29, [], ['ничего', 'self.ru'], true, 10)); expect($res->competitors)->toBe([]); });