diff --git a/app/Console/Commands/BackfillPluginMobileVersions.php b/app/Console/Commands/BackfillPluginMobileVersions.php new file mode 100644 index 000000000..2e97fd5c0 --- /dev/null +++ b/app/Console/Commands/BackfillPluginMobileVersions.php @@ -0,0 +1,71 @@ +option('dry-run'); + + /** @var array $changed */ + $changed = []; + /** @var array $unmatched */ + $unmatched = []; + + foreach (Plugin::query()->orderBy('name')->get() as $plugin) { + $constraint = data_get($plugin->composer_data, 'require.nativephp/mobile'); + $versions = Plugin::mobileVersionsFromComposer($plugin->composer_data); + + if (is_string($constraint) && $versions === null) { + $unmatched[] = [$plugin->name, $constraint]; + } + + if (($versions ?? []) === $plugin->supportedMobileVersions()) { + continue; + } + + $changed[] = [$plugin->name, is_string($constraint) ? $constraint : '—', $versions ? implode(', ', $versions) : '—']; + + if (! $dryRun) { + $plugin->update(['mobile_versions' => $versions]); + } + } + + if ($changed !== []) { + $this->table(['Plugin', 'Requires', 'Versions'], $changed); + } + + if ($unmatched !== []) { + $majorVersions = collect(config('plugins.mobile_major_versions', []))->map(fn (int $major): string => "{$major}.x"); + + $this->newLine(); + $this->warn("These require nativephp/mobile but don't allow any {$majorVersions->join(', ', ' or ')} release, so they have no versions:"); + $this->table(['Plugin', 'Requires'], $unmatched); + } + + $this->newLine(); + $this->info(sprintf('%d %s %s.', count($changed), Str::plural('plugin', count($changed)), $dryRun ? 'would change' : 'updated')); + + if ($dryRun) { + $this->warn('Dry run, so nothing was saved. Run it again without --dry-run to save these.'); + } + + return self::SUCCESS; + } +} diff --git a/app/Livewire/PluginDirectory.php b/app/Livewire/PluginDirectory.php index 0c2699359..102d3331b 100644 --- a/app/Livewire/PluginDirectory.php +++ b/app/Livewire/PluginDirectory.php @@ -27,11 +27,6 @@ class PluginDirectory extends Component */ public const string CATEGORY_UNCATEGORIZED = 'uncategorized'; - /** - * Filter value representing plugins with no `mobile_min_version` recorded. - */ - public const string MOBILE_VERSION_UNSPECIFIED = 'unspecified'; - #[Url] public string $search = ''; @@ -52,7 +47,8 @@ class PluginDirectory extends Component /** * Drop any `type`/`category` value that isn't a real enum case (a stale - * bookmark, a hand-edited URL) instead of crashing later on `::from()`. + * bookmark, a hand-edited URL) instead of crashing later on `::from()`, + * and any `mobileVersion` that isn't one of the filter's options. */ public function mount(): void { @@ -65,6 +61,10 @@ public function mount(): void && PluginCategory::tryFrom($this->category) === null) { $this->category = ''; } + + if ($this->mobileVersion !== '' && ! in_array($this->mobileVersion, $this->mobileVersionOptions(), true)) { + $this->mobileVersion = ''; + } } public function updatedSearch(): void @@ -177,16 +177,7 @@ public function render(): View $query->whereJsonContains('categories', $this->category); }) ->when($this->mobileVersion !== '', function (Builder $query): void { - if ($this->mobileVersion === self::MOBILE_VERSION_UNSPECIFIED) { - $query->whereNull('mobile_min_version'); - - return; - } - - $query->where(function (Builder $q): void { - $q->where('mobile_min_version', 'like', "{$this->mobileVersion}.%") - ->orWhere('mobile_min_version', $this->mobileVersion); - }); + $query->supportsMobileVersion((int) $this->mobileVersion); }) ->orderByDesc('featured') ->latest() @@ -211,9 +202,8 @@ public function render(): View 'authorUser' => $authorUser, 'typeOptions' => PluginType::cases(), 'categoryOptions' => PluginCategory::cases(), - 'mobileVersionOptions' => config('plugins.mobile_major_versions', []), + 'mobileVersionOptions' => $this->mobileVersionOptions(), 'categoryUncategorizedValue' => self::CATEGORY_UNCATEGORIZED, - 'mobileVersionUnspecifiedValue' => self::MOBILE_VERSION_UNSPECIFIED, 'typeLabel' => $this->type !== '' ? PluginType::from($this->type)->label() : null, 'categoryLabel' => $this->categoryLabel(), 'mobileVersionLabel' => $this->mobileVersionLabel(), @@ -231,10 +221,16 @@ protected function categoryLabel(): ?string protected function mobileVersionLabel(): ?string { - return match (true) { - $this->mobileVersion === '' => null, - $this->mobileVersion === self::MOBILE_VERSION_UNSPECIFIED => 'Version unspecified', - default => "v{$this->mobileVersion}.x and up", - }; + return $this->mobileVersion === '' ? null : "NativePHP {$this->mobileVersion}.x"; + } + + /** + * The NativePHP Mobile major versions plugins can be filtered by, newest first. + * + * @return array + */ + protected function mobileVersionOptions(): array + { + return array_map(strval(...), config('plugins.mobile_major_versions', [])); } } diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index f64a63bfd..bbfe9fd4a 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -18,6 +18,7 @@ use App\Notifications\PluginRejected; use App\Services\OgImageService; use App\Services\PluginSyncService; +use App\Support\ComposerConstraint; use App\Support\DemoVideo; use App\Support\PluginReadme; use BladeUI\Icons\Exceptions\SvgNotFound; @@ -348,6 +349,40 @@ public function worksInJump(): bool return $this->works_in_jump ?? false; } + /** + * The lowest NativePHP Mobile release the plugin works with in each major version + * it supports, keyed by major version, oldest first: [3 => '3.2.1', 4 => '4.0']. + * + * @return array + */ + public function supportedMobileVersions(): array + { + $versions = $this->mobile_versions ?? []; + + ksort($versions); + + return $versions; + } + + /** + * Work out from a composer.json which NativePHP Mobile versions a plugin supports, + * shaped like supportedMobileVersions(). Null if it doesn't require nativephp/mobile + * or allows none of the major versions in config('plugins.mobile_major_versions'). + * + * @param array|null $composerData + * @return array|null + */ + public static function mobileVersionsFromComposer(?array $composerData): ?array + { + $constraint = $composerData['require']['nativephp/mobile'] ?? null; + + if (! is_string($constraint)) { + return null; + } + + return ComposerConstraint::lowestVersionsByMajor($constraint, config('plugins.mobile_major_versions', [])) ?: null; + } + public function isSatisSynced(): bool { return $this->satis_synced_at !== null; @@ -497,6 +532,18 @@ protected function uncategorized(Builder $query): Builder ->orWhereJsonLength('categories', 0)); } + /** + * Plugins whose composer.json allows a release of the given NativePHP Mobile major version. + * + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function supportsMobileVersion(Builder $query, int $majorVersion): Builder + { + return $query->whereJsonContainsKey("mobile_versions->{$majorVersion}"); + } + /** * The categories Jev suggested for this plugin, likeliest first. * @@ -1028,6 +1075,7 @@ protected function casts(): array 'is_active' => 'boolean', 'is_official' => 'boolean', 'works_in_jump' => 'boolean', + 'mobile_versions' => 'array', 'composer_data' => 'array', 'nativephp_data' => 'array', 'last_synced_at' => 'datetime', diff --git a/app/Services/PluginSyncService.php b/app/Services/PluginSyncService.php index ccb836aa2..a8955f666 100644 --- a/app/Services/PluginSyncService.php +++ b/app/Services/PluginSyncService.php @@ -92,6 +92,7 @@ public function sync(Plugin $plugin): bool if ($composerData) { $updateData['mobile_min_version'] = $composerData['require']['nativephp/mobile'] ?? null; + $updateData['mobile_versions'] = Plugin::mobileVersionsFromComposer($composerData); } if ($readme) { diff --git a/app/Support/ComposerConstraint.php b/app/Support/ComposerConstraint.php new file mode 100644 index 000000000..a025c7af3 --- /dev/null +++ b/app/Support/ComposerConstraint.php @@ -0,0 +1,208 @@ + '3.2.1', 4 => '4.0']. + * + * Alternatives that don't name a version, like "dev-main", are skipped. + * + * @param array $majorVersions + * @return array + */ + public static function lowestVersionsByMajor(string $constraint, array $majorVersions): array + { + $constraint = trim(preg_replace('/@(?:stable|rc|beta|alpha|dev)\b/i', '', $constraint)); + + $ranges = array_filter(array_map(self::range(...), preg_split('/\s*\|\|?\s*/', $constraint))); + + sort($majorVersions); + + $lowest = []; + + foreach ($majorVersions as $major) { + foreach ($ranges as [$from, $limit, $includesLimit]) { + $candidate = max($from, [$major, 0, 0]); + + if ($candidate[0] === $major && self::isBelow($candidate, $limit, $includesLimit)) { + $lowest[$major] = min($lowest[$major] ?? $candidate, $candidate); + } + } + } + + return array_map(self::format(...), $lowest); + } + + /** + * The versions one "||" alternative allows, as [lowest version, upper limit, whether + * the limit itself is allowed]. A null limit means there isn't one. Returns null + * if the alternative can't be read or allows no version at all. + * + * @return array{0: array{int, int, int}, 1: array{int, int, int}|null, 2: bool}|null + */ + private static function range(string $alternative): ?array + { + $version = self::VERSION; + + $range = preg_match("/^{$version}\s+-\s+{$version}$/i", $alternative, $matches, PREG_UNMATCHED_AS_NULL) + ? self::hyphenRange(self::numbers(array_slice($matches, 1, 3)), self::numbers(array_slice($matches, 4, 3))) + : self::intersection(preg_split('/[\s,]+/', preg_replace('/(?<=[<>=!~^])\s+/', '', $alternative))); + + if ($range === null || ! self::isBelow($range[0], $range[1], $range[2])) { + return null; + } + + return $range; + } + + /** + * The versions all of the terms allow, like ">=3.1 <4.2". + * + * @param array $terms + * @return array{0: array{int, int, int}, 1: array{int, int, int}|null, 2: bool}|null + */ + private static function intersection(array $terms): ?array + { + $from = [0, 0, 0]; + $limit = null; + $includesLimit = false; + + foreach ($terms as $term) { + $range = self::termRange($term); + + if ($range === null) { + return null; + } + + $from = max($from, $range[0]); + + if ($range[1] !== null && ($limit === null || $range[1] < $limit || ($range[1] === $limit && ! $range[2]))) { + [$limit, $includesLimit] = [$range[1], $range[2]]; + } + } + + return [$from, $limit, $includesLimit]; + } + + /** + * The versions a single term like "^3.0", "~3.1", ">=3.1", "4.1.0" or "4.*" allows. + * + * @return array{0: array{int, int, int}, 1: array{int, int, int}|null, 2: bool}|null + */ + private static function termRange(string $term): ?array + { + if (preg_match('/^[x*]$/i', $term)) { + return [[0, 0, 0], null, false]; + } + + if (preg_match('/^v?(\d+)(?:\.(\d+))?\.[x*](?:-dev)?$/i', $term, $matches, PREG_UNMATCHED_AS_NULL)) { + [$major, $minor] = self::numbers(array_slice($matches, 1, 2)); + + return $minor === null + ? [[$major, 0, 0], [$major + 1, 0, 0], false] + : [[$major, $minor, 0], [$major, $minor + 1, 0], false]; + } + + if (! preg_match('/^(\^|~>?|<>|!=|>=?|<=?|==?)?'.self::VERSION.'$/i', $term, $matches, PREG_UNMATCHED_AS_NULL)) { + return null; + } + + [$major, $minor, $patch] = self::numbers(array_slice($matches, 2, 3)); + $version = [$major, $minor ?? 0, $patch ?? 0]; + + return match ($matches[1]) { + '^' => [$version, self::caretLimit($major, $minor, $patch), false], + '~', '~>' => [$version, $patch === null ? [$major + 1, 0, 0] : [$major, $minor + 1, 0], false], + '>=' => [$version, null, false], + '>' => [[$major, $minor ?? 0, ($patch ?? 0) + 1], null, false], + '<' => [[0, 0, 0], $version, false], + '<=' => [[0, 0, 0], $version, true], + '!=', '<>' => [[0, 0, 0], null, false], + default => [$version, $version, true], + }; + } + + /** + * The version a caret constraint stops before: ^3.2 allows up to 4.0, but + * ^0.3 only up to 0.4 and ^0.0.3 only up to 0.0.4. + * + * @return array{int, int, int} + */ + private static function caretLimit(int $major, ?int $minor, ?int $patch): array + { + return match (true) { + $major > 0 || $minor === null => [$major + 1, 0, 0], + $minor > 0 || $patch === null => [0, $minor + 1, 0], + default => [0, 0, $patch + 1], + }; + } + + /** + * "3.0 - 4.1" allows 3.0.0 up to, but not including, 4.2.0, while a complete + * upper version ("3.0 - 4.1.5") is itself allowed. + * + * @param array $from + * @param array $to + * @return array{0: array{int, int, int}, 1: array{int, int, int}, 2: bool} + */ + private static function hyphenRange(array $from, array $to): array + { + [$toMajor, $toMinor, $toPatch] = $to; + + return [ + [$from[0], $from[1] ?? 0, $from[2] ?? 0], + match (true) { + $toPatch !== null => [$toMajor, $toMinor, $toPatch], + $toMinor !== null => [$toMajor, $toMinor + 1, 0], + default => [$toMajor + 1, 0, 0], + }, + $toPatch !== null, + ]; + } + + /** + * Whether a version is below a range's upper limit, or on it when the limit is allowed. + * + * @param array{int, int, int} $version + * @param array{int, int, int}|null $limit + */ + private static function isBelow(array $version, ?array $limit, bool $includesLimit): bool + { + return $limit === null || $version < $limit || ($includesLimit && $version === $limit); + } + + /** + * @param array $numbers + * @return array + */ + private static function numbers(array $numbers): array + { + return array_map(fn (?string $number): ?int => $number === null ? null : (int) $number, array_values($numbers)); + } + + /** + * Write [4, 0, 0] as "4.0" and [4, 5, 2] as "4.5.2". + * + * @param array{int, int, int} $version + */ + private static function format(array $version): string + { + [$major, $minor, $patch] = $version; + + return $patch === 0 ? "{$major}.{$minor}" : "{$major}.{$minor}.{$patch}"; + } +} diff --git a/config/plugins.php b/config/plugins.php index ba9ffabf6..4bedfac5d 100644 --- a/config/plugins.php +++ b/config/plugins.php @@ -9,13 +9,14 @@ | Supported NativePHP Mobile major versions |-------------------------------------------------------------------------- | - | The NativePHP Mobile major versions plugins can declare support for via - | their `mobile_min_version`. Drives the marketplace directory's version - | filter options, newest first. + | The NativePHP Mobile major versions that have plugins, newest first. A + | plugin's composer.json is checked against each one to find the lowest + | release it works with, and they're the marketplace's version filter + | options. After adding a new major, run plugins:backfill-mobile-versions. | */ - 'mobile_major_versions' => [4, 3, 2, 1], + 'mobile_major_versions' => [4, 3], /* |-------------------------------------------------------------------------- diff --git a/database/factories/PluginFactory.php b/database/factories/PluginFactory.php index fba8a3b9a..0f8a4ff00 100644 --- a/database/factories/PluginFactory.php +++ b/database/factories/PluginFactory.php @@ -216,6 +216,16 @@ public function categories(PluginCategory ...$categories): static ]); } + /** + * The lowest NativePHP Mobile release the plugin supports in each major version, e.g. ('3.0', '4.5.2'). + */ + public function mobileVersions(string ...$versions): static + { + return $this->state(fn (array $attributes) => [ + 'mobile_versions' => collect($versions)->keyBy(fn (string $version): int => (int) $version)->all(), + ]); + } + public function withoutDescription(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_09_26_130540_add_mobile_versions_to_plugins_table.php b/database/migrations/2026_09_26_130540_add_mobile_versions_to_plugins_table.php new file mode 100644 index 000000000..cbced5148 --- /dev/null +++ b/database/migrations/2026_09_26_130540_add_mobile_versions_to_plugins_table.php @@ -0,0 +1,32 @@ +json('mobile_versions')->nullable()->after('mobile_min_version'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('mobile_versions'); + }); + } +}; diff --git a/resources/views/components/plugin-card.blade.php b/resources/views/components/plugin-card.blade.php index 1f8dda120..14da28b47 100644 --- a/resources/views/components/plugin-card.blade.php +++ b/resources/views/components/plugin-card.blade.php @@ -61,6 +61,21 @@ class="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-2.5 py-0.5 t @endif + @if ($plugin->supportedMobileVersions() !== []) +
+ @foreach ($plugin->supportedMobileVersions() as $majorVersion => $version) + + + @endforeach +
+ @endif +
View details diff --git a/resources/views/livewire/plugin-directory.blade.php b/resources/views/livewire/plugin-directory.blade.php index 72b9fbd83..aad1f7254 100644 --- a/resources/views/livewire/plugin-directory.blade.php +++ b/resources/views/livewire/plugin-directory.blade.php @@ -94,9 +94,8 @@ class="rounded-lg border border-gray-300 bg-white py-2 pl-3 pr-9 text-sm text-gr > @foreach ($mobileVersionOptions as $majorVersion) - + @endforeach -
@endif diff --git a/resources/views/plugin-show.blade.php b/resources/views/plugin-show.blade.php index bd82f643b..b97af31ee 100644 --- a/resources/views/plugin-show.blade.php +++ b/resources/views/plugin-show.blade.php @@ -117,6 +117,21 @@ class="inline-flex items-center gap-1.5 rounded-full bg-indigo-100 px-3 py-1 tex Works in Jump @endif + + @if ($plugin->supportedMobileVersions() !== []) + + @foreach ($plugin->supportedMobileVersions() as $majorVersion => $version) + + + @endforeach + + @endif diff --git a/tests/Feature/BackfillPluginMobileVersionsTest.php b/tests/Feature/BackfillPluginMobileVersionsTest.php new file mode 100644 index 000000000..a5cc0c83d --- /dev/null +++ b/tests/Feature/BackfillPluginMobileVersionsTest.php @@ -0,0 +1,86 @@ +create([ + 'composer_data' => ['require' => ['nativephp/mobile' => '^3.2.1 || ^4.0']], + 'mobile_versions' => null, + ]); + + $this->artisan('plugins:backfill-mobile-versions') + ->expectsOutputToContain('1 plugin updated.') + ->assertSuccessful(); + + $this->assertSame([3 => '3.2.1', 4 => '4.0'], $plugin->fresh()->mobile_versions); + } + + public function test_dry_run_does_not_save_versions(): void + { + $plugin = Plugin::factory()->create([ + 'composer_data' => ['require' => ['nativephp/mobile' => '^4.0']], + 'mobile_versions' => null, + ]); + + $this->artisan('plugins:backfill-mobile-versions', ['--dry-run' => true]) + ->expectsOutputToContain('1 plugin would change.') + ->assertSuccessful(); + + $this->assertNull($plugin->fresh()->mobile_versions); + } + + public function test_clears_versions_when_the_plugin_no_longer_requires_nativephp_mobile(): void + { + $plugin = Plugin::factory()->mobileVersions('3.0')->create([ + 'composer_data' => ['require' => ['php' => '^8.2']], + ]); + + $this->artisan('plugins:backfill-mobile-versions')->assertSuccessful(); + + $this->assertNull($plugin->fresh()->mobile_versions); + } + + public function test_plugins_that_have_never_been_synced_are_left_without_versions(): void + { + $plugin = Plugin::factory()->create(['composer_data' => null, 'mobile_versions' => null]); + + $this->artisan('plugins:backfill-mobile-versions') + ->expectsOutputToContain('0 plugins updated.') + ->assertSuccessful(); + + $this->assertNull($plugin->fresh()->mobile_versions); + } + + public function test_lists_plugins_whose_constraint_allows_none_of_the_filtered_major_versions(): void + { + $plugin = Plugin::factory()->create([ + 'composer_data' => ['require' => ['nativephp/mobile' => '^2.0']], + ]); + + $this->artisan('plugins:backfill-mobile-versions') + ->expectsOutputToContain("don't allow any 4.x or 3.x release") + ->assertSuccessful(); + + $this->assertNull($plugin->fresh()->mobile_versions); + } + + public function test_plugins_with_up_to_date_versions_are_left_alone(): void + { + Plugin::factory()->mobileVersions('3.0', '4.0')->create([ + 'composer_data' => ['require' => ['nativephp/mobile' => '^3.0 || ^4.0']], + ]); + + $this->artisan('plugins:backfill-mobile-versions') + ->expectsOutputToContain('0 plugins updated.') + ->assertSuccessful(); + } +} diff --git a/tests/Feature/PluginDirectoryTest.php b/tests/Feature/PluginDirectoryTest.php index 42ff21b3e..30c1d2fd8 100644 --- a/tests/Feature/PluginDirectoryTest.php +++ b/tests/Feature/PluginDirectoryTest.php @@ -8,8 +8,10 @@ use App\Livewire\PluginDirectory; use App\Models\Plugin; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Blade; use Laravel\Pennant\Feature; use Livewire\Livewire; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; class PluginDirectoryTest extends TestCase @@ -120,57 +122,118 @@ public function test_unfiltered_view_does_not_hide_uncategorized_plugins(): void public function test_mobile_version_filter_buckets_by_major_version(): void { - $v4 = Plugin::factory()->approved()->create(['mobile_min_version' => '4.2.0']); - Plugin::factory()->approved()->create(['mobile_min_version' => '3.1.0']); + $v4 = Plugin::factory()->approved()->mobileVersions('4.2')->create(); + Plugin::factory()->approved()->mobileVersions('3.1')->create(); Livewire::test(PluginDirectory::class) ->set('mobileVersion', '4') ->assertViewHas('plugins', fn ($plugins) => $plugins->pluck('id')->all() === [$v4->id]); } - public function test_mobile_version_filter_matches_exact_major_version_string(): void + public function test_mobile_version_filter_finds_a_plugin_under_each_major_version_it_supports(): void { - $v4 = Plugin::factory()->approved()->create(['mobile_min_version' => '4']); - Plugin::factory()->approved()->create(['mobile_min_version' => '3.1.0']); + $both = Plugin::factory()->approved()->mobileVersions('3.0', '4.0')->create(); + $v3 = Plugin::factory()->approved()->mobileVersions('3.2.1')->create(); + $v4 = Plugin::factory()->approved()->mobileVersions('4.5.2')->create(); + + Livewire::test(PluginDirectory::class) + ->set('mobileVersion', '3') + ->assertViewHas('plugins', fn ($plugins) => $plugins->pluck('id')->sort()->values()->all() === [$both->id, $v3->id]) + ->set('mobileVersion', '4') + ->assertViewHas('plugins', fn ($plugins) => $plugins->pluck('id')->sort()->values()->all() === [$both->id, $v4->id]); + } + + public function test_mobile_version_filter_leaves_out_plugins_without_versions(): void + { + Plugin::factory()->approved()->create(['mobile_versions' => null]); + $v4 = Plugin::factory()->approved()->mobileVersions('4.0')->create(); Livewire::test(PluginDirectory::class) ->set('mobileVersion', '4') ->assertViewHas('plugins', fn ($plugins) => $plugins->pluck('id')->all() === [$v4->id]); } - public function test_mobile_version_filter_unspecified_bucket_returns_only_null_versions(): void + #[DataProvider('retiredMobileVersionValues')] + public function test_retired_or_unknown_mobile_version_query_param_is_ignored(string $value): void + { + Plugin::factory()->approved()->create(['mobile_versions' => null]); + Plugin::factory()->approved()->mobileVersions('4.0')->create(); + + Livewire::withQueryParams(['mobileVersion' => $value]) + ->test(PluginDirectory::class) + ->assertSet('mobileVersion', '') + ->assertViewHas('plugins', fn ($plugins) => $plugins->count() === 2); + } + + /** + * @return array + */ + public static function retiredMobileVersionValues(): array { - $unspecified = Plugin::factory()->approved()->create(['mobile_min_version' => null]); - Plugin::factory()->approved()->create(['mobile_min_version' => '4.0.0']); + return [ + 'the old unspecified bucket' => ['unspecified'], + 'an older major version' => ['2'], + 'nonsense' => ['bogus'], + ]; + } + public function test_mobile_version_filter_options_are_the_configured_major_versions(): void + { Livewire::test(PluginDirectory::class) - ->set('mobileVersion', PluginDirectory::MOBILE_VERSION_UNSPECIFIED) - ->assertViewHas('plugins', fn ($plugins) => $plugins->pluck('id')->all() === [$unspecified->id]); + ->assertViewHas('mobileVersionOptions', ['4', '3']) + ->assertSee('NativePHP 4.x') + ->assertSee('NativePHP 3.x') + ->assertDontSee('Version Unspecified'); } - public function test_unfiltered_view_does_not_hide_plugins_without_mobile_min_version(): void + public function test_unfiltered_view_does_not_hide_plugins_without_mobile_versions(): void { - Plugin::factory()->approved()->create(['mobile_min_version' => null]); - Plugin::factory()->approved()->create(['mobile_min_version' => '4.0.0']); + Plugin::factory()->approved()->create(['mobile_versions' => null]); + Plugin::factory()->approved()->mobileVersions('4.0')->create(); Livewire::test(PluginDirectory::class) ->assertViewHas('plugins', fn ($plugins) => $plugins->count() === 2); } + public function test_plugin_card_shows_a_pill_for_each_supported_mobile_version(): void + { + $plugin = Plugin::factory()->approved()->mobileVersions('4.5.2', '3.0')->create(); + + $html = view('components.plugin-card', ['plugin' => $plugin])->render(); + + $this->assertMatchesRegularExpression( + '/Works with NativePHP Mobile 3\.x from 3\.0.*Works with NativePHP Mobile 4\.x from 4\.5\.2/s', + $html, + ); + } + + public function test_plugin_card_marks_mobile_versions_with_a_phone_icon(): void + { + $plugin = Plugin::factory()->approved()->mobileVersions('4.0')->create(); + + $html = view('components.plugin-card', ['plugin' => $plugin])->render(); + + $this->assertStringContainsString( + Blade::render('