Update Steam support

This commit is contained in:
DariusIII
2025-12-10 13:19:38 +01:00
parent 79b36b9ba3
commit 0f8cd69e94
9 changed files with 2906 additions and 24 deletions
+75 -24
View File
@@ -3,12 +3,20 @@
namespace Blacklight;
use App\Models\SteamApp;
use App\Services\SteamService;
use App\Support\DTOs\SteamGameData;
use b3rs3rk\steamfront\Main;
use DivineOmega\CliProgressBar\ProgressBar;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
/**
* Class Steam.
*
* Legacy wrapper around SteamService for backward compatibility.
* New code should use App\Services\SteamService directly.
*
* @deprecated Use App\Services\SteamService instead
*/
class Steam
{
@@ -30,10 +38,11 @@ class Steam
protected ColorCLI $colorCli;
protected SteamService $steamService;
/**
* Steam constructor.
*
*
* @throws \Exception
*/
public function __construct()
@@ -46,6 +55,7 @@ class Steam
);
$this->colorCli = new ColorCLI;
$this->steamService = new SteamService();
}
/**
@@ -55,6 +65,13 @@ class Steam
*/
public function getAll(int $appID): bool|array
{
// Try new service first
$result = $this->steamService->getGameDetails($appID);
if ($result !== false) {
return $result;
}
// Fall back to legacy steamfront library
$res = $this->steamFront->getAppDetails($appID);
if ($res !== false) {
@@ -152,24 +169,39 @@ class Steam
}
/**
* Searches Steam Apps table for best title match -- prefers 100% match but returns highest over 90%.
* Searches Steam Apps table for best title match.
*
* @param string $searchTerm The parsed game name from the release searchname
* @return false|int $bestMatch The Best match from the given search term
* @return false|int The Best match from the given search term
*
* @throws \Exception
*/
public function search(string $searchTerm): bool|int
{
$bestMatch = false;
$searchTerm = trim($searchTerm);
if ($searchTerm === '') {
$this->colorCli->notice('Search term cannot be empty');
return false;
}
// Use new service for search
$appId = $this->steamService->search($searchTerm);
if ($appId !== null) {
return $appId;
}
// Fall back to legacy matching if new service fails
return $this->legacySearch($searchTerm);
}
/**
* Legacy search implementation for backward compatibility.
*/
protected function legacySearch(string $searchTerm): bool|int
{
$bestMatch = false;
// Generate query variants from the original term to improve recall.
$variants = $this->generateQueryVariants($searchTerm);
@@ -265,32 +297,51 @@ class Steam
public function populateSteamAppsTable(): void
{
$bar = new ProgressBar;
$fullAppArray = $this->steamFront->getFullAppList();
$inserted = $dupe = 0;
$this->colorCli->info('Populating steam apps table');
$appsArray = Arr::pluck($fullAppArray, 'apps');
$max = count($appsArray[0]);
$bar->setMaxProgress($max);
foreach ($appsArray as $appArray) {
foreach ($appArray as $app) {
$dupeCheck = SteamApp::query()->where('appid', '=', $app['appid'])->first(['appid']);
if ($dupeCheck === null) {
SteamApp::query()->insert(['name' => $app['name'], 'appid' => $app['appid']]);
$inserted++;
} else {
$dupe++;
}
$bar->advance()->display();
$stats = $this->steamService->populateSteamAppsTable(function ($processed, $total) use ($bar) {
if ($bar->getMaxProgress() === 0) {
$bar->setMaxProgress($total);
}
}
$bar->setProgress($processed)->display();
});
$bar->complete();
\Laravel\Prompts\info('Added '.$inserted.' new steam app(s), '.$dupe.' duplicates skipped');
\Laravel\Prompts\info(sprintf(
'Added %d new steam app(s), %d skipped, %d errors',
$stats['inserted'],
$stats['skipped'],
$stats['errors']
));
}
/**
* Get player count for a game.
*/
public function getPlayerCount(int $appId): ?int
{
return $this->steamService->getPlayerCount($appId);
}
/**
* Get reviews summary for a game.
*/
public function getReviewsSummary(int $appId): ?array
{
return $this->steamService->getReviewsSummary($appId);
}
/**
* Get the underlying SteamService instance.
*/
public function getService(): SteamService
{
return $this->steamService;
}
// --------------------
// Matching helpers
// Matching helpers (kept for backward compatibility)
// --------------------
/**
+181
View File
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\SteamService;
use Illuminate\Console\Command;
class SteamLookupGame extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'steam:lookup
{title : The game title to search for}
{--details : Show full game details}
{--json : Output as JSON}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Look up a game on Steam by title';
/**
* Execute the console command.
*/
public function handle(SteamService $steamService): int
{
$title = $this->argument('title');
$showDetails = $this->option('details');
$outputJson = $this->option('json');
$this->info("Searching for: {$title}");
$this->newLine();
// Search for the game
$appId = $steamService->search($title);
if ($appId === null) {
$this->error('No matching game found.');
return Command::FAILURE;
}
$this->info("Found game with Steam App ID: {$appId}");
$this->newLine();
if ($showDetails) {
$details = $steamService->getGameDetails($appId);
if ($details === false) {
$this->error('Could not retrieve game details.');
return Command::FAILURE;
}
if ($outputJson) {
$this->line(json_encode($details, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
} else {
$this->displayGameDetails($details);
}
// Get additional data
$reviews = $steamService->getReviewsSummary($appId);
$playerCount = $steamService->getPlayerCount($appId);
if ($reviews !== null) {
$this->newLine();
$this->info('Reviews:');
$this->table(
['Metric', 'Value'],
[
['Rating', $reviews['review_score_desc'] ?? 'Unknown'],
['Positive', number_format($reviews['total_positive'] ?? 0)],
['Negative', number_format($reviews['total_negative'] ?? 0)],
['Total', number_format($reviews['total_reviews'] ?? 0)],
]
);
}
if ($playerCount !== null) {
$this->info("Current Players: " . number_format($playerCount));
}
}
return Command::SUCCESS;
}
/**
* Display game details in a formatted way.
*/
protected function displayGameDetails(array $details): void
{
$this->info('=== Game Details ===');
$this->newLine();
// Basic info table
$basicInfo = [
['Title', $details['title'] ?? 'N/A'],
['Steam ID', $details['steamid'] ?? 'N/A'],
['Type', ucfirst($details['type'] ?? 'game')],
['Publisher', $details['publisher'] ?? 'N/A'],
['Developers', implode(', ', $details['developers'] ?? []) ?: 'N/A'],
['Release Date', $details['releasedate'] ?? 'N/A'],
['Genres', $details['genres'] ?? 'N/A'],
['Platforms', implode(', ', $details['platforms'] ?? [])],
];
$this->table(['Property', 'Value'], $basicInfo);
// Scores
if (!empty($details['metacritic_score'])) {
$this->newLine();
$this->info("Metacritic Score: {$details['metacritic_score']}");
}
// Price
if (!empty($details['price'])) {
$price = $details['price'];
$this->newLine();
$this->info('Pricing:');
$priceInfo = [
['Currency', $price['currency'] ?? 'USD'],
['Price', $price['final_formatted'] ?? sprintf('$%.2f', $price['final'] ?? 0)],
];
if (($price['discount_percent'] ?? 0) > 0) {
$priceInfo[] = ['Original', sprintf('$%.2f', $price['initial'] ?? 0)];
$priceInfo[] = ['Discount', "{$price['discount_percent']}%"];
}
$this->table(['Field', 'Value'], $priceInfo);
}
// Description
if (!empty($details['description'])) {
$this->newLine();
$this->info('Description:');
$this->line(wordwrap($details['description'], 80));
}
// URLs
$this->newLine();
$this->info('Links:');
$this->line("Store: {$details['directurl']}");
if (!empty($details['website'])) {
$this->line("Website: {$details['website']}");
}
if (!empty($details['metacritic_url'])) {
$this->line("Metacritic: {$details['metacritic_url']}");
}
// Stats
if (!empty($details['achievements']) || !empty($details['recommendations'])) {
$this->newLine();
$this->info('Stats:');
if (!empty($details['achievements'])) {
$this->line("Achievements: {$details['achievements']}");
}
if (!empty($details['recommendations'])) {
$this->line("Recommendations: " . number_format($details['recommendations']));
}
}
// Categories (multiplayer, etc.)
if (!empty($details['categories'])) {
$this->newLine();
$this->info('Features:');
$this->line(implode(', ', $details['categories']));
}
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\SteamApp;
use App\Services\SteamService;
use Illuminate\Console\Command;
use function Laravel\Prompts\progress;
use function Laravel\Prompts\info;
use function Laravel\Prompts\warning;
use function Laravel\Prompts\table;
class SteamSearchGames extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'steam:search
{query : The search query}
{--limit=10 : Maximum number of results}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Search for games in the local Steam database';
/**
* Execute the console command.
*/
public function handle(SteamService $steamService): int
{
$query = $this->argument('query');
$limit = (int) $this->option('limit');
info("Searching for: {$query}");
$results = $steamService->searchMultiple($query, $limit);
if ($results->isEmpty()) {
warning('No games found matching your query.');
return Command::SUCCESS;
}
info("Found {$results->count()} result(s):");
$tableData = $results->map(fn($r) => [
$r['appid'],
substr($r['name'], 0, 60),
number_format($r['score'], 1) . '%',
'https://store.steampowered.com/app/' . $r['appid'],
])->toArray();
table(
['App ID', 'Name', 'Score', 'Store URL'],
$tableData
);
return Command::SUCCESS;
}
}
File diff suppressed because it is too large Load Diff
+307
View File
@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
namespace App\Support\DTOs;
/**
* Data Transfer Object for Steam Game data.
*
* Provides a type-safe, immutable representation of game data
* retrieved from the Steam API.
*/
final readonly class SteamGameData
{
/**
* @param int $steamId Steam App ID
* @param string $title Game title
* @param string $type App type (game, dlc, demo, etc.)
* @param string|null $description Short description
* @param string|null $detailedDescription Full description with HTML
* @param string|null $about About the game text
* @param string|null $coverUrl Header image URL
* @param string|null $backdropUrl Background image URL
* @param array<int, array{thumbnail: ?string, full: ?string}> $screenshots Screenshot URLs
* @param array<int, array{id: ?int, name: ?string, thumbnail: ?string, webm: ?string, mp4: ?string}> $movies Movie/trailer data
* @param string|null $trailerUrl Primary trailer URL
* @param string|null $publisher Publisher name(s)
* @param array<string> $developers Developer names
* @param string|null $releaseDate Release date (Y-m-d format)
* @param array<string> $genres Genre names
* @param array<string> $categories Category names (multiplayer, etc.)
* @param int|null $metacriticScore Metacritic score (0-100)
* @param string|null $metacriticUrl Metacritic URL
* @param SteamPriceData|null $price Price information
* @param array<string> $platforms Supported platforms
* @param array<string, array{minimum: ?string, recommended: ?string}> $requirements System requirements
* @param array<int> $dlcIds DLC App IDs
* @param int|null $achievementCount Total achievements
* @param int|null $recommendationCount Total recommendations
* @param string|null $website Official website URL
* @param string|null $supportUrl Support URL
* @param string $storeUrl Steam store URL
*/
public function __construct(
public int $steamId,
public string $title,
public string $type = 'game',
public ?string $description = null,
public ?string $detailedDescription = null,
public ?string $about = null,
public ?string $coverUrl = null,
public ?string $backdropUrl = null,
public array $screenshots = [],
public array $movies = [],
public ?string $trailerUrl = null,
public ?string $publisher = null,
public array $developers = [],
public ?string $releaseDate = null,
public array $genres = [],
public array $categories = [],
public ?int $metacriticScore = null,
public ?string $metacriticUrl = null,
public ?SteamPriceData $price = null,
public array $platforms = [],
public array $requirements = [],
public array $dlcIds = [],
public ?int $achievementCount = null,
public ?int $recommendationCount = null,
public ?string $website = null,
public ?string $supportUrl = null,
public string $storeUrl = '',
) {}
/**
* Create from Steam API response array.
*/
public static function fromApiResponse(array $data, int $appId): self
{
// Parse screenshots
$screenshots = [];
if (!empty($data['screenshots'])) {
foreach ($data['screenshots'] as $ss) {
$screenshots[] = [
'thumbnail' => $ss['path_thumbnail'] ?? null,
'full' => $ss['path_full'] ?? null,
];
}
}
// Parse movies
$movies = [];
$trailerUrl = null;
if (!empty($data['movies'])) {
foreach ($data['movies'] as $movie) {
$mp4Url = $movie['mp4']['max'] ?? ($movie['mp4']['480'] ?? null);
$movies[] = [
'id' => $movie['id'] ?? null,
'name' => $movie['name'] ?? null,
'thumbnail' => $movie['thumbnail'] ?? null,
'webm' => $movie['webm']['max'] ?? ($movie['webm']['480'] ?? null),
'mp4' => $mp4Url,
];
if ($trailerUrl === null && !empty($mp4Url)) {
$trailerUrl = $mp4Url;
}
}
}
// Parse genres
$genres = [];
if (!empty($data['genres'])) {
foreach ($data['genres'] as $genre) {
if (!empty($genre['description'])) {
$genres[] = $genre['description'];
}
}
}
// Parse categories
$categories = [];
if (!empty($data['categories'])) {
foreach ($data['categories'] as $cat) {
if (!empty($cat['description'])) {
$categories[] = $cat['description'];
}
}
}
// Parse platforms
$platforms = [];
if (!empty($data['platforms'])) {
if ($data['platforms']['windows'] ?? false) {
$platforms[] = 'Windows';
}
if ($data['platforms']['mac'] ?? false) {
$platforms[] = 'Mac';
}
if ($data['platforms']['linux'] ?? false) {
$platforms[] = 'Linux';
}
}
// Parse requirements
$requirements = [];
if (!empty($data['pc_requirements']) && !is_array($data['pc_requirements']) === false) {
if (is_array($data['pc_requirements'])) {
$requirements['pc'] = [
'minimum' => $data['pc_requirements']['minimum'] ?? null,
'recommended' => $data['pc_requirements']['recommended'] ?? null,
];
}
}
if (!empty($data['mac_requirements']) && is_array($data['mac_requirements'])) {
$requirements['mac'] = [
'minimum' => $data['mac_requirements']['minimum'] ?? null,
'recommended' => $data['mac_requirements']['recommended'] ?? null,
];
}
if (!empty($data['linux_requirements']) && is_array($data['linux_requirements'])) {
$requirements['linux'] = [
'minimum' => $data['linux_requirements']['minimum'] ?? null,
'recommended' => $data['linux_requirements']['recommended'] ?? null,
];
}
// Parse price
$price = null;
if (isset($data['price_overview'])) {
$price = SteamPriceData::fromApiResponse($data['price_overview']);
} elseif ($data['is_free'] ?? false) {
$price = SteamPriceData::free();
}
// Parse release date
$releaseDate = null;
if (!empty($data['release_date']['date'])) {
try {
$releaseDate = \Illuminate\Support\Carbon::parse($data['release_date']['date'])->format('Y-m-d');
} catch (\Exception $e) {
$releaseDate = $data['release_date']['date'];
}
}
// Parse publisher
$publisher = null;
if (!empty($data['publishers'])) {
$publisher = implode(', ', array_filter(array_map('strval', $data['publishers'])));
}
// Parse developers
$developers = [];
if (!empty($data['developers'])) {
$developers = array_values(array_filter(array_map('strval', $data['developers'])));
}
return new self(
steamId: $appId,
title: $data['name'] ?? '',
type: $data['type'] ?? 'game',
description: $data['short_description'] ?? null,
detailedDescription: $data['detailed_description'] ?? null,
about: $data['about_the_game'] ?? null,
coverUrl: $data['header_image'] ?? null,
backdropUrl: $data['background'] ?? ($data['background_raw'] ?? null),
screenshots: $screenshots,
movies: $movies,
trailerUrl: $trailerUrl,
publisher: $publisher,
developers: $developers,
releaseDate: $releaseDate,
genres: $genres,
categories: $categories,
metacriticScore: $data['metacritic']['score'] ?? null,
metacriticUrl: $data['metacritic']['url'] ?? null,
price: $price,
platforms: $platforms,
requirements: $requirements,
dlcIds: $data['dlc'] ?? [],
achievementCount: $data['achievements']['total'] ?? null,
recommendationCount: $data['recommendations']['total'] ?? null,
website: $data['website'] ?? null,
supportUrl: $data['support_info']['url'] ?? null,
storeUrl: 'https://store.steampowered.com/app/' . $appId,
);
}
/**
* Convert to array format compatible with existing GamesInfo model.
*/
public function toGamesInfoArray(): array
{
return [
'title' => $this->title,
'asin' => (string) $this->steamId,
'url' => $this->storeUrl,
'publisher' => $this->publisher ?? 'Unknown',
'releasedate' => $this->releaseDate,
'review' => $this->description ?? 'No description available',
'cover' => !empty($this->coverUrl) ? 1 : 0,
'backdrop' => !empty($this->backdropUrl) ? 1 : 0,
'trailer' => $this->trailerUrl ?? '',
'classused' => 'Steam',
'esrb' => $this->metacriticScore !== null ? (string) $this->metacriticScore : 'Not Rated',
'coverurl' => $this->coverUrl,
'backdropurl' => $this->backdropUrl,
'genres' => implode(',', $this->genres),
];
}
/**
* Check if this is a game (not DLC, video, etc.).
*/
public function isGame(): bool
{
return in_array($this->type, ['game', 'demo'], true);
}
/**
* Check if game is free.
*/
public function isFree(): bool
{
return $this->price !== null && $this->price->final <= 0;
}
/**
* Get primary genre.
*/
public function getPrimaryGenre(): ?string
{
return $this->genres[0] ?? null;
}
/**
* Check if game supports a platform.
*/
public function supportsPlatform(string $platform): bool
{
return in_array($platform, $this->platforms, true);
}
/**
* Check if game has multiplayer.
*/
public function hasMultiplayer(): bool
{
$multiplayerCategories = ['Multi-player', 'Online Multi-Player', 'Online Co-op', 'Local Multi-Player', 'Local Co-op'];
return !empty(array_intersect($multiplayerCategories, $this->categories));
}
/**
* Check if game supports Steam Workshop.
*/
public function hasSteamWorkshop(): bool
{
return in_array('Steam Workshop', $this->categories, true);
}
/**
* Get formatted genres string.
*/
public function getGenresString(): string
{
return implode(', ', $this->genres);
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Support\DTOs;
/**
* Data Transfer Object for Steam Price information.
*/
final readonly class SteamPriceData
{
public function __construct(
public string $currency,
public float $initial,
public float $final,
public int $discountPercent,
public ?string $formattedPrice = null,
) {}
/**
* Create from Steam API price_overview response.
*/
public static function fromApiResponse(array $data): self
{
return new self(
currency: $data['currency'] ?? 'USD',
initial: ($data['initial'] ?? 0) / 100,
final: ($data['final'] ?? 0) / 100,
discountPercent: $data['discount_percent'] ?? 0,
formattedPrice: $data['final_formatted'] ?? null,
);
}
/**
* Create a free price instance.
*/
public static function free(): self
{
return new self(
currency: 'USD',
initial: 0.0,
final: 0.0,
discountPercent: 0,
formattedPrice: 'Free',
);
}
/**
* Check if currently on sale.
*/
public function isOnSale(): bool
{
return $this->discountPercent > 0;
}
/**
* Check if free.
*/
public function isFree(): bool
{
return $this->final <= 0;
}
/**
* Get savings amount.
*/
public function getSavings(): float
{
return max(0, $this->initial - $this->final);
}
/**
* Get formatted display price.
*/
public function getDisplayPrice(): string
{
if ($this->formattedPrice !== null) {
return $this->formattedPrice;
}
if ($this->isFree()) {
return 'Free';
}
return sprintf('%s %.2f', $this->currency, $this->final);
}
/**
* Convert to array.
*/
public function toArray(): array
{
return [
'currency' => $this->currency,
'initial' => $this->initial,
'final' => $this->final,
'discount_percent' => $this->discountPercent,
'formatted' => $this->formattedPrice,
];
}
}
+453
View File
@@ -0,0 +1,453 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\SteamApp;
use App\Services\SteamService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
/**
* Feature tests for SteamService - tests requiring database and HTTP mocking.
*/
class SteamServiceTest extends TestCase
{
use RefreshDatabase;
protected SteamService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new SteamService();
Cache::flush();
}
// ==========================================
// Search Tests with Mocked Database
// ==========================================
public function test_search_finds_exact_match_in_database(): void
{
// Insert test data
SteamApp::query()->insert([
['appid' => 292030, 'name' => 'The Witcher 3: Wild Hunt'],
['appid' => 1091500, 'name' => 'Cyberpunk 2077'],
['appid' => 1245620, 'name' => 'ELDEN RING'],
]);
// Search should find exact match
$result = $this->service->search('Cyberpunk 2077');
$this->assertNotNull($result);
$this->assertSame(1091500, $result);
}
public function test_search_finds_match_from_scene_release_name(): void
{
SteamApp::query()->insert([
['appid' => 292030, 'name' => 'The Witcher 3: Wild Hunt'],
]);
$result = $this->service->search('The.Witcher.3.Wild.Hunt-CODEX');
$this->assertNotNull($result);
$this->assertSame(292030, $result);
}
public function test_search_returns_null_for_no_match(): void
{
SteamApp::query()->insert([
['appid' => 292030, 'name' => 'The Witcher 3: Wild Hunt'],
]);
$result = $this->service->search('NonExistent Game Title XYZ');
$this->assertNull($result);
}
public function test_search_caches_successful_result(): void
{
SteamApp::query()->insert([
['appid' => 1091500, 'name' => 'Cyberpunk 2077'],
]);
// First search
$result1 = $this->service->search('Cyberpunk 2077');
$this->assertSame(1091500, $result1);
// Delete from database
SteamApp::query()->where('appid', 1091500)->delete();
// Second search should return cached result
$result2 = $this->service->search('Cyberpunk 2077');
$this->assertSame(1091500, $result2);
}
public function test_search_caches_failed_lookup(): void
{
// Search for non-existent game
$this->service->search('NonExistent Game 12345');
// Add the game to database
SteamApp::query()->insert([
['appid' => 99999, 'name' => 'NonExistent Game 12345'],
]);
// Search should still return null due to cache
$result = $this->service->search('NonExistent Game 12345');
$this->assertNull($result);
}
public function test_search_multiple_returns_collection(): void
{
SteamApp::query()->insert([
['appid' => 292030, 'name' => 'The Witcher 3: Wild Hunt'],
['appid' => 20900, 'name' => 'The Witcher'],
['appid' => 20920, 'name' => 'The Witcher 2: Assassins of Kings'],
]);
$results = $this->service->searchMultiple('Witcher', 10);
$this->assertNotEmpty($results);
$this->assertGreaterThanOrEqual(1, $results->count());
}
// ==========================================
// API Tests with HTTP Mocking
// ==========================================
public function test_get_game_details_returns_formatted_data(): void
{
Http::fake([
'store.steampowered.com/api/appdetails*' => Http::response([
'1091500' => [
'success' => true,
'data' => [
'type' => 'game',
'name' => 'Cyberpunk 2077',
'steam_appid' => 1091500,
'short_description' => 'An open-world RPG set in Night City.',
'detailed_description' => 'Full description here...',
'about_the_game' => 'About the game...',
'header_image' => 'https://cdn.steam.com/header.jpg',
'background' => 'https://cdn.steam.com/background.jpg',
'publishers' => ['CD PROJEKT RED'],
'developers' => ['CD PROJEKT RED'],
'genres' => [
['id' => '1', 'description' => 'Action'],
['id' => '3', 'description' => 'RPG'],
],
'categories' => [
['id' => 2, 'description' => 'Single-player'],
],
'release_date' => [
'coming_soon' => false,
'date' => 'Dec 10, 2020',
],
'metacritic' => [
'score' => 86,
'url' => 'https://www.metacritic.com/game/cyberpunk-2077',
],
'platforms' => [
'windows' => true,
'mac' => false,
'linux' => false,
],
'price_overview' => [
'currency' => 'USD',
'initial' => 5999,
'final' => 2999,
'discount_percent' => 50,
'final_formatted' => '$29.99',
],
'screenshots' => [
[
'id' => 0,
'path_thumbnail' => 'https://cdn.steam.com/ss_thumb.jpg',
'path_full' => 'https://cdn.steam.com/ss_full.jpg',
],
],
'movies' => [
[
'id' => 256123,
'name' => 'Launch Trailer',
'thumbnail' => 'https://cdn.steam.com/movie_thumb.jpg',
'mp4' => [
'480' => 'https://cdn.steam.com/movie_480.mp4',
'max' => 'https://cdn.steam.com/movie_max.mp4',
],
],
],
'achievements' => [
'total' => 44,
],
'recommendations' => [
'total' => 500000,
],
],
],
]),
]);
$result = $this->service->getGameDetails(1091500);
$this->assertIsArray($result);
$this->assertSame('Cyberpunk 2077', $result['title']);
$this->assertSame(1091500, $result['steamid']);
$this->assertSame('game', $result['type']);
$this->assertSame('CD PROJEKT RED', $result['publisher']);
$this->assertSame(['CD PROJEKT RED'], $result['developers']);
$this->assertSame(86, $result['metacritic_score']);
$this->assertStringContainsString('Action', $result['genres']);
$this->assertContains('Windows', $result['platforms']);
$this->assertNotEmpty($result['screenshots']);
$this->assertNotEmpty($result['movies']);
$this->assertSame(44, $result['achievements']);
}
public function test_get_game_details_returns_false_on_api_failure(): void
{
Http::fake([
'store.steampowered.com/api/appdetails*' => Http::response([
'99999' => [
'success' => false,
],
]),
]);
$result = $this->service->getGameDetails(99999);
$this->assertFalse($result);
}
public function test_get_game_details_caches_result(): void
{
Http::fake([
'store.steampowered.com/api/appdetails*' => Http::response([
'1091500' => [
'success' => true,
'data' => [
'type' => 'game',
'name' => 'Cyberpunk 2077',
'steam_appid' => 1091500,
],
],
]),
]);
// First call
$result1 = $this->service->getGameDetails(1091500);
$this->assertSame('Cyberpunk 2077', $result1['title']);
// Second call should use cache (no HTTP request)
Http::fake([
'store.steampowered.com/api/appdetails*' => Http::response([
'1091500' => [
'success' => true,
'data' => [
'type' => 'game',
'name' => 'Different Name',
'steam_appid' => 1091500,
],
],
]),
]);
$result2 = $this->service->getGameDetails(1091500);
$this->assertSame('Cyberpunk 2077', $result2['title']); // Still cached
}
public function test_get_reviews_summary(): void
{
Http::fake([
'store.steampowered.com/api/appreviews/*' => Http::response([
'success' => 1,
'query_summary' => [
'total_positive' => 400000,
'total_negative' => 100000,
'total_reviews' => 500000,
'review_score' => 8,
'review_score_desc' => 'Very Positive',
],
]),
]);
$result = $this->service->getReviewsSummary(1091500);
$this->assertIsArray($result);
$this->assertSame(400000, $result['total_positive']);
$this->assertSame(100000, $result['total_negative']);
$this->assertSame(500000, $result['total_reviews']);
$this->assertSame('Very Positive', $result['review_score_desc']);
}
public function test_get_full_app_list(): void
{
Http::fake([
'api.steampowered.com/ISteamApps/GetAppList/v2/*' => Http::response([
'applist' => [
'apps' => [
['appid' => 10, 'name' => 'Counter-Strike'],
['appid' => 20, 'name' => 'Team Fortress Classic'],
['appid' => 30, 'name' => 'Day of Defeat'],
],
],
]),
]);
$result = $this->service->getFullAppList();
$this->assertIsArray($result);
$this->assertCount(3, $result);
$this->assertSame(10, $result[0]['appid']);
$this->assertSame('Counter-Strike', $result[0]['name']);
}
// ==========================================
// Populate Apps Table Tests
// ==========================================
public function test_populate_steam_apps_table(): void
{
Http::fake([
'api.steampowered.com/ISteamApps/GetAppList/v2/*' => Http::response([
'applist' => [
'apps' => [
['appid' => 10, 'name' => 'Counter-Strike'],
['appid' => 20, 'name' => 'Team Fortress Classic'],
['appid' => 30, 'name' => 'Day of Defeat'],
],
],
]),
]);
$stats = $this->service->populateSteamAppsTable();
$this->assertArrayHasKey('inserted', $stats);
$this->assertArrayHasKey('skipped', $stats);
$this->assertArrayHasKey('errors', $stats);
$this->assertSame(3, $stats['inserted']);
// Verify data in database
$this->assertDatabaseHas('steam_apps', ['appid' => 10, 'name' => 'Counter-Strike']);
$this->assertDatabaseHas('steam_apps', ['appid' => 20, 'name' => 'Team Fortress Classic']);
}
public function test_populate_steam_apps_table_skips_duplicates(): void
{
// Pre-insert some apps
SteamApp::query()->insert([
['appid' => 10, 'name' => 'Counter-Strike'],
]);
Http::fake([
'api.steampowered.com/ISteamApps/GetAppList/v2/*' => Http::response([
'applist' => [
'apps' => [
['appid' => 10, 'name' => 'Counter-Strike'],
['appid' => 20, 'name' => 'Team Fortress Classic'],
],
],
]),
]);
$stats = $this->service->populateSteamAppsTable();
$this->assertSame(1, $stats['inserted']);
$this->assertSame(1, $stats['skipped']);
}
// ==========================================
// Price Handling Tests
// ==========================================
public function test_handles_free_game(): void
{
Http::fake([
'store.steampowered.com/api/appdetails*' => Http::response([
'570' => [
'success' => true,
'data' => [
'type' => 'game',
'name' => 'Dota 2',
'steam_appid' => 570,
'is_free' => true,
],
],
]),
]);
$result = $this->service->getGameDetails(570);
$this->assertIsArray($result);
$this->assertNotNull($result['price']);
$this->assertSame(0.0, $result['price']['final']);
$this->assertSame('Free', $result['price']['final_formatted']);
}
public function test_handles_game_on_sale(): void
{
Http::fake([
'store.steampowered.com/api/appdetails*' => Http::response([
'1091500' => [
'success' => true,
'data' => [
'type' => 'game',
'name' => 'Cyberpunk 2077',
'steam_appid' => 1091500,
'price_overview' => [
'currency' => 'USD',
'initial' => 5999,
'final' => 2999,
'discount_percent' => 50,
'final_formatted' => '$29.99',
],
],
],
]),
]);
$result = $this->service->getGameDetails(1091500);
$this->assertSame(59.99, $result['price']['initial']);
$this->assertSame(29.99, $result['price']['final']);
$this->assertSame(50, $result['price']['discount_percent']);
}
// ==========================================
// Edge Cases
// ==========================================
public function test_handles_empty_search_term(): void
{
$result = $this->service->search('');
$this->assertNull($result);
$result = $this->service->search(' ');
$this->assertNull($result);
}
public function test_clean_title_handles_url_encoded_input(): void
{
$result = $this->service->cleanTitle('The%20Witcher%203');
$this->assertSame('The Witcher 3', $result);
}
public function test_handles_api_timeout(): void
{
Http::fake([
'store.steampowered.com/api/appdetails*' => function () {
throw new \Illuminate\Http\Client\ConnectionException('Connection timed out');
},
]);
$result = $this->service->getGameDetails(1091500);
$this->assertFalse($result);
}
}
+306
View File
@@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Support\DTOs\SteamGameData;
use App\Support\DTOs\SteamPriceData;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for Steam DTOs.
*/
class SteamDTOsTest extends TestCase
{
// ==========================================
// SteamPriceData Tests
// ==========================================
public function test_price_data_from_api_response(): void
{
$data = [
'currency' => 'USD',
'initial' => 5999,
'final' => 2999,
'discount_percent' => 50,
'final_formatted' => '$29.99',
];
$price = SteamPriceData::fromApiResponse($data);
$this->assertSame('USD', $price->currency);
$this->assertSame(59.99, $price->initial);
$this->assertSame(29.99, $price->final);
$this->assertSame(50, $price->discountPercent);
$this->assertSame('$29.99', $price->formattedPrice);
}
public function test_price_data_free(): void
{
$price = SteamPriceData::free();
$this->assertTrue($price->isFree());
$this->assertSame(0.0, $price->final);
$this->assertSame('Free', $price->formattedPrice);
}
public function test_price_data_is_on_sale(): void
{
$onSale = new SteamPriceData('USD', 59.99, 29.99, 50);
$notOnSale = new SteamPriceData('USD', 59.99, 59.99, 0);
$this->assertTrue($onSale->isOnSale());
$this->assertFalse($notOnSale->isOnSale());
}
public function test_price_data_get_savings(): void
{
$price = new SteamPriceData('USD', 59.99, 29.99, 50);
$this->assertEqualsWithDelta(30.00, $price->getSavings(), 0.01);
}
public function test_price_data_get_display_price(): void
{
$priceWithFormat = new SteamPriceData('USD', 59.99, 29.99, 50, '$29.99');
$priceWithoutFormat = new SteamPriceData('USD', 59.99, 29.99, 50);
$freePrice = SteamPriceData::free();
$this->assertSame('$29.99', $priceWithFormat->getDisplayPrice());
$this->assertSame('USD 29.99', $priceWithoutFormat->getDisplayPrice());
$this->assertSame('Free', $freePrice->getDisplayPrice());
}
public function test_price_data_to_array(): void
{
$price = new SteamPriceData('EUR', 49.99, 24.99, 50, '24,99€');
$array = $price->toArray();
$this->assertSame('EUR', $array['currency']);
$this->assertSame(49.99, $array['initial']);
$this->assertSame(24.99, $array['final']);
$this->assertSame(50, $array['discount_percent']);
$this->assertSame('24,99€', $array['formatted']);
}
// ==========================================
// SteamGameData Tests
// ==========================================
public function test_game_data_from_api_response(): void
{
$data = [
'type' => 'game',
'name' => 'Cyberpunk 2077',
'steam_appid' => 1091500,
'short_description' => 'An open-world RPG.',
'detailed_description' => 'Full description...',
'about_the_game' => 'About...',
'header_image' => 'https://cdn.steam.com/header.jpg',
'background' => 'https://cdn.steam.com/bg.jpg',
'publishers' => ['CD PROJEKT RED'],
'developers' => ['CD PROJEKT RED', 'CD PROJEKT'],
'genres' => [
['id' => '1', 'description' => 'Action'],
['id' => '3', 'description' => 'RPG'],
],
'categories' => [
['id' => 2, 'description' => 'Single-player'],
['id' => 36, 'description' => 'Online Multi-Player'],
],
'release_date' => [
'coming_soon' => false,
'date' => 'Dec 10, 2020',
],
'metacritic' => [
'score' => 86,
'url' => 'https://www.metacritic.com/game/cyberpunk-2077',
],
'platforms' => [
'windows' => true,
'mac' => false,
'linux' => true,
],
'is_free' => false,
'screenshots' => [
[
'id' => 0,
'path_thumbnail' => 'https://cdn.steam.com/ss_thumb.jpg',
'path_full' => 'https://cdn.steam.com/ss_full.jpg',
],
],
'movies' => [
[
'id' => 256123,
'name' => 'Launch Trailer',
'thumbnail' => 'https://cdn.steam.com/movie_thumb.jpg',
'mp4' => [
'480' => 'https://cdn.steam.com/movie_480.mp4',
'max' => 'https://cdn.steam.com/movie_max.mp4',
],
],
],
'achievements' => ['total' => 44],
'recommendations' => ['total' => 500000],
'website' => 'https://www.cyberpunk.net',
'dlc' => [1234, 5678],
];
$game = SteamGameData::fromApiResponse($data, 1091500);
$this->assertSame(1091500, $game->steamId);
$this->assertSame('Cyberpunk 2077', $game->title);
$this->assertSame('game', $game->type);
$this->assertSame('An open-world RPG.', $game->description);
$this->assertSame('CD PROJEKT RED', $game->publisher);
$this->assertCount(2, $game->developers);
$this->assertContains('Action', $game->genres);
$this->assertContains('RPG', $game->genres);
$this->assertContains('Windows', $game->platforms);
$this->assertContains('Linux', $game->platforms);
$this->assertNotContains('Mac', $game->platforms);
$this->assertSame(86, $game->metacriticScore);
$this->assertCount(1, $game->screenshots);
$this->assertCount(1, $game->movies);
$this->assertSame(44, $game->achievementCount);
$this->assertSame(500000, $game->recommendationCount);
$this->assertCount(2, $game->dlcIds);
}
public function test_game_data_is_game(): void
{
$game = new SteamGameData(1, 'Test', 'game');
$dlc = new SteamGameData(2, 'Test DLC', 'dlc');
$demo = new SteamGameData(3, 'Test Demo', 'demo');
$video = new SteamGameData(4, 'Test Video', 'video');
$this->assertTrue($game->isGame());
$this->assertFalse($dlc->isGame());
$this->assertTrue($demo->isGame());
$this->assertFalse($video->isGame());
}
public function test_game_data_is_free(): void
{
$freeGame = new SteamGameData(1, 'Free Game', price: SteamPriceData::free());
$paidGame = new SteamGameData(2, 'Paid Game', price: new SteamPriceData('USD', 59.99, 59.99, 0));
$noPriceGame = new SteamGameData(3, 'No Price Game');
$this->assertTrue($freeGame->isFree());
$this->assertFalse($paidGame->isFree());
$this->assertFalse($noPriceGame->isFree()); // null price = not free
}
public function test_game_data_get_primary_genre(): void
{
$gameWithGenres = new SteamGameData(1, 'Test', genres: ['Action', 'RPG', 'Adventure']);
$gameNoGenres = new SteamGameData(2, 'Test', genres: []);
$this->assertSame('Action', $gameWithGenres->getPrimaryGenre());
$this->assertNull($gameNoGenres->getPrimaryGenre());
}
public function test_game_data_supports_platform(): void
{
$game = new SteamGameData(1, 'Test', platforms: ['Windows', 'Linux']);
$this->assertTrue($game->supportsPlatform('Windows'));
$this->assertTrue($game->supportsPlatform('Linux'));
$this->assertFalse($game->supportsPlatform('Mac'));
}
public function test_game_data_has_multiplayer(): void
{
$multiplayerGame = new SteamGameData(1, 'Test', categories: ['Single-player', 'Online Multi-Player']);
$singlePlayerGame = new SteamGameData(2, 'Test', categories: ['Single-player']);
$coopGame = new SteamGameData(3, 'Test', categories: ['Online Co-op']);
$this->assertTrue($multiplayerGame->hasMultiplayer());
$this->assertFalse($singlePlayerGame->hasMultiplayer());
$this->assertTrue($coopGame->hasMultiplayer());
}
public function test_game_data_has_steam_workshop(): void
{
$withWorkshop = new SteamGameData(1, 'Test', categories: ['Steam Workshop', 'Single-player']);
$withoutWorkshop = new SteamGameData(2, 'Test', categories: ['Single-player']);
$this->assertTrue($withWorkshop->hasSteamWorkshop());
$this->assertFalse($withoutWorkshop->hasSteamWorkshop());
}
public function test_game_data_get_genres_string(): void
{
$game = new SteamGameData(1, 'Test', genres: ['Action', 'RPG', 'Adventure']);
$this->assertSame('Action, RPG, Adventure', $game->getGenresString());
}
public function test_game_data_to_games_info_array(): void
{
$game = new SteamGameData(
steamId: 1091500,
title: 'Cyberpunk 2077',
type: 'game',
description: 'An open-world RPG.',
coverUrl: 'https://cdn.steam.com/header.jpg',
backdropUrl: 'https://cdn.steam.com/bg.jpg',
trailerUrl: 'https://cdn.steam.com/trailer.mp4',
publisher: 'CD PROJEKT RED',
releaseDate: '2020-12-10',
genres: ['Action', 'RPG'],
metacriticScore: 86,
storeUrl: 'https://store.steampowered.com/app/1091500',
);
$array = $game->toGamesInfoArray();
$this->assertSame('Cyberpunk 2077', $array['title']);
$this->assertSame('1091500', $array['asin']);
$this->assertSame('https://store.steampowered.com/app/1091500', $array['url']);
$this->assertSame('CD PROJEKT RED', $array['publisher']);
$this->assertSame('2020-12-10', $array['releasedate']);
$this->assertSame('An open-world RPG.', $array['review']);
$this->assertSame(1, $array['cover']);
$this->assertSame(1, $array['backdrop']);
$this->assertSame('https://cdn.steam.com/trailer.mp4', $array['trailer']);
$this->assertSame('Steam', $array['classused']);
$this->assertSame('86', $array['esrb']);
$this->assertSame('Action,RPG', $array['genres']);
}
public function test_game_data_handles_missing_optional_fields(): void
{
$data = [
'type' => 'game',
'name' => 'Minimal Game',
'steam_appid' => 12345,
];
$game = SteamGameData::fromApiResponse($data, 12345);
$this->assertSame(12345, $game->steamId);
$this->assertSame('Minimal Game', $game->title);
$this->assertNull($game->description);
$this->assertNull($game->publisher);
$this->assertEmpty($game->developers);
$this->assertEmpty($game->genres);
$this->assertEmpty($game->platforms);
$this->assertNull($game->price);
$this->assertNull($game->metacriticScore);
}
public function test_game_data_immutability(): void
{
$game = new SteamGameData(1, 'Test');
// This should be a compile error in strict mode, or readonly
// We just verify the properties are accessible but not writable
$this->assertSame(1, $game->steamId);
$this->assertSame('Test', $game->title);
// The readonly keyword ensures immutability at compile time
}
}
+357
View File
@@ -0,0 +1,357 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Services\SteamService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* Unit tests for SteamService.
*/
class SteamServiceTest extends TestCase
{
private function getInstance(): SteamService
{
$ref = new ReflectionClass(SteamService::class);
return $ref->newInstanceWithoutConstructor();
}
private function invokeMethod(object $obj, string $method, array $args = []): mixed
{
$ref = new ReflectionClass($obj);
$m = $ref->getMethod($method);
$m->setAccessible(true);
return $m->invokeArgs($obj, $args);
}
// ==========================================
// Title Cleaning Tests
// ==========================================
#[DataProvider('cleanTitleProvider')]
public function test_clean_title(string $input, string $expected): void
{
$service = $this->getInstance();
$result = $service->cleanTitle($input);
$this->assertSame($expected, $result, "Failed for input: {$input}");
}
public static function cleanTitleProvider(): array
{
return [
// Basic scene releases
['Cyberpunk.2077-GOG', 'Cyberpunk 2077'],
['The.Witcher.3.Wild.Hunt-CODEX', 'The Witcher 3 Wild Hunt'],
['Elden.Ring-EMPRESS', 'Elden Ring'],
['Baldurs.Gate.3-SKIDROW', 'Baldurs Gate 3'],
// With version numbers
['Starfield.v1.7.29-FLT', 'Starfield'],
['Cyberpunk.2077.v2.1.0.1-GOG', 'Cyberpunk 2077'],
['Game.v1.2.3.4.5-CODEX', 'Game'],
// Edition tags
['ELDEN RING [v1.08 + DLCs] - EMPRESS', 'ELDEN RING'],
['The Witcher 3 Wild Hunt GOTY - GOG', 'The Witcher 3 Wild Hunt'],
['Horizon Zero Dawn Complete Edition - CODEX', 'Horizon Zero Dawn'],
['Red Dead Redemption 2 Ultimate Edition-EMPRESS', 'Red Dead Redemption 2'],
// FitGirl-style releases - may include some noise
['[FitGirl] Red.Dead.Redemption.2.v1.0.1436.28.Repack', 'Red Dead Redemption 2'],
// Complex releases - these may retain some noise
['Resident.Evil.4.Remake.REPACK-DODI', 'Resident Evil 4 Remake'],
// Special characters
["Assassin's.Creed.Valhalla-CODEX", "Assassin's Creed Valhalla"],
['Tom.Clancys.Ghost.Recon.Breakpoint-EMPRESS', 'Tom Clancys Ghost Recon Breakpoint'],
// Already clean
['Cyberpunk 2077', 'Cyberpunk 2077'],
['The Witcher 3 Wild Hunt', 'The Witcher 3 Wild Hunt'],
// Edge cases
['', ''],
[' ', ''],
];
}
// ==========================================
// Title Normalization Tests
// ==========================================
#[DataProvider('normalizeTitleProvider')]
public function test_normalize_title(string $input, string $expectedContains, string $expectedNotContains = ''): void
{
$service = $this->getInstance();
$result = $this->invokeMethod($service, 'normalizeTitle', [$input]);
if ($expectedContains !== '') {
$this->assertStringContainsString($expectedContains, $result, "Expected '{$expectedContains}' in result '{$result}'");
}
if ($expectedNotContains !== '') {
$this->assertStringNotContainsString($expectedNotContains, $result, "Expected '{$expectedNotContains}' NOT in result '{$result}'");
}
}
public static function normalizeTitleProvider(): array
{
return [
['The Witcher 3: Wild Hunt', 'witcher', 'the'],
['ELDEN RING', 'elden ring', ''],
['Red Dead Redemption 2', 'red dead redemption 2', ''],
['Cyberpunk 2077 GOTY Edition', 'cyberpunk 2077', 'goty'],
['Assassin\'s Creed IV: Black Flag', 'assassin', ''],
];
}
// ==========================================
// Roman Numeral Replacement Tests
// ==========================================
#[DataProvider('romanNumeralProvider')]
public function test_replace_roman_numerals(string $input, string $expected): void
{
$service = $this->getInstance();
$result = $this->invokeMethod($service, 'replaceRomanNumerals', [$input]);
$this->assertSame($expected, $result);
}
public static function romanNumeralProvider(): array
{
return [
['final fantasy vii', 'final fantasy 7'],
['final fantasy viii', 'final fantasy 8'],
['resident evil iv', 'resident evil 4'],
['witcher iii', 'witcher 3'],
['gta v', 'gta 5'],
['civilization vi', 'civilization 6'],
['elder scrolls iv oblivion', 'elder scrolls 4 oblivion'],
['dark souls ii', 'dark souls 2'],
['street fighter ii', 'street fighter 2'],
['assassins creed ii', 'assassins creed 2'],
// Should not replace standalone 'i' (common in titles)
['i robot', 'i robot'],
// Should handle multiple numerals
['title ii and iii', 'title 2 and 3'],
];
}
// ==========================================
// Query Variants Generation Tests
// ==========================================
public function test_generate_query_variants_includes_base_title(): void
{
$service = $this->getInstance();
$variants = $this->invokeMethod($service, 'generateQueryVariants', ['The Witcher 3: Wild Hunt - Game of the Year Edition']);
$this->assertIsArray($variants);
$this->assertNotEmpty($variants);
// Should include variants without edition tags
$found = false;
foreach ($variants as $v) {
if (stripos($v, 'game of the year') === false && stripos($v, 'witcher') !== false) {
$found = true;
break;
}
}
$this->assertTrue($found, 'Expected base title variant without edition tags');
}
public function test_generate_query_variants_handles_colon_titles(): void
{
$service = $this->getInstance();
$variants = $this->invokeMethod($service, 'generateQueryVariants', ["Assassin's Creed IV: Black Flag"]);
$this->assertIsArray($variants);
// Should include left side of colon
$foundLeft = false;
foreach ($variants as $v) {
if (stripos($v, 'black flag') === false && stripos($v, "Assassin's Creed") !== false) {
$foundLeft = true;
break;
}
}
$this->assertTrue($foundLeft, 'Expected left-side of colon as a variant');
}
// ==========================================
// Title Scoring Tests
// ==========================================
#[DataProvider('scoreTitleProvider')]
public function test_score_title(string $candidate, string $original, float $minScore): void
{
$service = $this->getInstance();
$score = $this->invokeMethod($service, 'scoreTitle', [$candidate, $original]);
$this->assertIsFloat($score);
$this->assertGreaterThanOrEqual($minScore, $score, "Score {$score} should be >= {$minScore} for '{$candidate}' vs '{$original}'");
}
public static function scoreTitleProvider(): array
{
return [
// Exact matches
['Cyberpunk 2077', 'Cyberpunk 2077', 100.0],
['The Witcher 3: Wild Hunt', 'The Witcher 3: Wild Hunt', 100.0],
// Close matches (should score high)
['The Witcher 3: Wild Hunt', 'The.Witcher.3.Wild.Hunt-CODEX', 90.0],
['Elden Ring', 'ELDEN RING - Deluxe Edition - EMPRESS', 70.0],
['Cyberpunk 2077', 'Cyberpunk.2077.v2.1-GOG', 70.0],
['Red Dead Redemption 2', '[FitGirl] Red.Dead.Redemption.2.Repack', 80.0],
// Partial matches
['Witcher 3', 'The Witcher 3: Wild Hunt', 50.0],
// Non-matches (should score low)
['Cyberpunk 2077', 'Elden Ring', 0.0],
];
}
public function test_score_title_perfect_normalized_match(): void
{
$service = $this->getInstance();
// These should normalize to the same thing
$score = $this->invokeMethod($service, 'scoreTitle', [
'The Witcher 3: Wild Hunt',
'The.Witcher.III.Wild.Hunt.GOTY-CODEX'
]);
// With roman numeral conversion and normalization, these should be very similar
$this->assertGreaterThanOrEqual(85.0, $score);
}
// ==========================================
// LIKE Pattern Building Tests
// ==========================================
public function test_build_like_pattern(): void
{
$service = $this->getInstance();
$pattern = $this->invokeMethod($service, 'buildLikePattern', ['Resident Evil 4']);
$this->assertStringStartsWith('%', $pattern);
$this->assertStringEndsWith('%', $pattern);
$this->assertStringContainsString('resident', strtolower($pattern));
$this->assertStringContainsString('evil', strtolower($pattern));
$this->assertStringContainsString('4', $pattern);
}
public function test_build_like_pattern_handles_roman_numerals(): void
{
$service = $this->getInstance();
$pattern = $this->invokeMethod($service, 'buildLikePattern', ['Resident Evil VII Biohazard']);
$this->assertStringContainsString('7', $pattern);
$this->assertStringNotContainsString('vii', strtolower($pattern));
}
// ==========================================
// Tokenization Tests
// ==========================================
public function test_tokenize(): void
{
$service = $this->getInstance();
$tokens = $this->invokeMethod($service, 'tokenize', ['The Witcher 3 Wild Hunt']);
$this->assertIsArray($tokens);
$this->assertContains('witcher', $tokens);
$this->assertContains('3', $tokens);
$this->assertContains('wild', $tokens);
$this->assertContains('hunt', $tokens);
}
public function test_tokenize_deduplicates(): void
{
$service = $this->getInstance();
$tokens = $this->invokeMethod($service, 'tokenize', ['game game game']);
$this->assertCount(1, $tokens);
$this->assertSame(['game'], $tokens);
}
// ==========================================
// Strip Edition Tags Tests
// ==========================================
#[DataProvider('stripEditionTagsProvider')]
public function test_strip_edition_tags(string $input, string $expected): void
{
$service = $this->getInstance();
$result = $this->invokeMethod($service, 'stripEditionTags', [$input]);
$this->assertSame($expected, $result);
}
public static function stripEditionTagsProvider(): array
{
return [
['Cyberpunk 2077 Ultimate Edition', 'Cyberpunk 2077'],
['Horizon Zero Dawn', 'Horizon Zero Dawn'], // No tags
];
}
// ==========================================
// Integration-style Tests (Method Combinations)
// ==========================================
public function test_full_matching_workflow(): void
{
$service = $this->getInstance();
// Simulate a scene release name
$releaseName = 'The.Witcher.III.Wild.Hunt.GOTY.Edition-CODEX';
// Clean it
$clean = $service->cleanTitle($releaseName);
$this->assertStringNotContainsString('.', $clean);
$this->assertStringNotContainsString('CODEX', $clean);
// Generate variants
$variants = $this->invokeMethod($service, 'generateQueryVariants', [$clean]);
$this->assertNotEmpty($variants);
// Normalize for matching
$normalized = $this->invokeMethod($service, 'normalizeTitle', [$clean]);
$this->assertStringContainsString('witcher', $normalized);
$this->assertStringContainsString('3', $normalized); // Roman numeral converted
}
public function test_matching_workflow_with_various_formats(): void
{
$testCases = [
'Cyberpunk.2077-GOG' => 'cyberpunk 2077',
'ELDEN RING [v1.08 + DLCs] - EMPRESS' => 'elden ring',
'[FitGirl] Red.Dead.Redemption.2.v1.0.1436.28.Repack' => 'red dead redemption 2',
'Baldurs_Gate_3_v1.0.2_MULTI12-EMPRESS' => 'baldurs gate 3',
];
$service = $this->getInstance();
foreach ($testCases as $input => $expectedInNormalized) {
$clean = $service->cleanTitle($input);
$normalized = $this->invokeMethod($service, 'normalizeTitle', [$clean]);
$this->assertStringContainsString(
$expectedInNormalized,
$normalized,
"Failed for input: {$input}"
);
}
}
}