mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update Steam support
This commit is contained in:
+75
-24
@@ -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)
|
||||
// --------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user