From ad56fd35f9d60d07ccecd5fedcd90d187d6f13f2 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 17 Oct 2026 16:34:37 +0100 Subject: [PATCH] Open the web's achievements dialog from the profile's shelf Tapping the rank, the unlocked count or a badge on a profile's shelf opens the web's achievements dialog as a sheet: the rank and XP, the bar to the next rank, every badge with its progress, and the Unix ranks on a second tab. A badge opens it with that badge outlined in the owner's lane. EDGE can't scroll to a child, so a badge in the second half of the list opens the sheet at the end of the list, where the badge is in view. The shelf's badge labels read part of a Blade expression, because a double quoted string inside a native attribute ends the attribute. They now come from Badge::statusLabel(). Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Data/Achievements.php | 47 +++-- app/Devnull/Data/Badge.php | 16 ++ app/Devnull/Data/Rank.php | 36 ++++ .../Concerns/OpensAchievements.php | 65 +++++++ app/NativeComponents/Profile.php | 2 + .../partials/achievements-sheet.blade.php | 124 +++++++++++++ .../native/partials/achievements.blade.php | 61 +++--- .../native/partials/progress-bar.blade.php | 15 ++ resources/views/native/profile.blade.php | 1 + .../Feature/Screens/AchievementsSheetTest.php | 175 ++++++++++++++++++ tests/Unit/Devnull/AchievementsTest.php | 41 ++++ 11 files changed, 539 insertions(+), 44 deletions(-) create mode 100644 app/Devnull/Data/Rank.php create mode 100644 app/NativeComponents/Concerns/OpensAchievements.php create mode 100644 resources/views/native/partials/achievements-sheet.blade.php create mode 100644 resources/views/native/partials/progress-bar.blade.php create mode 100644 tests/Feature/Screens/AchievementsSheetTest.php create mode 100644 tests/Unit/Devnull/AchievementsTest.php diff --git a/app/Devnull/Data/Achievements.php b/app/Devnull/Data/Achievements.php index da372e4..570c2a2 100644 --- a/app/Devnull/Data/Achievements.php +++ b/app/Devnull/Data/Achievements.php @@ -3,13 +3,14 @@ namespace App\Devnull\Data; /** - * A profile's achievements as the web's shelf shows them: the Unix rank and - * its level, the XP towards the next rank, and the badges, unlocked or not. + * A profile's achievements as the web's shelf and dialog show them: the Unix + * rank and its level, the XP towards the next rank, every rank, and the + * badges, unlocked or not. */ final readonly class Achievements { /** - * @param list $ranks + * @param list $ranks * @param list $badges */ public function __construct( @@ -31,21 +32,13 @@ public static function fromArray(mixed $data): ?self return null; } - $ranks = []; - - foreach (is_array($data['ranks'] ?? null) ? $data['ranks'] : [] as $rank) { - if (is_array($rank) && is_string($rank['rank'] ?? null) && is_int($rank['level'] ?? null) && is_int($rank['min_xp'] ?? null)) { - $ranks[] = ['rank' => $rank['rank'], 'level' => $rank['level'], 'min_xp' => $rank['min_xp']]; - } - } - return new self( level: $data['level'], rank: $data['rank'], xp: is_int($data['xp'] ?? null) ? max(0, $data['xp']) : 0, nextLevelXp: is_int($data['next_level_xp'] ?? null) ? $data['next_level_xp'] : null, levelProgress: is_int($data['level_progress'] ?? null) ? max(0, min(100, $data['level_progress'])) : 0, - ranks: $ranks, + ranks: array_values(array_filter(array_map(Rank::fromArray(...), is_array($data['ranks'] ?? null) ? $data['ranks'] : []))), badges: array_values(array_map(Badge::fromArray(...), array_filter(is_array($data['badges'] ?? null) ? $data['badges'] : [], is_array(...)))), ); } @@ -75,13 +68,37 @@ public function xpLabel(): string * "130 XP to operator", or the web's line for the top rank. */ public function nextRankLabel(): string + { + $next = $this->nextRank(); + + return $next === null ? 'Maximum rank (UID 0)' : ($next->minXp - $this->xp).' XP to '.$next->rank; + } + + /** + * The rank a level above this one, as the web finds it, or null at the top. + */ + public function nextRank(): ?Rank { foreach ($this->ranks as $rank) { - if ($rank['level'] === $this->level + 1) { - return ($rank['min_xp'] - $this->xp).' XP to '.$rank['rank']; + if ($rank->level === $this->level + 1) { + return $rank; + } + } + + return null; + } + + /** + * Where the badge with this id sits on the shelf, or null when there's none. + */ + public function badgeIndex(string $id): ?int + { + foreach ($this->badges as $index => $badge) { + if ($badge->id === $id) { + return $index; } } - return 'Maximum rank (UID 0)'; + return null; } } diff --git a/app/Devnull/Data/Badge.php b/app/Devnull/Data/Badge.php index de84019..6f9057c 100644 --- a/app/Devnull/Data/Badge.php +++ b/app/Devnull/Data/Badge.php @@ -40,4 +40,20 @@ public function showsProgress(): bool { return ! $this->unlocked && $this->target > 1; } + + /** + * "unlocked", or how far along it is: "3 of 10". + */ + public function statusLabel(): string + { + return $this->unlocked ? 'unlocked' : "{$this->progress} of {$this->target}"; + } + + /** + * How far along it is as a whole percentage, for the bar under a locked badge in the dialog. + */ + public function progressPercent(): int + { + return min(100, (int) round($this->progress / $this->target * 100)); + } } diff --git a/app/Devnull/Data/Rank.php b/app/Devnull/Data/Rank.php new file mode 100644 index 0000000..6a8b55c --- /dev/null +++ b/app/Devnull/Data/Rank.php @@ -0,0 +1,36 @@ +profile?->achievements; + + if ($achievements === null) { + return; + } + + $this->achievementsBadge = $badge !== null && $achievements->badgeIndex($badge) !== null ? $badge : null; + $this->achievementsTab = 'badges'; + $this->achievementsOpen = true; + } + + public function showAchievementsTab(string $tab): void + { + if (in_array($tab, self::AchievementsTabs, true)) { + $this->achievementsTab = $tab; + } + } + + /** + * iOS says the sheet has gone. + */ + public function achievementsClosed(): void + { + $this->achievementsOpen = false; + $this->achievementsBadge = null; + $this->achievementsTab = 'badges'; + } +} diff --git a/app/NativeComponents/Profile.php b/app/NativeComponents/Profile.php index 0a7dd6c..4bc68ff 100644 --- a/app/NativeComponents/Profile.php +++ b/app/NativeComponents/Profile.php @@ -12,6 +12,7 @@ use App\Icons\Lucide; use App\NativeComponents\Concerns\ExpandsThreads; use App\NativeComponents\Concerns\HoldsNewCommits; +use App\NativeComponents\Concerns\OpensAchievements; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\ShowsCommits; @@ -44,6 +45,7 @@ class Profile extends NativeComponent { use ExpandsThreads; use HoldsNewCommits; + use OpensAchievements; use OpensInTab { isShowing as isShowingPath; } diff --git a/resources/views/native/partials/achievements-sheet.blade.php b/resources/views/native/partials/achievements-sheet.blade.php new file mode 100644 index 0000000..98ed917 --- /dev/null +++ b/resources/views/native/partials/achievements-sheet.blade.php @@ -0,0 +1,124 @@ +{{-- + The web's achievements dialog, as a sheet over the profile (see OpensAchievements): the title with the rank in the + owner's lane, the handle, XP and how many badges are unlocked, the bar to the next rank, then two tabs. Badges + lists every badge with its icon, name and description, a tick once it's unlocked or its progress until then, and a + bar under a locked one that takes more than one step. Unix Ranks lists the ranks with the XP each needs, the + current one in the lane. The XP formula closes it, as on the web. + + A badge the sheet was opened on wears the lane, as the web marks the current rank. EDGE can't scroll to a child, + so a badge in the second half of the list opens the sheet at its end instead, where that badge is in view. + + The web's locked cards are dashed. A dashed card costs about ninety nodes here (see dashed-box), so a locked card + is a faint wash inside a solid hairline, as the shelf's chips are. Only the tab showing is drawn, and nothing at + all until the sheet opens, to keep the profile's frames small. +--}} +@php($achievements = $profile?->achievements) + + @if ($achievementsOpen && $achievements) + @php($lane = $profile->user->lane()) + @php($markedIndex = $achievementsBadge === null ? null : $achievements->badgeIndex($achievementsBadge)) + @php($opensAtEnd = $achievementsTab === 'badges' && $markedIndex !== null && $markedIndex >= intdiv(count($achievements->badges), 2)) + + + + + Developer Achievements + + {{ $achievements->label() }} + + + {{ $profile->user->handle() }} · {{ $achievements->xp }} XP · {{ $achievements->unlockedCount() }}/{{ count($achievements->badges) }} badges unlocked + + + + + {{ $achievements->xpLabel() }} + + {{ $achievements->nextRankLabel() }} + + @include('native.partials.progress-bar', ['ref' => 'sheet-level-progress', 'percent' => $achievements->levelProgress, 'fill' => "bg-theme-lane-{$lane}", 'label' => "{$achievements->levelProgress}% of the way to the next rank"]) + + + + + @foreach ([['badges', 'Badges', count($achievements->badges)], ['ranks', 'Unix Ranks', count($achievements->ranks)]] as [$key, $title, $count]) + + + + {{ $title }} ({{ $count }}) + + + + + @endforeach + + + + + @if ($achievementsTab === 'badges') + + @foreach ($achievements->badges as $index => $badge) + @php($icon = \App\Icons\Lucide::forBadge($badge->icon)) + + + + + + {{ $badge->name }} + @if ($badge->unlocked) + + ✓ + + @else + {{ $badge->progress }}/{{ $badge->target }} + @endif + + {{ $badge->description }} + @if ($badge->showsProgress()) + + @include('native.partials.progress-bar', ['ref' => "sheet-badge-progress-{$badge->id}", 'percent' => $badge->progressPercent(), 'height' => 4, 'label' => "{$badge->progressPercent()}% of the way"]) + + @endif + + @endforeach + + @else + + @foreach ($achievements->ranks as $rank) + @php($current = $rank->level === $achievements->level) + + + {{ $rank->level }} + + + + {{ $rank->rank }} + @if ($current) + + CURRENT + + @endif + + {{ $rank->description }} + + + {{ $rank->minXp }} XP + @if ($rank->achieved) + ✓ Achieved + @else + Requires {{ $rank->minXp }} XP + @endif + + + @endforeach + + @endif + + + + XP: 10/commit · 15/day streak · 5/star received · 5/reply · 2/star given + + + + @endif + diff --git a/resources/views/native/partials/achievements.blade.php b/resources/views/native/partials/achievements.blade.php index 6ceeefa..b29bb6f 100644 --- a/resources/views/native/partials/achievements.blade.php +++ b/resources/views/native/partials/achievements.blade.php @@ -5,48 +5,51 @@ The web wraps the badges onto as many lines as they need. EDGE parses flex-wrap but its iOS FlexContainer never wraps, so here they scroll sideways in one row. The web's locked chips are dashed, which EDGE can't draw round a - pill, so they're a faint solid line. Tapping the shelf opens a dialog on the web; the app has none yet. + pill, so they're a faint solid line. Tapping the rank, the count or a badge opens the web's dialog, as a sheet + (see achievements-sheet); a badge opens it marked. The badges' row runs the full width of the screen and carries the shelf's 16pt inset inside it, so the first chip lines up with the text above, and chips scroll out to the edge of the screen rather than stopping short of it. --}} - - - - {{ $achievements->label() }} - · - {{ $achievements->xp }} XP - - {{ $achievements->unlockedCount() }} / {{ count($achievements->badges) }} unlocked - - - - - {{ $achievements->xpLabel() }} + + + + + {{ $achievements->label() }} + · + {{ $achievements->xp }} XP - {{ $achievements->nextRankLabel() }} - - - @if ($achievements->levelProgress > 0) - - @endif - + + {{ $achievements->unlockedCount() }} / {{ count($achievements->badges) }} unlocked + + + + + + {{ $achievements->xpLabel() }} + + {{ $achievements->nextRankLabel() }} + + @include('native.partials.progress-bar', ['ref' => 'level-progress', 'percent' => $achievements->levelProgress, 'fill' => "bg-theme-lane-{$lane}", 'label' => "{$achievements->levelProgress}% of the way to the next rank"]) + - + @foreach ($achievements->badges as $badge) @php($icon = \App\Icons\Lucide::forBadge($badge->icon)) - progress} of {$badge->target}" }}. {{ $badge->description }}"> - - {{ $badge->name }} - @if ($badge->showsProgress()) - ({{ $badge->progress }}/{{ $badge->target }}) - @endif - + + + + {{ $badge->name }} + @if ($badge->showsProgress()) + ({{ $badge->progress }}/{{ $badge->target }}) + @endif + + @endforeach diff --git a/resources/views/native/partials/progress-bar.blade.php b/resources/views/native/partials/progress-bar.blade.php new file mode 100644 index 0000000..ff457c7 --- /dev/null +++ b/resources/views/native/partials/progress-bar.blade.php @@ -0,0 +1,15 @@ +{{-- + A rounded bar filled $percent of the way, as the web's achievements draw progress. EDGE has no percentage widths, + so the fill and the rest share the row by flex-grow. A grow of 0 counts as 1 here, so each side is left out when + it would have none. +--}} +@php($height ??= 6) +@php($fill ??= 'bg-theme-ink-muted/40') + + @if ($percent > 0) + + @endif + @if ($percent < 100) + + @endif + diff --git a/resources/views/native/profile.blade.php b/resources/views/native/profile.blade.php index 4efd9ed..8b291dc 100644 --- a/resources/views/native/profile.blade.php +++ b/resources/views/native/profile.blade.php @@ -2,6 +2,7 @@ @include('native.partials.error') @include('native.partials.refresh-failed') @include('native.partials.commit-sheet') + @include('native.partials.achievements-sheet') {{-- The list stays alone in here, so the banners above coming and going never add a node beside it. --}} diff --git a/tests/Feature/Screens/AchievementsSheetTest.php b/tests/Feature/Screens/AchievementsSheetTest.php new file mode 100644 index 0000000..2aa689e --- /dev/null +++ b/tests/Feature/Screens/AchievementsSheetTest.php @@ -0,0 +1,175 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * Ada's signed profile, with the ranks and badges devnull sends: one unlocked, one on its way, one not started. + * + * @return array + */ +function achievementsProfileJson(): array +{ + return [ + 'data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace', 'lane' => 1], commits: 12), + 'is_following' => false, + 'follows_you' => false, + 'is_muting' => false, + 'activity' => ['scale' => 'week', 'buckets' => []], + 'streaks' => ['longest' => 4, 'current' => 2], + 'achievements' => [ + 'level' => 2, + 'rank' => 'daemon', + 'xp' => 120, + 'next_level_xp' => 150, + 'level_progress' => 70, + 'xp_breakdown' => [], + 'ranks' => [ + ['rank' => 'user', 'level' => 1, 'min_xp' => 0, 'description' => 'Unprivileged user (UID 1000)', 'achieved' => true], + ['rank' => 'daemon', 'level' => 2, 'min_xp' => 50, 'description' => 'Background process running in user space', 'achieved' => true], + ['rank' => 'operator', 'level' => 3, 'min_xp' => 150, 'description' => 'System operator with elevated permissions', 'achieved' => false], + ], + 'badges' => [ + ['id' => 'init', 'name' => 'init', 'description' => 'Piped your first commit to /dev/null.', 'icon' => 'terminal', 'unlocked' => true, 'progress' => 1, 'target' => 1], + ['id' => 'stdout', 'name' => 'stdout', 'description' => 'Reached 50 commits piped to /dev/null.', 'icon' => 'box', 'unlocked' => false, 'progress' => 12, 'target' => 50], + ['id' => 'max_payload', 'name' => 'max payload', 'description' => 'Committed an exact 280-character post.', 'icon' => 'maximize', 'unlocked' => false, 'progress' => 0, 'target' => 1], + ['id' => 'authorized_keys', 'name' => 'authorized_keys', 'description' => 'Provisioned a public SSH key from GitHub.', 'icon' => 'key', 'unlocked' => false, 'progress' => 0, 'target' => 1], + ], + ], + 'pinned_commit' => null, + ]; +} + +function adaWithAchievements(): TestableComponent +{ + fakeApi(['GET /api/v1/users/ada' => [achievementsProfileJson()], 'GET /api/v1/users/ada/commits' => [pageJson([])]]); + + return Native::visit('/log/users/ada', platform: 'ios'); +} + +/** + * The refs of the badge cards in the sheet whose border is drawn in Ada's lane. + * + * @return list + */ +function markedBadges(TestableComponent $screen): array +{ + $lane = config('native-ui.theme.light.lane-1'); + $cards = findNodes(nodeByRef($screen, 'sheet-badges'), fn (array $node): bool => str_starts_with((string) ($node['ref'] ?? ''), 'sheet-badge-') && ! str_contains($node['ref'], 'progress')); + + return array_values(array_map(fn (array $card): string => $card['ref'], array_filter($cards, fn (array $card): bool => ($card['style']['border_color'] ?? null) === $lane))); +} + +it('keeps the sheet down and empty until the shelf is tapped', function () { + $screen = adaWithAchievements(); + + expect(nodeByRef($screen, 'achievements-sheet')['props']['visible'])->toBeFalse() + ->and(nodeByRef($screen, 'achievements-sheet')['children'] ?? [])->toBe([]); +}); + +it('opens the web\'s achievements dialog from the shelf, with the rank, the XP and every badge', function () { + $screen = adaWithAchievements()->tap('achievements-open'); + + $screen->assertSet('achievementsOpen', true) + ->assertSee('Developer Achievements') + ->assertSee('@ada · 120 XP · 1/4 badges unlocked') + ->assertSee('120 / 150 XP') + ->assertSee('30 XP to operator') + ->assertSee('Badges (4)') + ->assertSee('Unix Ranks (3)') + ->assertSee('Reached 50 commits piped to /dev/null.') + ->assertSee('12/50') + ->assertSee('0/1') + ->assertSee('XP: 10/commit · 15/day streak · 5/star received · 5/reply · 2/star given') + ->assertDontSee('CURRENT'); + + $lane = config('native-ui.theme.light.lane-1'); + + expect(nodeByRef($screen, 'achievements-sheet')['props'])->toMatchArray(['visible' => true, 'detents' => 'large']) + ->and(nodeByRef($screen, 'achievements-scroll')['props']['scroll_anchor'])->toBe('top') + ->and(findNode(nodeByRef($screen, 'sheet-rank'), fn (array $node): bool => ($node['type'] ?? null) === 'text')['props']['color'] ?? null)->toBe($lane) + ->and(nodeByRef($screen, 'sheet-level-progress')['children'][0]['style']['bg_color'] ?? null)->toBe($lane) + ->and(childRefs($screen, 'sheet-badges'))->toBe(['sheet-badge-init', 'sheet-badge-stdout', 'sheet-badge-max_payload', 'sheet-badge-authorized_keys']) + ->and(findNode(nodeByRef($screen, 'sheet-badge-stdout'), fn (array $node): bool => ($node['type'] ?? null) === 'icon')['props']['name'] ?? null)->toBe('custom:box') + ->and(markedBadges($screen))->toBe([]); + + // A tick on the unlocked badge, and a bar only under a locked one that takes more than one step. + expect(treeShows(nodeByRef($screen, 'sheet-badge-init'), '✓'))->toBeTrue() + ->and(findNodes(nodeByRef($screen, 'sheet-badges'), fn (array $node): bool => str_starts_with((string) ($node['ref'] ?? ''), 'sheet-badge-progress-')))->toHaveCount(1) + ->and(nodeByRef($screen, 'sheet-badge-progress-stdout')['children'][0]['layout']['flex_grow'] ?? null)->toEqual(24); +}); + +it('marks the badge it was opened on in the owner\'s lane', function () { + $screen = adaWithAchievements(); + + expect(nodeByRef($screen, 'badge-stdout')['props']['a11y_label'])->toBe('stdout, 12 of 50. Reached 50 commits piped to /dev/null.'); + + $screen->tap('badge-stdout'); + + $screen->assertSet('achievementsBadge', 'stdout'); + + expect(markedBadges($screen))->toBe(['sheet-badge-stdout']) + ->and(nodeByRef($screen, 'achievements-scroll')['props']['scroll_anchor'])->toBe('top'); +}); + +it('opens at the end of the list on a badge in its second half, where that badge is in view', function () { + $screen = adaWithAchievements()->tap('badge-max_payload'); + + expect(markedBadges($screen))->toBe(['sheet-badge-max_payload']) + ->and(nodeByRef($screen, 'achievements-scroll')['props']['scroll_anchor'])->toBe('bottom'); +}); + +it('marks nothing for a badge the profile doesn\'t have', function () { + $screen = adaWithAchievements()->call('openAchievements', 'nope'); + + $screen->assertSet('achievementsOpen', true)->assertSet('achievementsBadge', null); + + expect(markedBadges($screen))->toBe([]); +}); + +it('lists the Unix ranks with the XP each needs, the current one in the lane', function () { + $screen = adaWithAchievements()->tap('badge-max_payload')->tap('achievements-tab-ranks'); + + $screen->assertSet('achievementsTab', 'ranks') + ->assertSee('Background process running in user space') + ->assertSee('CURRENT') + ->assertSee('✓ Achieved') + ->assertSee('Requires 150 XP') + ->assertMissingElement('column', fn (array $node): bool => ($node['ref'] ?? null) === 'sheet-badges'); + + $lane = config('native-ui.theme.light.lane-1'); + + expect(childRefs($screen, 'sheet-ranks'))->toBe(['sheet-rank-1', 'sheet-rank-2', 'sheet-rank-3']) + ->and(nodeByRef($screen, 'sheet-rank-2')['style']['border_color'] ?? null)->toBe($lane) + ->and(nodeByRef($screen, 'sheet-rank-1')['style']['border_color'] ?? null)->not->toBe($lane) + // A fresh scroll view at the top, even though the badges opened at the end. + ->and(nodeByRef($screen, 'achievements-scroll')['props']['scroll_anchor'])->toBe('top'); +}); + +it('forgets the badge and the tab once the sheet has gone, and opens on the badges again', function () { + $screen = adaWithAchievements()->tap('badge-stdout')->tap('achievements-tab-ranks')->dismissSheet('achievements-sheet'); + + $screen->assertSet('achievementsOpen', false) + ->assertSet('achievementsBadge', null) + ->assertSet('achievementsTab', 'badges'); + + expect(nodeByRef($screen, 'achievements-sheet')['children'] ?? [])->toBe([]); + + $screen->tap('achievements-open')->assertSee('Reached 50 commits piped to /dev/null.'); + + expect(markedBadges($screen))->toBe([]); +}); + +it('opens nothing on a profile devnull sent without achievements', function () { + $json = achievementsProfileJson(); + unset($json['achievements']); + + fakeApi(['GET /api/v1/users/ada' => [$json], 'GET /api/v1/users/ada/commits' => [pageJson([])]]); + + Native::visit('/log/users/ada')->call('openAchievements')->assertSet('achievementsOpen', false); +}); diff --git a/tests/Unit/Devnull/AchievementsTest.php b/tests/Unit/Devnull/AchievementsTest.php new file mode 100644 index 0000000..440bc0b --- /dev/null +++ b/tests/Unit/Devnull/AchievementsTest.php @@ -0,0 +1,41 @@ + 2, + 'rank' => 'daemon', + 'xp' => 120, + 'ranks' => [ + ['rank' => 'user', 'level' => 1, 'min_xp' => 0, 'description' => 'Unprivileged user (UID 1000)', 'achieved' => true], + ['rank' => 'daemon', 'level' => 2, 'min_xp' => 50, 'description' => 'Background process running in user space', 'achieved' => true], + ['rank' => 'operator', 'level' => 3, 'min_xp' => 150], + ['rank' => 'broken'], + 'not a rank', + ], + 'badges' => [ + ['id' => 'init', 'name' => 'init'], + ['id' => 'stdout', 'name' => 'stdout'], + ], + ]); + + expect($achievements->ranks)->toHaveCount(3) + ->and($achievements->ranks[1])->toEqual(new Rank('daemon', 2, 50, 'Background process running in user space', true)) + ->and($achievements->ranks[2])->toEqual(new Rank('operator', 3, 150)) + ->and($achievements->nextRank()?->rank)->toBe('operator') + ->and($achievements->badgeIndex('stdout'))->toBe(1) + ->and($achievements->badgeIndex('nope'))->toBeNull(); +}); + +it('says how far along a badge is, in words and as a whole percentage', function () { + expect(new Badge('stdout', 'stdout', unlocked: false, progress: 12, target: 50)) + ->statusLabel()->toBe('12 of 50') + ->progressPercent()->toBe(24) + ->and(new Badge('init', 'init', unlocked: true, progress: 1, target: 1)) + ->statusLabel()->toBe('unlocked') + ->progressPercent()->toBe(100) + ->and(new Badge('pipe', 'pipe', progress: 9, target: 5)->progressPercent())->toBe(100); +});