Semvar & Automatic Resolution - Remix

- Updated the SptVersion and ModVersion dependancies to resolve *all* compatible versions and introduced new relationships to pull just the latest compatible version. Had to rewrite a *bunch*, but it should be much more capable now. It can be expensive to resolve these properties when iterated over, so *make sure they're eager loaded using the `with` method when you're building the queries*.
- Updated the mod listing Livewire component to save the filter options within the PHP session instead of in browser local storage. *Much* cleaner.
- Removed caching from homepage queries to see how they preform on production. Will add back later.
- Updated ModVersion factory to create SptVersions if there are none specified.
- Probably lots of other changes too... I need to make smaller commits. :(
This commit is contained in:
Refringe 2024-08-29 15:46:10 -04:00
parent da706636a8
commit 1783a683ed
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
34 changed files with 957 additions and 720 deletions

View File

@ -30,13 +30,13 @@ class ModController extends Controller
$mod = Mod::withTotalDownloads()
->with([
'versions',
'versions.sptVersion',
'versions.dependencies',
'versions.dependencies.resolvedVersion',
'versions.dependencies.resolvedVersion.mod',
'versions.latestSptVersion:id,version,color_class',
'versions.latestResolvedDependencies',
'versions.latestResolvedDependencies.mod:id,name,slug',
'users:id,name',
'license:id,name,link',
])
->whereHas('latestVersion')
->findOrFail($modId);
if ($mod->slug !== $slug) {
@ -45,9 +45,7 @@ class ModController extends Controller
$this->authorize('view', $mod);
$latestVersion = $mod->versions->first();
return view('mod.show', compact(['mod', 'latestVersion']));
return view('mod.show', compact(['mod']));
}
public function update(ModRequest $request, Mod $mod)

View File

@ -31,7 +31,12 @@ class ModFilter
{
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at'])
->withTotalDownloads()
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name']);
->with([
'users:id,name',
'latestVersion' => function ($query) {
$query->with('latestSptVersion:id,version,color_class');
},
]);
}
/**
@ -95,12 +100,10 @@ class ModFilter
/**
* Filter the results to a specific SPT version.
*/
private function sptVersion(array $versions): Builder
private function sptVersions(array $versions): Builder
{
return $this->builder->whereHas('latestVersion', function ($query) use ($versions) {
$query->whereHas('sptVersion', function ($query) use ($versions) {
$query->whereIn('version', $versions);
});
return $this->builder->whereHas('latestVersion.sptVersions', function ($query) use ($versions) {
$query->whereIn('spt_versions.version', $versions);
});
}
}

View File

@ -608,8 +608,6 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Import the SPT versions from the Hub database to the local database.
*
* @throws Exception
*/
protected function importSptVersions(): void
{
@ -938,7 +936,6 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
'description' => $this->cleanHubContent($versionContent->description ?? ''),
'link' => $version->downloadURL,
'spt_version_constraint' => $sptVersionConstraint,
'resolved_spt_version_id' => null,
'virus_total_link' => $optionVirusTotal?->virus_total_link ?? '',
'downloads' => max((int) $version->downloads, 0), // At least 0.
'disabled' => (bool) $version->isDisabled,
@ -955,9 +952,9 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
'description',
'link',
'spt_version_constraint',
'resolved_spt_version_id',
'virus_total_link',
'downloads',
'disabled',
'published_at',
'created_at',
'updated_at',

View File

@ -6,7 +6,9 @@ use App\Http\Filters\ModFilter;
use App\Models\SptVersion;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Session;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
@ -19,24 +21,28 @@ class Index extends Component
* The search query value.
*/
#[Url]
#[Session]
public string $query = '';
/**
* The sort order value.
*/
#[Url]
#[Session]
public string $order = 'created';
/**
* The SPT version filter value.
* The SPT versions filter value.
*/
#[Url]
public array $sptVersion = [];
#[Session]
public array $sptVersions = [];
/**
* The featured filter value.
*/
#[Url]
#[Session]
public string $featured = 'include';
/**
@ -49,11 +55,13 @@ class Index extends Component
*/
public function mount(): void
{
// TODO: This should be updated to only pull versions that have mods associated with them.
// To do this, the ModVersion to SptVersion relationship needs to be converted to a many-to-many relationship. Ugh.
$this->availableSptVersions = SptVersion::select(['id', 'version', 'color_class'])->orderByDesc('version')->get();
// TODO: This should ideally be updated to only pull SPT versions that have mods associated with them so that no
// empty options are shown in the listing filter.
$this->availableSptVersions = Cache::remember('availableSptVersions', 60 * 60, function () {
return SptVersion::select(['id', 'version', 'color_class'])->orderByDesc('version')->get();
});
$this->sptVersion = $this->getLatestMinorVersions()->pluck('version')->toArray();
$this->sptVersions = $this->sptVersions ?? $this->getLatestMinorVersions()->pluck('version')->toArray();
}
/**
@ -76,7 +84,7 @@ class Index extends Component
'query' => $this->query,
'featured' => $this->featured,
'order' => $this->order,
'sptVersion' => $this->sptVersion,
'sptVersions' => $this->sptVersions,
];
$mods = (new ModFilter($filters))->apply()->paginate(16);
@ -89,7 +97,7 @@ class Index extends Component
public function resetFilters(): void
{
$this->query = '';
$this->sptVersion = $this->getLatestMinorVersions()->pluck('version')->toArray();
$this->sptVersions = $this->getLatestMinorVersions()->pluck('version')->toArray();
$this->featured = 'include';
// Clear local storage
@ -109,7 +117,7 @@ class Index extends Component
if ($this->featured !== 'include') {
$count++;
}
$count += count($this->sptVersion);
$count += count($this->sptVersions);
return $count;
}

View File

@ -58,16 +58,11 @@ class Mod extends Model
/**
* The relationship between a mod and its versions.
*/
public function versions(bool $resolvedOnly = true): HasMany
public function versions(): HasMany
{
$relation = $this->hasMany(ModVersion::class)
return $this->hasMany(ModVersion::class)
->whereHas('sptVersions')
->orderByDesc('version');
if ($resolvedOnly) {
$relation->whereNotNull('resolved_spt_version_id');
}
return $relation;
}
/**
@ -84,16 +79,11 @@ class Mod extends Model
/**
* The relationship between a mod and its last updated version.
*/
public function lastUpdatedVersion(bool $resolvedOnly = true): HasOne
public function lastUpdatedVersion(): HasOne
{
$relation = $this->hasOne(ModVersion::class)
return $this->hasOne(ModVersion::class)
->whereHas('sptVersions')
->orderByDesc('updated_at');
if ($resolvedOnly) {
$relation->whereNotNull('resolved_spt_version_id');
}
return $relation;
}
/**
@ -101,10 +91,8 @@ class Mod extends Model
*/
public function toSearchableArray(): array
{
$latestVersion = $this->latestVersion()->with('sptVersion')->first();
return [
'id' => (int) $this->id,
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
@ -113,26 +101,21 @@ class Mod extends Model
'created_at' => strtotime($this->created_at),
'updated_at' => strtotime($this->updated_at),
'published_at' => strtotime($this->published_at),
'latestVersion' => $latestVersion?->sptVersion->version,
'latestVersionColorClass' => $latestVersion?->sptVersion->color_class,
'latestVersion' => $this->latestVersion()?->first()?->latestSptVersion()?->first()?->version_formatted,
'latestVersionColorClass' => $this->latestVersion()?->first()?->latestSptVersion()?->first()?->color_class,
];
}
/**
* The relationship to the latest mod version, dictated by the mod version number.
*/
public function latestVersion(bool $resolvedOnly = true): HasOne
public function latestVersion(): HasOne
{
$relation = $this->hasOne(ModVersion::class)
return $this->hasOne(ModVersion::class)
->whereHas('sptVersions')
->orderByDesc('version')
->orderByDesc('updated_at')
->take(1);
if ($resolvedOnly) {
$relation->whereNotNull('resolved_spt_version_id');
}
return $relation;
}
/**
@ -140,7 +123,31 @@ class Mod extends Model
*/
public function shouldBeSearchable(): bool
{
return ! $this->disabled;
// Ensure the mod is not disabled.
if ($this->disabled) {
return false;
}
// Ensure the mod has a publish date.
if (is_null($this->published_at)) {
return false;
}
// Fetch the latest version instance.
$latestVersion = $this->latestVersion()?->first();
// Ensure the mod has a latest version.
if (is_null($latestVersion)) {
return false;
}
// Ensure the latest version has a latest SPT version.
if ($latestVersion->latestSptVersion()->doesntExist()) {
return false;
}
// All conditions are met; the mod should be searchable.
return true;
}
/**

View File

@ -5,12 +5,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property int $mod_version_id
* @property int $dependency_mod_id
* @property string $version_constraint
* @property string $constraint
* @property int|null $resolved_version_id
*/
class ModDependency extends Model
@ -18,7 +19,7 @@ class ModDependency extends Model
use HasFactory;
/**
* The relationship between a mod dependency and mod version.
* The relationship between the mod dependency and the mod version.
*/
public function modVersion(): BelongsTo
{
@ -26,18 +27,18 @@ class ModDependency extends Model
}
/**
* The relationship between the mod dependency and the mod that is depended on.
* The relationship between the mod dependency and the resolved dependency.
*/
public function dependencyMod(): BelongsTo
public function resolvedDependencies(): HasMany
{
return $this->belongsTo(Mod::class, 'dependency_mod_id');
return $this->hasMany(ModResolvedDependency::class, 'dependency_id');
}
/**
* The relationship between a mod dependency and resolved mod version.
* The relationship between the mod dependency and the dependent mod.
*/
public function resolvedVersion(): BelongsTo
public function dependentMod(): BelongsTo
{
return $this->belongsTo(ModVersion::class, 'resolved_version_id');
return $this->belongsTo(Mod::class, 'dependent_mod_id');
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ModResolvedDependency extends Model
{
/**
* The relationship between the resolved dependency and the mod version.
*/
public function modVersion(): BelongsTo
{
return $this->belongsTo(ModVersion::class, 'mod_version_id');
}
/**
* The relationship between the resolved dependency and the dependency.
*/
public function dependency(): BelongsTo
{
return $this->belongsTo(ModDependency::class);
}
/**
* The relationship between the resolved dependency and the resolved mod version.
*/
public function resolvedModVersion(): BelongsTo
{
return $this->belongsTo(ModVersion::class, 'resolved_mod_version_id');
}
}

View File

@ -7,6 +7,7 @@ use App\Models\Scopes\PublishedScope;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
@ -39,22 +40,53 @@ class ModVersion extends Model
/**
* The relationship between a mod version and its dependencies.
*/
public function dependencies(bool $resolvedOnly = true): HasMany
public function dependencies(): HasMany
{
$relation = $this->hasMany(ModDependency::class);
if ($resolvedOnly) {
$relation->whereNotNull('resolved_version_id');
}
return $relation;
return $this->hasMany(ModDependency::class);
}
/**
* The relationship between a mod version and SPT version.
* The relationship between a mod version and its resolved dependencies.
*/
public function sptVersion(): BelongsTo
public function resolvedDependencies(): BelongsToMany
{
return $this->belongsTo(SptVersion::class, 'resolved_spt_version_id');
return $this->belongsToMany(ModVersion::class, 'mod_resolved_dependencies', 'mod_version_id', 'resolved_mod_version_id')
->withPivot('dependency_id')
->withTimestamps();
}
/**
* The relationship between a mod version and its each of it's resolved dependencies' latest versions.
*/
public function latestResolvedDependencies(): BelongsToMany
{
return $this->belongsToMany(ModVersion::class, 'mod_resolved_dependencies', 'mod_version_id', 'resolved_mod_version_id')
->withPivot('dependency_id')
->join('mod_versions as latest_versions', function ($join) {
$join->on('latest_versions.id', '=', 'mod_versions.id')
->whereRaw('latest_versions.version = (SELECT MAX(mv.version) FROM mod_versions mv WHERE mv.mod_id = mod_versions.mod_id)');
})
->with('mod:id,name,slug')
->withTimestamps();
}
/**
* The relationship between a mod version and each of its SPT versions' latest version.
* Hint: Be sure to call `->first()` on this to get the actual instance.
*/
public function latestSptVersion(): BelongsToMany
{
return $this->belongsToMany(SptVersion::class, 'mod_version_spt_version')
->orderBy('version', 'desc')
->limit(1);
}
/**
* The relationship between a mod version and its SPT versions.
*/
public function sptVersions(): BelongsToMany
{
return $this->belongsToMany(SptVersion::class, 'mod_version_spt_version')
->orderByDesc('version');
}
}

View File

@ -6,7 +6,7 @@ use App\Exceptions\InvalidVersionNumberException;
use App\Services\LatestSptVersionService;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\App;
@ -14,12 +14,20 @@ class SptVersion extends Model
{
use HasFactory, SoftDeletes;
/**
* Get the version with "SPT " prepended.
*/
public function getVersionFormattedAttribute(): string
{
return __('SPT ').$this->version;
}
/**
* The relationship between an SPT version and mod version.
*/
public function modVersions(): HasMany
public function modVersions(): BelongsToMany
{
return $this->hasMany(ModVersion::class);
return $this->belongsToMany(ModVersion::class, 'mod_version_spt_version');
}
/**

View File

@ -2,9 +2,7 @@
namespace App\Observers;
use App\Exceptions\CircularDependencyException;
use App\Models\ModDependency;
use App\Models\ModVersion;
use App\Services\DependencyVersionService;
class ModDependencyObserver
@ -18,34 +16,17 @@ class ModDependencyObserver
/**
* Handle the ModDependency "saved" event.
*
* @throws CircularDependencyException
*/
public function saved(ModDependency $modDependency): void
{
$this->resolveDependencyVersion($modDependency);
}
/**
* Resolve the ModDependency's dependencies.
*
* @throws CircularDependencyException
*/
public function resolveDependencyVersion(ModDependency $modDependency): void
{
$modVersion = ModVersion::find($modDependency->mod_version_id);
if ($modVersion) {
$this->dependencyVersionService->resolve($modVersion);
}
$this->dependencyVersionService->resolve($modDependency->modVersion);
}
/**
* Handle the ModDependency "deleted" event.
*
* @throws CircularDependencyException
*/
public function deleted(ModDependency $modDependency): void
{
$this->resolveDependencyVersion($modDependency);
$this->dependencyVersionService->resolve($modDependency->modVersion);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Observers;
use App\Models\Mod;
use App\Services\DependencyVersionService;
class ModObserver
{
protected DependencyVersionService $dependencyVersionService;
public function __construct(
DependencyVersionService $dependencyVersionService,
) {
$this->dependencyVersionService = $dependencyVersionService;
}
/**
* Handle the Mod "saved" event.
*/
public function saved(Mod $mod): void
{
foreach ($mod->versions as $modVersion) {
$this->dependencyVersionService->resolve($modVersion);
}
}
}

View File

@ -2,8 +2,6 @@
namespace App\Observers;
use App\Exceptions\CircularDependencyException;
use App\Models\ModDependency;
use App\Models\ModVersion;
use App\Services\DependencyVersionService;
use App\Services\SptVersionService;
@ -24,35 +22,18 @@ class ModVersionObserver
/**
* Handle the ModVersion "saved" event.
*
* @throws CircularDependencyException
*/
public function saved(ModVersion $modVersion): void
{
$this->resolveDependencyVersion($modVersion);
$this->dependencyVersionService->resolve($modVersion);
$this->sptVersionService->resolve($modVersion);
}
/**
* Resolve the ModVersion's dependencies.
*
* @throws CircularDependencyException
*/
private function resolveDependencyVersion(ModVersion $modVersion): void
{
$dependencies = ModDependency::where('resolved_version_id', $modVersion->id)->get();
foreach ($dependencies as $dependency) {
$this->dependencyVersionService->resolve($dependency->modVersion);
}
}
/**
* Handle the ModVersion "deleted" event.
*
* @throws CircularDependencyException
*/
public function deleted(ModVersion $modVersion): void
{
$this->resolveDependencyVersion($modVersion);
$this->dependencyVersionService->resolve($modVersion);
}
}

View File

@ -2,11 +2,13 @@
namespace App\Providers;
use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModVersion;
use App\Models\SptVersion;
use App\Models\User;
use App\Observers\ModDependencyObserver;
use App\Observers\ModObserver;
use App\Observers\ModVersionObserver;
use App\Observers\SptVersionObserver;
use App\Services\LatestSptVersionService;
@ -36,6 +38,7 @@ class AppServiceProvider extends ServiceProvider
Model::unguard();
// Register observers.
Mod::observe(ModObserver::class);
ModVersion::observe(ModVersionObserver::class);
ModDependency::observe(ModDependencyObserver::class);
SptVersion::observe(SptVersionObserver::class);

View File

@ -2,118 +2,46 @@
namespace App\Services;
use App\Exceptions\CircularDependencyException;
use App\Models\ModDependency;
use App\Models\ModVersion;
use Composer\Semver\Semver;
class DependencyVersionService
{
/**
* Keep track of visited versions to avoid resolving them again.
* Resolve the dependencies for a mod version.
*/
protected array $visited = [];
/**
* Keep track of the current path in the depth-first search.
*/
protected array $stack = [];
/**
* Resolve dependencies for the given mod version.
*
* @throws CircularDependencyException
*/
public function resolve(ModVersion $modVersion): array
public function resolve(ModVersion $modVersion): void
{
$this->visited = [];
$this->stack = [];
// Store the resolved versions for each dependency.
$resolvedVersions = [];
// Start the recursive depth-first search to resolve dependencies.
$this->processDependencies($modVersion, $resolvedVersions);
return $resolvedVersions;
$dependencies = $this->satisfyConstraint($modVersion);
$modVersion->resolvedDependencies()->sync($dependencies);
}
/**
* Perform a depth-first search to resolve dependencies for the given mod version.
*
* @throws CircularDependencyException
* Satisfies all dependency constraints of a ModVersion.
*/
protected function processDependencies(ModVersion $modVersion, array &$resolvedVersions): void
private function satisfyConstraint(ModVersion $modVersion): array
{
// Detect circular dependencies
if (in_array($modVersion->id, $this->stack)) {
throw new CircularDependencyException("Circular dependency detected in ModVersion ID: {$modVersion->id}");
}
// Eager load the dependencies and their mod versions.
$modVersion->load('dependencies.dependentMod.versions');
// Skip already processed versions
if (in_array($modVersion->id, $this->visited)) {
return;
}
// Iterate over each ModVersion dependency.
$dependencies = [];
foreach ($modVersion->dependencies as $dependency) {
// Mark the current version
$this->visited[] = $modVersion->id;
$this->stack[] = $modVersion->id;
// Get all dependent mod versions.
$dependentModVersions = $dependency->dependentMod->versions()->get();
// Get the dependencies for the current mod version.
$dependencies = $modVersion->dependencies(resolvedOnly: false)->get();
// Filter the dependent mod versions to find the ones that satisfy the dependency constraint.
$matchedVersions = $dependentModVersions->filter(function ($version) use ($dependency) {
return Semver::satisfies($version->version, $dependency->constraint);
});
foreach ($dependencies as $dependency) {
// Resolve the latest mod version ID that satisfies the version constraint on the mod version dependency.
$resolvedId = $this->resolveDependency($dependency);
// Update the resolved version ID for the dependency if it has changed.
// Do it "quietly" to avoid triggering the observer again.
if ($dependency->resolved_version_id !== $resolvedId) {
$dependency->updateQuietly(['resolved_version_id' => $resolvedId]);
}
// At this point, the dependency has been resolved (or not) and we can add it to the resolved versions to
// avoid resolving it again in the future and to help with circular dependency detection.
$resolvedVersions[$dependency->id] = $resolvedId ? ModVersion::find($resolvedId) : null;
// Recursively process the resolved dependency.
if ($resolvedId) {
$nextModVersion = ModVersion::find($resolvedId);
if ($nextModVersion) {
$this->processDependencies($nextModVersion, $resolvedVersions);
}
// Map the matched versions to the sync data.
foreach ($matchedVersions as $matchedVersion) {
$dependencies[$matchedVersion->id] = ['dependency_id' => $dependency->id];
}
}
// Remove the current version from the stack now that we have processed all its dependencies.
array_pop($this->stack);
}
/**
* Resolve the latest mod version ID that satisfies the version constraint on the mod version dependency.
*/
protected function resolveDependency(ModDependency $dependency): ?int
{
$dependencyModVersions = $dependency->dependencyMod->versions(resolvedOnly: false);
// There are no mod versions for the dependency mod.
if ($dependencyModVersions->doesntExist()) {
return null;
}
$availableVersions = $dependencyModVersions->pluck('id', 'version')->toArray();
$satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $dependency->version_constraint);
// There are no mod versions that satisfy the version constraint.
if (empty($satisfyingVersions)) {
return null;
}
// Sort the satisfying versions in descending order using the version_compare function.
usort($satisfyingVersions, 'version_compare');
$satisfyingVersions = array_reverse($satisfyingVersions);
// Return the latest (highest version number) satisfying version.
return $availableVersions[$satisfyingVersions[0]];
return $dependencies;
}
}

View File

@ -13,14 +13,14 @@ class SptVersionService
*/
public function resolve(ModVersion $modVersion): void
{
$modVersion->resolved_spt_version_id = $this->satisfyconstraint($modVersion);
$modVersion->saveQuietly();
$satisfyingVersionIds = $this->satisfyConstraint($modVersion);
$modVersion->sptVersions()->sync($satisfyingVersionIds);
}
/**
* Satisfies the version constraint of a given ModVersion. Returns the ID of the satisfying SptVersion.
*/
private function satisfyConstraint(ModVersion $modVersion): ?int
private function satisfyConstraint(ModVersion $modVersion): array
{
$availableVersions = SptVersion::query()
->orderBy('version', 'desc')
@ -29,14 +29,10 @@ class SptVersionService
$satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $modVersion->spt_version_constraint);
if (empty($satisfyingVersions)) {
return null;
return [];
}
// Ensure the satisfying versions are sorted in descending order to get the latest version
usort($satisfyingVersions, 'version_compare');
$satisfyingVersions = array_reverse($satisfyingVersions);
// Return the ID of the latest satisfying version
return $availableVersions[$satisfyingVersions[0]];
// Return the IDs of all satisfying versions
return array_map(fn ($version) => $availableVersions[$version], $satisfyingVersions);
}
}

View File

@ -6,7 +6,6 @@ use App\Models\Mod;
use App\Models\ModVersion;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\Component;
class ModListSection extends Component
@ -26,44 +25,53 @@ class ModListSection extends Component
private function fetchFeaturedMods(): Collection
{
return Cache::remember('homepage-featured-mods', now()->addMinutes(5), function () {
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured'])
->withTotalDownloads()
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name'])
->where('featured', true)
->latest()
->limit(6)
->get();
});
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured'])
->withTotalDownloads()
->with([
'latestVersion',
'latestVersion.latestSptVersion:id,version,color_class',
'users:id,name',
'license:id,name,link',
])
->where('featured', true)
->latest()
->limit(6)
->get();
}
private function fetchLatestMods(): Collection
{
return Cache::remember('homepage-latest-mods', now()->addMinutes(5), function () {
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at'])
->withTotalDownloads()
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name'])
->latest()
->limit(6)
->get();
});
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at'])
->withTotalDownloads()
->with([
'latestVersion',
'latestVersion.latestSptVersion:id,version,color_class',
'users:id,name',
'license:id,name,link',
])
->latest()
->limit(6)
->get();
}
private function fetchUpdatedMods(): Collection
{
return Cache::remember('homepage-updated-mods', now()->addMinutes(5), function () {
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured'])
->withTotalDownloads()
->with(['lastUpdatedVersion', 'lastUpdatedVersion.sptVersion', 'users:id,name'])
->orderByDesc(
ModVersion::select('updated_at')
->whereColumn('mod_id', 'mods.id')
->orderByDesc('updated_at')
->take(1)
)
->limit(6)
->get();
});
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured'])
->withTotalDownloads()
->with([
'latestVersion',
'latestVersion.latestSptVersion:id,version,color_class',
'users:id,name',
'license:id,name,link',
])
->orderByDesc(
ModVersion::select('updated_at')
->whereColumn('mod_id', 'mods.id')
->orderByDesc('updated_at')
->take(1)
)
->limit(6)
->get();
}
public function render(): View

View File

@ -16,20 +16,10 @@ class ModDependencyFactory extends Factory
{
return [
'mod_version_id' => ModVersion::factory(),
'dependency_mod_id' => Mod::factory(),
'version_constraint' => fake()->numerify($this->generateVersionConstraint()),
'dependent_mod_id' => Mod::factory(),
'constraint' => '*',
'created_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
'updated_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
];
}
/**
* This method generates a random version constraint from a predefined set of options.
*/
private function generateVersionConstraint(): string
{
$versionConstraints = ['*', '^1.#.#', '>=2.#.#', '~1.#.#', '>=1.2.# <2.#.#'];
return $versionConstraints[array_rand($versionConstraints)];
}
}

View File

@ -14,15 +14,15 @@ class ModVersionFactory extends Factory
public function definition(): array
{
$constraint = fake()->numerify($this->generateVersionConstraint());
return [
'mod_id' => Mod::factory(),
'version' => fake()->numerify('#.#.#'),
'description' => fake()->text(),
'link' => fake()->url(),
'spt_version_constraint' => $constraint,
'resolved_spt_version_id' => null,
// Unless a custom constraint is provided, this will also generate the required SPT versions.
'spt_version_constraint' => $this->faker->randomElement(['^1.0', '^2.0', '>=3.0', '<4.0']),
'virus_total_link' => fake()->url(),
'downloads' => fake()->randomNumber(),
'published_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
@ -32,28 +32,43 @@ class ModVersionFactory extends Factory
}
/**
* This method generates a random version constraint from a predefined set of options.
* Configure the model factory.
*/
private function generateVersionConstraint(): string
public function configure(): ModVersionFactory
{
$versionConstraints = ['*', '^1.#.#', '>=2.#.#', '~1.#.#'];
return $versionConstraints[array_rand($versionConstraints)];
return $this->afterCreating(function (ModVersion $modVersion) {
$this->ensureSptVersionsExist($modVersion); // Create SPT Versions
});
}
/**
* Indicate that the mod version should have a resolved SPT version.
* Ensure that the required SPT versions exist and are associated with the mod version.
*/
public function sptVersionResolved(): static
protected function ensureSptVersionsExist(ModVersion $modVersion): void
{
$constraint = fake()->numerify('#.#.#');
$constraint = $modVersion->spt_version_constraint;
return $this->state(fn (array $attributes) => [
'spt_version_constraint' => $constraint,
'resolved_spt_version_id' => SptVersion::factory()->create([
'version' => $constraint,
]),
]);
$requiredVersions = match ($constraint) {
'^1.0' => ['1.0.0', '1.1.0', '1.2.0'],
'^2.0' => ['2.0.0', '2.1.0'],
'>=3.0' => ['3.0.0', '3.1.0', '3.2.0', '4.0.0'],
'<4.0' => ['1.0.0', '2.0.0', '3.0.0'],
default => [],
};
// If the version is anything but the default, no SPT versions are created.
if (! $requiredVersions) {
return;
}
foreach ($requiredVersions as $version) {
SptVersion::firstOrCreate(['version' => $version], [
'color_class' => $this->faker->randomElement(['red', 'green', 'emerald', 'lime', 'yellow', 'grey']),
'link' => $this->faker->url,
]);
}
$modVersion->sptVersions()->sync(SptVersion::whereIn('version', $requiredVersions)->pluck('id')->toArray());
}
/**

View File

@ -35,7 +35,9 @@ return new class extends Migration
$table->timestamp('published_at')->nullable()->default(null);
$table->timestamps();
$table->index(['deleted_at', 'disabled'], 'mods_show_index');
$table->index(['slug']);
$table->index(['featured']);
$table->index(['deleted_at', 'disabled', 'published_at'], 'mods_filtering_index');
});
}

View File

@ -23,11 +23,6 @@ return new class extends Migration
$table->longText('description');
$table->string('link');
$table->string('spt_version_constraint');
$table->foreignId('resolved_spt_version_id')
->nullable()
->constrained('spt_versions')
->nullOnDelete()
->cascadeOnUpdate();
$table->string('virus_total_link');
$table->unsignedBigInteger('downloads');
$table->boolean('disabled')->default(false);
@ -35,7 +30,9 @@ return new class extends Migration
$table->timestamp('published_at')->nullable()->default(null);
$table->timestamps();
$table->index(['mod_id', 'deleted_at', 'disabled', 'version'], 'mod_versions_filtering_index');
$table->index(['version']);
$table->index(['mod_id', 'deleted_at', 'disabled', 'published_at'], 'mod_versions_filtering_index');
$table->index(['id', 'deleted_at'], 'mod_versions_id_deleted_at_index');
});
}

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mod_version_spt_version', function (Blueprint $table) {
$table->id();
$table->foreignId('mod_version_id')->constrained('mod_versions')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreignId('spt_version_id')->constrained('spt_versions')->cascadeOnDelete()->cascadeOnUpdate();
$table->timestamps();
$table->index(['mod_version_id', 'spt_version_id'], 'mod_version_spt_version_index');
$table->index(['spt_version_id', 'mod_version_id'], 'spt_version_mod_version_index');
});
}
public function down(): void
{
Schema::dropIfExists('mod_version_spt_version');
}
};

View File

@ -10,23 +10,12 @@ return new class extends Migration
{
Schema::create('mod_dependencies', function (Blueprint $table) {
$table->id();
$table->foreignId('mod_version_id')
->constrained('mod_versions')
->cascadeOnDelete()
->cascadeOnUpdate();
$table->foreignId('dependency_mod_id')
->constrained('mods')
->cascadeOnDelete()
->cascadeOnUpdate();
$table->string('version_constraint');
$table->foreignId('resolved_version_id')
->nullable()
->constrained('mod_versions')
->nullOnDelete()
->cascadeOnUpdate();
$table->foreignId('mod_version_id')->constrained('mod_versions')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreignId('dependent_mod_id')->constrained('mods')->cascadeOnDelete()->cascadeOnUpdate();
$table->string('constraint');
$table->timestamps();
$table->unique(['mod_version_id', 'dependency_mod_id', 'version_constraint'], 'mod_dependencies_unique');
$table->index(['mod_version_id', 'dependent_mod_id']);
});
}

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mod_resolved_dependencies', function (Blueprint $table) {
$table->id();
$table->foreignId('mod_version_id')->constrained('mod_versions')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreignId('dependency_id')->constrained('mod_dependencies')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreignId('resolved_mod_version_id')->constrained('mod_versions')->cascadeOnDelete()->cascadeOnUpdate();
$table->timestamps();
$table->index(['mod_version_id', 'dependency_id']);
});
}
public function down(): void
{
Schema::dropIfExists('mod_resolved_dependencies');
}
};

View File

@ -1,12 +1,3 @@
import "./registerViteAssets";
import "./registerAlpineLivewire";
import "./themeToggle";
document.addEventListener("livewire:init", () => {
Livewire.on("clear-filters", (event) => {
localStorage.removeItem("filter-query");
localStorage.removeItem("filter-order");
localStorage.removeItem("filter-sptVersion");
localStorage.removeItem("filter-featured");
});
});

View File

@ -18,8 +18,8 @@
<div class="pb-3">
<div class="flex justify-between items-center space-x-3">
<h3 class="block mt-1 text-lg leading-tight font-medium text-black dark:text-white group-hover:underline">{{ $mod->name }}</h3>
<span class="badge-version {{ $mod->{$versionScope}->sptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $mod->{$versionScope}->sptVersion->version }}
<span class="badge-version {{ $mod->{$versionScope}->latestSptVersion->first()->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $mod->{$versionScope}->latestSptVersion->first()->version_formatted }}
</span>
</div>
<p class="text-sm italic text-slate-600 dark:text-gray-200">

View File

@ -1,35 +1,4 @@
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"
x-data="{
query: @entangle('query'),
order: @entangle('order'),
sptVersion: @entangle('sptVersion'),
featured: @entangle('featured'),
init() {
this.loadFiltersFromLocalStorage();
$wire.$refresh();
$watch('query', value => this.saveFilterToLocalStorage('query', value));
$watch('order', value => this.saveFilterToLocalStorage('order', value));
$watch('sptVersion', value => this.saveFilterToLocalStorage('sptVersion', value));
$watch('featured', value => this.saveFilterToLocalStorage('featured', value));
},
saveFilterToLocalStorage(key, value) {
localStorage.setItem(`filter-${key}`, JSON.stringify(value));
},
loadFiltersFromLocalStorage() {
const query = localStorage.getItem('filter-query');
if (query) this.query = JSON.parse(query);
const order = localStorage.getItem('filter-order');
if (order) this.order = JSON.parse(order);
const sptVersion = localStorage.getItem('filter-sptVersion');
if (sptVersion) this.sptVersion = JSON.parse(sptVersion);
const featured = localStorage.getItem('filter-featured');
if (featured) this.featured = JSON.parse(featured);
},
}">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="px-4 py-8 sm:px-6 lg:px-8 bg-white dark:bg-gray-900 overflow-hidden shadow-xl dark:shadow-gray-900 rounded-none sm:rounded-lg">
<h1 class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-200">{{ __('Mods') }}</h1>
<p class="mt-4 text-base text-slate-500 dark:text-gray-300">{!! __('Explore an enhanced <abbr title="Single Player Tarkov">SPT</abbr> experience with the mods available below. Check out the featured mods for a tailored solo-survival game with maximum immersion.') !!}</p>
@ -61,7 +30,13 @@
<button @click="$wire.call('resetFilters')" type="button" class="pl-6 text-gray-500 dark:text-gray-300">{{ __('Reset Filters') }}</button>
<div wire:loading.flex>
<p class="pl-6 flex items-center font-medium text-gray-700 dark:text-gray-300">{{ __('Loading...') }}</p>
<p class="pl-6 flex items-center font-medium text-gray-700 dark:text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" class="w-4 h-4 fill-cyan-600 dark:fill-cyan-600 motion-safe:animate-spin">
<path d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity=".25" />
<path d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z" />
</svg>
<span class="pl-1.5">{{ __('Loading...') }}</span>
</p>
</div>
</div>
</div>
@ -86,7 +61,7 @@
<div class="space-y-6 pt-6 sm:space-y-4 sm:pt-4">
@foreach ($availableSptVersions as $index => $version)
@if ($index < $half)
<x-filter-checkbox id="sptVersion-{{ $index }}" name="sptVersion" value="{{ $version->version }}">{{ $version->version }}</x-filter-checkbox>
<x-filter-checkbox id="sptVersions-{{ $index }}" name="sptVersions" value="{{ $version->version }}">{{ $version->version }}</x-filter-checkbox>
@endif
@endforeach
</div>
@ -96,7 +71,7 @@
<div class="space-y-6 pt-6 sm:space-y-4 sm:pt-4">
@foreach ($availableSptVersions as $index => $version)
@if ($index >= $half)
<x-filter-checkbox id="sptVersion-{{ $index }}" name="sptVersion" value="{{ $version->version }}">{{ $version->version }}</x-filter-checkbox>
<x-filter-checkbox id="sptVersions-{{ $index }}" name="sptVersions" value="{{ $version->version }}">{{ $version->version }}</x-filter-checkbox>
@endif
@endforeach
</div>

View File

@ -28,7 +28,7 @@
<h2 class="pb-1 sm:pb-2 text-3xl font-bold text-gray-900 dark:text-white">
{{ $mod->name }}
<span class="font-light text-nowrap text-gray-700 dark:text-gray-400">
{{ $latestVersion->version }}
{{ $mod->latestVersion->version }}
</span>
</h2>
</div>
@ -40,8 +40,8 @@
</p>
<p title="{{ __('Exactly') }} {{ $mod->total_downloads }}">{{ Number::downloads($mod->total_downloads) }} {{ __('Downloads') }}</p>
<p class="mt-2">
<span class="badge-version {{ $latestVersion->sptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $latestVersion->sptVersion->version }} {{ __('Compatible') }}
<span class="badge-version {{ $mod->latestVersion->latestSptVersion->first()->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $mod->latestVersion->latestSptVersion->first()->version_formatted }} {{ __('Compatible') }}
</span>
</p>
</div>
@ -54,8 +54,8 @@
</div>
{{-- Mobile Download Button --}}
<a href="{{ $latestVersion->link }}" class="block lg:hidden">
<button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $latestVersion->version }})</button>
<a href="{{ $mod->latestVersion->link }}" class="block lg:hidden">
<button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $mod->latestVersion->version }})</button>
</a>
{{-- Tabs --}}
@ -108,8 +108,8 @@
<p class="text-gray-700 dark:text-gray-300" title="{{ __('Exactly') }} {{ $version->downloads }}">{{ Number::downloads($version->downloads) }} {{ __('Downloads') }}</p>
</div>
<div class="flex items-center justify-between">
<span class="badge-version {{ $version->sptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $version->sptVersion->version }}
<span class="badge-version {{ $version->latestSptVersion->first()->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $version->latestSptVersion->first()->version_formatted }}
</span>
<a href="{{ $version->virus_total_link }}">{{__('Virus Total Results')}}</a>
</div>
@ -117,20 +117,18 @@
<span>{{ __('Created') }} {{ $version->created_at->format("M d, h:m a") }}</span>
<span>{{ __('Updated') }} {{ $version->updated_at->format("M d, h:m a") }}</span>
</div>
@if ($version->dependencies->isNotEmpty() && $version->dependencies->contains(fn($dependency) => $dependency->resolvedVersion?->mod))
{{-- Display latest resolved dependencies --}}
@if ($version->latestResolvedDependencies->isNotEmpty())
<div class="text-gray-600 dark:text-gray-400">
{{ __('Dependencies:') }}
@foreach ($version->dependencies as $dependency)
@if ($dependency->resolvedVersion?->mod)
<a href="{{ $dependency->resolvedVersion->mod->detailUrl() }}">
{{ $dependency->resolvedVersion->mod->name }}&nbsp;({{ $dependency->resolvedVersion->version }})
</a>@if (!$loop->last), @endif
@endif
@foreach ($version->latestResolvedDependencies as $resolvedDependency)
<a href="{{ $resolvedDependency->mod->detailUrl() }}">{{ $resolvedDependency->mod->name }}&nbsp;({{ $resolvedDependency->version }})</a>@if (!$loop->last), @endif
@endforeach
</div>
@endif
</div>
<div class="p-3 user-markdown">
<div class="py-3 user-markdown">
{{-- The description below is safe to write directly because it has been run though HTMLPurifier during the import process. --}}
{!! Str::markdown($version->description) !!}
</div>
@ -149,8 +147,8 @@
<div class="col-span-1 flex flex-col gap-6">
{{-- Desktop Download Button --}}
<a href="{{ $latestVersion->link }}" class="hidden lg:block">
<button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $latestVersion->version }})</button>
<a href="{{ $mod->latestVersion->link }}" class="hidden lg:block">
<button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $mod->latestVersion->version }})</button>
</a>
{{-- Additional Mod Details --}}
@ -177,21 +175,21 @@
</p>
</li>
@endif
@if ($latestVersion->virus_total_link)
@if ($mod->latestVersion->virus_total_link)
<li class="px-4 py-4 sm:px-0">
<h3>{{ __('Latest Version VirusTotal Result') }}</h3>
<p class="truncate">
<a href="{{ $latestVersion->virus_total_link }}" title="{{ $latestVersion->virus_total_link }}" target="_blank">
{{ $latestVersion->virus_total_link }}
<a href="{{ $mod->latestVersion->virus_total_link }}" title="{{ $mod->latestVersion->virus_total_link }}" target="_blank">
{{ $mod->latestVersion->virus_total_link }}
</a>
</p>
</li>
@endif
@if ($latestVersion->dependencies->isNotEmpty() && $latestVersion->dependencies->contains(fn($dependency) => $dependency->resolvedVersion?->mod))
@if ($mod->latestVersion->dependencies->isNotEmpty() && $mod->latestVersion->dependencies->contains(fn($dependency) => $dependency->resolvedVersion?->mod))
<li class="px-4 py-4 sm:px-0">
<h3>{{ __('Latest Version Dependencies') }}</h3>
<p class="truncate">
@foreach ($latestVersion->dependencies as $dependency)
@foreach ($mod->latestVersion->dependencies as $dependency)
<a href="{{ $dependency->resolvedVersion->mod->detailUrl() }}">
{{ $dependency->resolvedVersion->mod->name }}&nbsp;({{ $dependency->resolvedVersion->version }})
</a><br />

View File

@ -0,0 +1,312 @@
<?php
use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModResolvedDependency;
use App\Models\ModVersion;
use App\Services\DependencyVersionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('resolves mod version dependencies on create', function () {
$modVersion = ModVersion::factory()->create();
$dependentMod = Mod::factory()->create();
$dependentVersion1 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.0.0']);
$dependentVersion2 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '2.0.0']);
// Create a dependency
ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([
'constraint' => '^1.0', // Should resolve to dependentVersion1
]);
// Check that the resolved dependency has been created
expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->first())
->not()->toBeNull()
->resolved_mod_version_id->toBe($dependentVersion1->id);
});
it('resolves multiple matching versions', function () {
$modVersion = ModVersion::factory()->create();
$dependentMod = Mod::factory()->create();
$dependentVersion1 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.0.0']);
$dependentVersion2 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.1.0']);
$dependentVersion3 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '2.0.0']);
// Create a dependency
ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([
'constraint' => '^1.0', // Should resolve to dependentVersion1 and dependentVersion2
]);
$resolvedDependencies = ModResolvedDependency::where('mod_version_id', $modVersion->id)->get();
expect($resolvedDependencies->count())->toBe(2)
->and($resolvedDependencies->pluck('resolved_mod_version_id'))
->toContain($dependentVersion1->id)
->toContain($dependentVersion2->id);
});
it('does not resolve dependencies when no versions match', function () {
$modVersion = ModVersion::factory()->create();
$dependentMod = Mod::factory()->create();
ModVersion::factory()->recycle($dependentMod)->create(['version' => '2.0.0']);
ModVersion::factory()->recycle($dependentMod)->create(['version' => '3.0.0']);
// Create a dependency
ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([
'constraint' => '^1.0', // No versions match
]);
// Check that no resolved dependencies were created
expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->exists())->toBeFalse();
});
it('updates resolved dependencies when constraint changes', function () {
$modVersion = ModVersion::factory()->create();
$dependentMod = Mod::factory()->create();
$dependentVersion1 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.0.0']);
$dependentVersion2 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '2.0.0']);
// Create a dependency with an initial constraint
$dependency = ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([
'constraint' => '^1.0', // Should resolve to dependentVersion1
]);
$resolvedDependency = ModResolvedDependency::where('mod_version_id', $modVersion->id)->first();
expect($resolvedDependency->resolved_mod_version_id)->toBe($dependentVersion1->id);
// Update the constraint
$dependency->update(['constraint' => '^2.0']); // Should now resolve to dependentVersion2
$resolvedDependency = ModResolvedDependency::where('mod_version_id', $modVersion->id)->first();
expect($resolvedDependency->resolved_mod_version_id)->toBe($dependentVersion2->id);
});
it('removes resolved dependencies when dependency is removed', function () {
$modVersion = ModVersion::factory()->create();
$dependentMod = Mod::factory()->create();
$dependentVersion1 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.0.0']);
// Create a dependency
$dependency = ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([
'constraint' => '^1.0',
]);
$resolvedDependency = ModResolvedDependency::where('mod_version_id', $modVersion->id)->first();
expect($resolvedDependency)->not()->toBeNull();
// Delete the dependency
$dependency->delete();
// Check that the resolved dependency is removed
expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->exists())->toBeFalse();
});
it('handles mod versions with no dependencies gracefully', function () {
$serviceSpy = $this->spy(DependencyVersionService::class);
$modVersion = ModVersion::factory()->create(['version' => '1.0.0']);
// Check that the service was called and that no resolved dependencies were created.
$serviceSpy->shouldHaveReceived('resolve');
expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->exists())->toBeFalse();
});
it('resolves the correct versions with a complex semver constraint', function () {
$modVersion = ModVersion::factory()->create(['version' => '1.0.0']);
$dependentMod = Mod::factory()->create();
$dependentVersion1 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.0.0']);
$dependentVersion2 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.2.0']);
$dependentVersion3 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.5.0']);
$dependentVersion4 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '2.0.0']);
$dependentVersion5 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '2.5.0']);
// Create a complex SemVer constraint
ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([
'constraint' => '>1.0 <2.0 || >=2.5.0 <3.0', // Should resolve to dependentVersion2, dependentVersion3, and dependentVersion5
]);
$resolvedDependencies = ModResolvedDependency::where('mod_version_id', $modVersion->id)->pluck('resolved_mod_version_id');
expect($resolvedDependencies)->toContain($dependentVersion2->id)
->toContain($dependentVersion3->id)
->toContain($dependentVersion5->id)
->not->toContain($dependentVersion1->id)
->not->toContain($dependentVersion4->id);
});
it('resolves overlapping version constraints from multiple dependencies correctly', function () {
$modVersion = ModVersion::factory()->create(['version' => '1.0.0']);
$dependentMod1 = Mod::factory()->create();
$dependentVersion1_1 = ModVersion::factory()->recycle($dependentMod1)->create(['version' => '1.0.0']);
$dependentVersion1_2 = ModVersion::factory()->recycle($dependentMod1)->create(['version' => '1.5.0']);
$dependentMod2 = Mod::factory()->create();
$dependentVersion2_1 = ModVersion::factory()->recycle($dependentMod2)->create(['version' => '1.0.0']);
$dependentVersion2_2 = ModVersion::factory()->recycle($dependentMod2)->create(['version' => '1.5.0']);
// Create two dependencies with overlapping constraints
ModDependency::factory()->recycle([$modVersion, $dependentMod1])->create([
'constraint' => '>=1.0 <2.0', // Matches both versions of dependentMod1
]);
ModDependency::factory()->recycle([$modVersion, $dependentMod2])->create([
'constraint' => '>=1.5.0 <2.0.0', // Matches only the second version of dependentMod2
]);
$resolvedDependencies = ModResolvedDependency::where('mod_version_id', $modVersion->id)->get();
expect($resolvedDependencies->pluck('resolved_mod_version_id'))
->toContain($dependentVersion1_1->id)
->toContain($dependentVersion1_2->id)
->toContain($dependentVersion2_2->id)
->not->toContain($dependentVersion2_1->id);
});
it('handles the case where a dependent mod has no versions available', function () {
$modVersion = ModVersion::factory()->create(['version' => '1.0.0']);
$dependentMod = Mod::factory()->create();
// Create a dependency where the dependent mod has no versions.
ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([
'constraint' => '>=1.0.0',
]);
// Verify that no versions were resolved.
expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->exists())->toBeFalse();
});
it('handles a large number of versions efficiently', function () {
$versionCount = 100;
$modVersion = ModVersion::factory()->create();
$dependentMod = Mod::factory()->create();
for ($i = 0; $i < $versionCount; $i++) {
ModVersion::factory()->recycle($dependentMod)->create(['version' => "1.0.$i"]);
}
// Create a dependency with a broad constraint
ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([
'constraint' => '>=1.0.0',
]);
// Verify that all versions were resolved
expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->count())->toBe($versionCount);
});
it('calls DependencyVersionService when a Mod is updated', function () {
$mod = Mod::factory()->create();
ModVersion::factory(2)->recycle($mod)->create();
$serviceSpy = $this->spy(DependencyVersionService::class);
$mod->update(['name' => 'New Mod Name']);
$mod->refresh();
expect($mod->versions)->toHaveCount(2);
foreach ($mod->versions as $modVersion) {
$serviceSpy->shouldReceive('resolve')->with($modVersion);
}
});
it('calls DependencyVersionService when a Mod is deleted', function () {
$mod = Mod::factory()->create();
ModVersion::factory(2)->recycle($mod)->create();
$serviceSpy = $this->spy(DependencyVersionService::class);
$mod->delete();
$mod->refresh();
expect($mod->versions)->toHaveCount(2);
foreach ($mod->versions as $modVersion) {
$serviceSpy->shouldReceive('resolve')->with($modVersion);
}
});
it('calls DependencyVersionService when a ModVersion is updated', function () {
$modVersion = ModVersion::factory()->create();
$serviceSpy = $this->spy(DependencyVersionService::class);
$modVersion->update(['version' => '1.1.0']);
$serviceSpy->shouldHaveReceived('resolve');
});
it('calls DependencyVersionService when a ModVersion is deleted', function () {
$modVersion = ModVersion::factory()->create();
$serviceSpy = $this->spy(DependencyVersionService::class);
$modVersion->delete();
$serviceSpy->shouldHaveReceived('resolve');
});
it('calls DependencyVersionService when a ModDependency is updated', function () {
$modVersion = ModVersion::factory()->create(['version' => '1.0.0']);
$dependentMod = Mod::factory()->create();
$modDependency = ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([
'constraint' => '^1.0',
]);
$serviceSpy = $this->spy(DependencyVersionService::class);
$modDependency->update(['constraint' => '^2.0']);
$serviceSpy->shouldHaveReceived('resolve');
});
it('calls DependencyVersionService when a ModDependency is deleted', function () {
$modVersion = ModVersion::factory()->create(['version' => '1.0.0']);
$dependentMod = Mod::factory()->create();
$modDependency = ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([
'constraint' => '^1.0',
]);
$serviceSpy = $this->spy(DependencyVersionService::class);
$modDependency->delete();
$serviceSpy->shouldHaveReceived('resolve');
});
it('displays the latest resolved dependencies on the mod detail page', function () {
$dependentMod1 = Mod::factory()->create();
$dependentMod1Version1 = ModVersion::factory()->recycle($dependentMod1)->create(['version' => '1.0.0']);
$dependentMod1Version2 = ModVersion::factory()->recycle($dependentMod1)->create(['version' => '2.0.0']);
$dependentMod2 = Mod::factory()->create();
$dependentMod2Version1 = ModVersion::factory()->recycle($dependentMod2)->create(['version' => '1.0.0']);
$dependentMod2Version2 = ModVersion::factory()->recycle($dependentMod2)->create(['version' => '1.1.0']);
$dependentMod2Version3 = ModVersion::factory()->recycle($dependentMod2)->create(['version' => '1.2.0']);
$dependentMod2Version4 = ModVersion::factory()->recycle($dependentMod2)->create(['version' => '1.2.1']);
$mod = Mod::factory()->create();
$mainModVersion = ModVersion::factory()->recycle($mod)->create();
ModDependency::factory()->recycle([$mainModVersion, $dependentMod1])->create(['constraint' => '>=1.0.0']);
ModDependency::factory()->recycle([$mainModVersion, $dependentMod2])->create(['constraint' => '>=1.0.0']);
$mainModVersion->load('latestResolvedDependencies');
expect($mainModVersion->latestResolvedDependencies)->toHaveCount(2)
->and($mainModVersion->latestResolvedDependencies->pluck('version'))
->toContain($dependentMod1Version2->version) // Latest version of dependentMod1
->toContain($dependentMod2Version4->version); // Latest version of dependentMod2
$response = $this->get(route('mod.show', ['mod' => $mod->id, 'slug' => $mod->slug]));
$response->assertSeeInOrder(explode(' ', __('Dependencies: ')."$dependentMod1->name ($dependentMod1Version2->version)"));
$response->assertSeeInOrder(explode(' ', __('Dependencies: ')."$dependentMod2->name ($dependentMod2Version4->version)"));
});

View File

@ -0,0 +1,161 @@
<?php
use App\Http\Filters\ModFilter;
use App\Models\Mod;
use App\Models\ModVersion;
use App\Models\SptVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('filters mods by a single SPT version', function () {
$sptVersion1 = SptVersion::factory()->create(['version' => '1.0.0']);
$sptVersion2 = SptVersion::factory()->create(['version' => '2.0.0']);
$mod1 = Mod::factory()->create();
$modVersion1 = ModVersion::factory()->recycle($mod1)->create([
'spt_version_constraint' => '1.0.0', // Constraint matching sptVersion1
]);
$mod2 = Mod::factory()->create();
$modVersion2 = ModVersion::factory()->recycle($mod2)->create([
'spt_version_constraint' => '2.0.0', // Constraint matching sptVersion2
]);
// Confirm associations created by observers and services
expect($modVersion1->sptVersions->pluck('version')->toArray())->toContain($sptVersion1->version)
->and($modVersion2->sptVersions->pluck('version')->toArray())->toContain($sptVersion2->version);
// Apply the filter
$filters = ['sptVersions' => [$sptVersion1->version]];
$filteredMods = (new ModFilter($filters))->apply()->get();
// Assert that only the correct mod is returned
expect($filteredMods)->toHaveCount(1)
->and($filteredMods->first()->id)->toBe($mod1->id);
});
it('filters mods by multiple SPT versions', function () {
// Create the SPT versions
$sptVersion1 = SptVersion::factory()->create(['version' => '1.0.0']);
$sptVersion2 = SptVersion::factory()->create(['version' => '2.0.0']);
$sptVersion3 = SptVersion::factory()->create(['version' => '3.0.0']);
// Create the mods and their versions with appropriate constraints
$mod1 = Mod::factory()->create();
$modVersion1 = ModVersion::factory()->recycle($mod1)->create([
'spt_version_constraint' => '1.0.0', // Constraint matching sptVersion1
]);
$mod2 = Mod::factory()->create();
$modVersion2 = ModVersion::factory()->recycle($mod2)->create([
'spt_version_constraint' => '2.0.0', // Constraint matching sptVersion2
]);
$mod3 = Mod::factory()->create();
$modVersion3 = ModVersion::factory()->recycle($mod3)->create([
'spt_version_constraint' => '3.0.0', // Constraint matching sptVersion3
]);
// Confirm associations created by observers and services
expect($modVersion1->sptVersions->pluck('version')->toArray())->toContain($sptVersion1->version)
->and($modVersion2->sptVersions->pluck('version')->toArray())->toContain($sptVersion2->version)
->and($modVersion3->sptVersions->pluck('version')->toArray())->toContain($sptVersion3->version);
// Apply the filter with multiple SPT versions
$filters = ['sptVersions' => [$sptVersion1->version, $sptVersion3->version]];
$filteredMods = (new ModFilter($filters))->apply()->get();
// Assert that the correct mods are returned
expect($filteredMods)->toHaveCount(2)
->and($filteredMods->pluck('id')->toArray())->toContain($mod1->id, $mod3->id);
});
it('returns no mods when no SPT versions match', function () {
// Create the SPT versions
$sptVersion1 = SptVersion::factory()->create(['version' => '1.0.0']);
$sptVersion2 = SptVersion::factory()->create(['version' => '2.0.0']);
// Create the mods and their versions with appropriate constraints
$mod1 = Mod::factory()->create();
$modVersion1 = ModVersion::factory()->recycle($mod1)->create([
'spt_version_constraint' => '1.0.0', // Constraint matching sptVersion1
]);
$mod2 = Mod::factory()->create();
$modVersion2 = ModVersion::factory()->recycle($mod2)->create([
'spt_version_constraint' => '2.0.0', // Constraint matching sptVersion2
]);
// Confirm associations created by observers and services
expect($modVersion1->sptVersions->pluck('version')->toArray())->toContain($sptVersion1->version)
->and($modVersion2->sptVersions->pluck('version')->toArray())->toContain($sptVersion2->version);
// Apply the filter with a non-matching SPT version
$filters = ['sptVersions' => ['3.0.0']]; // Version '3.0.0' does not exist in associations
$filteredMods = (new ModFilter($filters))->apply()->get();
// Assert that no mods are returned
expect($filteredMods)->toBeEmpty();
});
it('filters mods correctly with combined filters', function () {
// Create the SPT versions
$sptVersion1 = SptVersion::factory()->create(['version' => '1.0.0']);
$sptVersion2 = SptVersion::factory()->create(['version' => '2.0.0']);
// Create the mods and their versions with appropriate names and featured status
$mod1 = Mod::factory()->create(['name' => 'Awesome Mod', 'featured' => true]);
$modVersion1 = ModVersion::factory()->recycle($mod1)->create([
'spt_version_constraint' => '1.0.0', // Constraint matching sptVersion1
]);
$mod2 = Mod::factory()->create(['name' => 'Cool Mod', 'featured' => false]);
$modVersion2 = ModVersion::factory()->recycle($mod2)->create([
'spt_version_constraint' => '2.0.0', // Constraint matching sptVersion2
]);
// Confirm associations created by observers and services
expect($modVersion1->sptVersions->pluck('version')->toArray())->toContain($sptVersion1->version)
->and($modVersion2->sptVersions->pluck('version')->toArray())->toContain($sptVersion2->version);
// Apply combined filters
$filters = [
'query' => 'Awesome',
'featured' => 'only',
'sptVersions' => [$sptVersion1->version],
];
$filteredMods = (new ModFilter($filters))->apply()->get();
// Assert that only the correct mod is returned
expect($filteredMods)->toHaveCount(1)
->and($filteredMods->first()->id)->toBe($mod1->id);
});
it('handles an empty SPT versions array correctly', function () {
// Create the SPT versions
$sptVersion1 = SptVersion::factory()->create(['version' => '1.0.0']);
$sptVersion2 = SptVersion::factory()->create(['version' => '2.0.0']);
// Create the mods and their versions with appropriate constraints
$mod1 = Mod::factory()->create();
$modVersion1 = ModVersion::factory()->recycle($mod1)->create([
'spt_version_constraint' => '1.0.0', // Constraint matching sptVersion1
]);
$mod2 = Mod::factory()->create();
$modVersion2 = ModVersion::factory()->recycle($mod2)->create([
'spt_version_constraint' => '2.0.0', // Constraint matching sptVersion2
]);
// Confirm associations created by observers and services
expect($modVersion1->sptVersions->pluck('version')->toArray())->toContain($sptVersion1->version)
->and($modVersion2->sptVersions->pluck('version')->toArray())->toContain($sptVersion2->version);
// Apply the filter with an empty SPT versions array
$filters = ['sptVersions' => []];
$filteredMods = (new ModFilter($filters))->apply()->get();
// Assert that the behavior is as expected (return all mods, or none, depending on intended behavior)
expect($filteredMods)->toHaveCount(2); // Modify this assertion to reflect your desired behavior
});

View File

@ -0,0 +1,52 @@
<?php
use App\Models\Mod;
use App\Models\ModVersion;
use App\Models\SptVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('displays homepage mod cards with the latest supported spt version number', function () {
$sptVersion1 = SptVersion::factory()->create(['version' => '1.0.0']);
$sptVersion2 = SptVersion::factory()->create(['version' => '2.0.0']);
$sptVersion3 = SptVersion::factory()->create(['version' => '3.0.0']);
$mod1 = Mod::factory()->create();
ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion1->version]);
ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion1->version]);
ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion2->version]);
ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion2->version]);
ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion3->version]);
ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion3->version]);
$response = $this->get(route('home'));
$response->assertSeeInOrder(explode(' ', "$mod1->name $sptVersion3->version_formatted"));
});
it('displays the latest version on the mod detail page', function () {
$versions = [
'1.0.0',
'1.1.0',
'1.2.0',
'2.0.0',
'2.1.0',
];
$latestVersion = max($versions);
$mod = Mod::factory()->create();
foreach ($versions as $version) {
ModVersion::factory()->recycle($mod)->create(['version' => $version]);
}
$response = $this->get($mod->detailUrl());
expect($latestVersion)->toBe('2.1.0');
// Assert the latest version is next to the mod's name
$response->assertSeeInOrder(explode(' ', "$mod->name $latestVersion"));
// Assert the latest version is in the latest download button
$response->assertSeeText(__('Download Latest Version')." ($latestVersion)");
});

View File

@ -7,7 +7,7 @@ use Illuminate\Support\Carbon;
uses(RefreshDatabase::class);
it('resolves spt version when mod version is created', function () {
it('resolves spt versions when mod version is created', function () {
SptVersion::factory()->create(['version' => '1.0.0']);
SptVersion::factory()->create(['version' => '1.1.0']);
SptVersion::factory()->create(['version' => '1.1.1']);
@ -17,11 +17,13 @@ it('resolves spt version when mod version is created', function () {
$modVersion->refresh();
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
expect($modVersion->sptVersion->version)->toBe('1.1.1');
$sptVersions = $modVersion->sptVersions;
expect($sptVersions)->toHaveCount(2)
->and($sptVersions->pluck('version'))->toContain('1.1.0', '1.1.1');
});
it('resolves spt version when constraint is updated', function () {
it('resolves spt versions when constraint is updated', function () {
SptVersion::factory()->create(['version' => '1.0.0']);
SptVersion::factory()->create(['version' => '1.1.0']);
SptVersion::factory()->create(['version' => '1.1.1']);
@ -29,19 +31,25 @@ it('resolves spt version when constraint is updated', function () {
$modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
expect($modVersion->sptVersion->version)->toBe('1.1.1');
$modVersion->refresh();
$sptVersions = $modVersion->sptVersions;
expect($sptVersions)->toHaveCount(2)
->and($sptVersions->pluck('version'))->toContain('1.1.0', '1.1.1');
$modVersion->spt_version_constraint = '~1.2.0';
$modVersion->save();
$modVersion->refresh();
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
expect($modVersion->sptVersion->version)->toBe('1.2.0');
$sptVersions = $modVersion->sptVersions;
expect($sptVersions)->toHaveCount(1)
->and($sptVersions->pluck('version'))->toContain('1.2.0');
});
it('resolves spt version when spt version is created', function () {
it('resolves spt versions when spt version is created', function () {
SptVersion::factory()->create(['version' => '1.0.0']);
SptVersion::factory()->create(['version' => '1.1.0']);
SptVersion::factory()->create(['version' => '1.1.1']);
@ -49,17 +57,24 @@ it('resolves spt version when spt version is created', function () {
$modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
expect($modVersion->sptVersion->version)->toBe('1.1.1');
$modVersion->refresh();
$sptVersions = $modVersion->sptVersions;
expect($sptVersions)->toHaveCount(2)
->and($sptVersions->pluck('version'))->toContain('1.1.0', '1.1.1');
SptVersion::factory()->create(['version' => '1.1.2']);
$modVersion->refresh();
expect($modVersion->sptVersion->version)->toBe('1.1.2');
$sptVersions = $modVersion->sptVersions;
expect($sptVersions)->toHaveCount(3)
->and($sptVersions->pluck('version'))->toContain('1.1.0', '1.1.1', '1.1.2');
});
it('resolves spt version when spt version is deleted', function () {
it('resolves spt versions when spt version is deleted', function () {
SptVersion::factory()->create(['version' => '1.0.0']);
SptVersion::factory()->create(['version' => '1.1.0']);
SptVersion::factory()->create(['version' => '1.1.1']);
@ -67,13 +82,19 @@ it('resolves spt version when spt version is deleted', function () {
$modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
expect($modVersion->sptVersion->version)->toBe('1.1.2');
$modVersion->refresh();
$sptVersions = $modVersion->sptVersions;
expect($sptVersions)->toHaveCount(3)
->and($sptVersions->pluck('version'))->toContain('1.1.0', '1.1.1', '1.1.2');
$sptVersion->delete();
$modVersion->refresh();
expect($modVersion->sptVersion->version)->toBe('1.1.1');
$modVersion->refresh();
$sptVersions = $modVersion->sptVersions;
expect($sptVersions)->toHaveCount(2)
->and($sptVersions->pluck('version'))->toContain('1.1.0', '1.1.1');
});
it('includes only published mod versions', function () {

View File

@ -1,266 +0,0 @@
<?php
use App\Exceptions\CircularDependencyException;
use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('resolves mod version dependency when mod version is created', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->recycle($modB)->create(['version' => '1.0.0']);
ModVersion::factory()->recycle($modB)->create(['version' => '1.1.0']);
ModVersion::factory()->recycle($modB)->create(['version' => '1.1.1']);
ModVersion::factory()->recycle($modB)->create(['version' => '2.0.0']);
// Create versions for Mod A that depends on Mod B
$modAv1 = ModVersion::factory()->recycle($modA)->create(['version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^1.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
});
it('resolves mod version dependency when mod version is updated', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
$modBv3 = ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
// Create versions for Mod A that depends on Mod B
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^1.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
// Update the mod B version
$modBv3->update(['version' => '1.1.2']);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.2');
});
it('resolves mod version dependency when mod version is deleted', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
$modBv3 = ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
// Create versions for Mod A that depends on Mod B
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^1.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
// Delete the mod B version
$modBv3->delete();
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.0');
});
it('resolves mod version dependency after semantic version constraint is updated', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.1']);
// Create versions for Mod A that depends on Mod B
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^1.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
// Update the dependency version constraint
$modDependency->update(['version_constraint' => '^2.0.0']);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('2.0.1');
});
it('resolves mod version dependency with exact semantic version constraint', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
// Create versions for Mod A that depends on Mod B
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '1.1.0',
]);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.0');
});
it('resolves mod version dependency with complex semantic version constraint', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.2.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.2.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
// Create versions for Mod A that depends on Mod B
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '>=1.0.0 <2.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.2.1');
$modDependency->update(['version_constraint' => '1.0.0 || >=1.1.0 <1.2.0']);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
});
it('resolves previously unresolved mod version dependency after semantic version constraint is updated', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.1']);
// Create version for Mod A that depends on Mod B, but no version satisfies the constraint.
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^3.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolved_version_id)->toBeNull();
// Update the dependency version constraint
$modDependency->update(['version_constraint' => '^2.0.0']);
$modDependency->refresh();
expect($modDependency->resolved_version_id)->not->toBeNull();
expect($modDependency->resolvedVersion->version)->toBe('2.0.1');
});
it('resolves null when no mod versions are available', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create version for Mod A that has no resolvable dependency
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^1.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolved_version_id)->toBeNull();
});
it('resolves null when no mod versions match against semantic version constraint', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
// Create version for Mod A that has no resolvable dependency
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '~1.2.0',
]);
$modDependency->refresh();
expect($modDependency->resolved_version_id)->toBeNull();
});
it('resolves multiple dependencies', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
$modC = Mod::factory()->create(['name' => 'Mod C']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '2.0.0']);
// Creating a version for Mod A that depends on Mod B and Mod C
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependencyB = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^1.0.0',
]);
$modDependencyC = ModDependency::factory()->recycle([$modAv1, $modC])->create([
'version_constraint' => '^1.0.0',
]);
$modDependencyB->refresh();
expect($modDependencyB->resolvedVersion->version)->toBe('1.1.1');
$modDependencyC->refresh();
expect($modDependencyC->resolvedVersion->version)->toBe('1.1.1');
});
it('throws exception when there is a circular version dependency', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modAv1 = ModVersion::factory()->recycle($modA)->create(['version' => '1.0.0']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
$modBv1 = ModVersion::factory()->recycle($modB)->create(['version' => '1.0.0']);
$modDependencyAtoB = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '1.0.0',
]);
// Create circular dependencies
$modDependencyBtoA = ModDependency::factory()->recycle([$modBv1, $modA])->create([
'version_constraint' => '1.0.0',
]);
})->throws(CircularDependencyException::class);

View File

@ -1,54 +0,0 @@
<?php
use App\Models\Mod;
use App\Models\ModVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('can retrieve all unresolved versions', function () {
// Create a mod instance
$mod = Mod::factory()->create();
ModVersion::factory(5)->recycle($mod)->create();
ModVersion::all()->each(function (ModVersion $modVersion) {
$modVersion->resolved_spt_version_id = null;
$modVersion->saveQuietly();
});
$unresolvedMix = $mod->versions(resolvedOnly: false);
$unresolvedMix->each(function (ModVersion $modVersion) {
expect($modVersion)->toBeInstanceOf(ModVersion::class)
->and($modVersion->resolved_spt_version_id)->toBeNull();
});
expect($unresolvedMix->count())->toBe(5)
->and($mod->versions->count())->toBe(0);
});
it('shows the latest version on the mod detail page', function () {
$versions = [
'1.0.0',
'1.1.0',
'1.2.0',
'2.0.0',
'2.1.0',
];
$latestVersion = max($versions);
$mod = Mod::factory()->create();
foreach ($versions as $version) {
ModVersion::factory()->sptVersionResolved()->recycle($mod)->create(['version' => $version]);
}
$response = $this->get($mod->detailUrl());
expect($latestVersion)->toBe('2.1.0');
// Assert the latest version is next to the mod's name
$response->assertSeeInOrder(explode(' ', "$mod->name $latestVersion"));
// Assert the latest version is in the latest download button
$response->assertSeeText(__('Download Latest Version')." ($latestVersion)");
});

View File

@ -1,9 +0,0 @@
<?php
use function Pest\Stressless\stress;
it('homepage has a fast response time', function () {
$result = stress('/');
expect($result->requests()->duration()->med())->toBeLessThan(100);
});