diff --git a/app/Community/Actions/BuildAchievementChecklistAction.php b/app/Community/Actions/BuildAchievementChecklistAction.php new file mode 100644 index 0000000000..d3fc8be6e3 --- /dev/null +++ b/app/Community/Actions/BuildAchievementChecklistAction.php @@ -0,0 +1,89 @@ +parseGroup($group); + } + } + + return $this->fillData($groups, $user); + } + + private function parseGroup(string $group): array + { + $index = strrpos($group, ':'); + if ($index === false) { + $header = ''; + $ids = $group; + } else { + $header = substr($group, 0, $index); + $ids = substr($group, $index + 1); + } + + $achievementIds = []; + foreach (explode(',', $ids) as $id) { + $achievementIds[] = (int) $id; + } + + return [ + 'header' => $header, + 'achievementIds' => $achievementIds, + ]; + } + + /** + * @return AchievementGroupData[] + */ + private function fillData(array $groups, User $user): array + { + $ids = []; + foreach ($groups as $group) { + $ids = array_merge($ids, $group['achievementIds']); + } + $ids = array_unique($ids); + + $achievements = Achievement::whereIn('ID', $ids)->with('game')->get(); + $unlocks = PlayerAchievement::where('user_id', $user->id)->whereIn('achievement_id', $ids)->get(); + + $result = []; + foreach ($groups as $group) { + $achievementList = []; + foreach ($group['achievementIds'] as $achievementId) { + $achievement = $achievements->filter(fn ($a) => $a->ID === $achievementId)->first(); + if ($achievement) { + $unlock = $unlocks->filter(fn ($u) => $u->achievement_id === $achievementId)->first(); + $achievementList[] = AchievementData::from($achievement, $unlock)->include( + 'description', + 'points', + 'badgeUnlockedUrl', + 'badgeLockedUrl', + 'unlockedAt', + 'unlockedHardcoreAt', + 'game.badgeUrl', + ); + } + } + + $result[] = new AchievementGroupData($group['header'], $achievementList); + } + + return $result; + } +} diff --git a/app/Community/Controllers/UserAchievementChecklistController.php b/app/Community/Controllers/UserAchievementChecklistController.php new file mode 100644 index 0000000000..d90e69434e --- /dev/null +++ b/app/Community/Controllers/UserAchievementChecklistController.php @@ -0,0 +1,33 @@ +authorize('view', $user); + + $list = $request->get('list'); + + $groups = (new BuildAchievementChecklistAction())->execute($list, $user); + + $props = new AchievementChecklistPagePropsData( + UserData::fromUser($user), + $groups, + ); + + return Inertia::render('user/[user]/achievement-checklist', $props); + } +} diff --git a/app/Community/Data/AchievementChecklistPagePropsData.php b/app/Community/Data/AchievementChecklistPagePropsData.php new file mode 100644 index 0000000000..9843cafd1f --- /dev/null +++ b/app/Community/Data/AchievementChecklistPagePropsData.php @@ -0,0 +1,21 @@ +name('forum.recent-posts'); Route::get('user/{user}/posts', [UserForumTopicCommentController::class, 'index'])->name('user.posts.index'); + Route::get('user/{user}/achievement-checklist', [UserAchievementChecklistController::class, 'index'])->name('user.achievement-checklist'); Route::get('settings', [UserSettingsController::class, 'show'])->name('settings.show'); }); diff --git a/lang/en_US.json b/lang/en_US.json index d0b0212283..1380c7f061 100644 --- a/lang/en_US.json +++ b/lang/en_US.json @@ -14,6 +14,7 @@ "Accountability for content": "Accountability for content", "Accountability for links": "Accountability for links", "Achievement": "Achievement", + "Achievement Checklist": "Achievement Checklist", "Achievement Unlocks": "Achievement Unlocks", "Achievement of the Week": "Achievement of the Week", "Achievements": "Achievements", @@ -135,6 +136,7 @@ "Forum Index": "Forum Index", "Forum Posts": "Forum Posts", "Forum Posts - {{user}}": "Forum Posts - {{user}}", + "from": "from", "Game": "Game", "Game Details": "Game Details", "Games": "Games", @@ -165,6 +167,7 @@ "Important": "Important", "Including your correct emulator version helps developers more quickly identify and resolve issues.": "Including your correct emulator version helps developers more quickly identify and resolve issues.", "Information about cookies": "Information about cookies", + "Invalid list": "Invalid list", "Issue": "Issue", "Join us on Discord": "Join us on Discord", "Just Released": "Just Released", @@ -352,6 +355,7 @@ "Type / to focus the search field.": "Type / to focus the search field.", "Type your comment here. Do not post or request any links to copyrighted ROMs.": "Type your comment here. Do not post or request any links to copyrighted ROMs.", "Undo": "Undo", + "Unlocked {{when}}": "Unlocked {{when}}", "Unpublished": "Unpublished", "Unsubscribe": "Unsubscribe", "Unsubscribed!": "Unsubscribed!", diff --git a/resources/js/features/achievements/components/AchievementGroup/AchievementGroup.test.tsx b/resources/js/features/achievements/components/AchievementGroup/AchievementGroup.test.tsx new file mode 100644 index 0000000000..df104cb0f0 --- /dev/null +++ b/resources/js/features/achievements/components/AchievementGroup/AchievementGroup.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@/test'; +import { createAchievement, createAchievementGroup, createGame } from '@/test/factories'; + +import { AchievementGroup } from './AchievementGroup'; + +describe('Component: AchievementGroup', () => { + it('renders without crashing', () => { + // ARRANGE + const group = createAchievementGroup(); + const { container } = render(); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('displays the header', () => { + // ARRANGE + const group = createAchievementGroup({ + header: 'Creative Name', + }); + + render(); + + // ASSERT + expect(screen.getByText(/Creative Name/)).toBeVisible(); + }); + + it('displays the achievements without games', () => { + // ARRANGE + const group = createAchievementGroup({ + achievements: [ + createAchievement({ + title: 'First Achievement', + description: 'Do the first thing', + game: createGame({ title: 'First Game' }), + }), + createAchievement({ title: 'Second Achievement', description: 'Do the second thing' }), + createAchievement({ title: 'Third Achievement', description: 'Do the third thing' }), + ], + }); + + render(); + + // ASSERT + expect(screen.getByText(/First Achievement/)).toBeVisible(); + expect(screen.queryByText(/First Game/)).toBeNull(); + expect(screen.getByText(/Do the first thing/)).toBeVisible(); + expect(screen.getByText(/Second Achievement/)).toBeVisible(); + expect(screen.getByText(/Do the second thing/)).toBeVisible(); + expect(screen.getByText(/Third Achievement/)).toBeVisible(); + expect(screen.getByText(/Do the third thing/)).toBeVisible(); + }); + + it('displays the achievements with games', () => { + // ARRANGE + const group = createAchievementGroup({ + achievements: [ + createAchievement({ + title: 'First Achievement', + description: 'Do the first thing', + game: createGame({ title: 'First Game' }), + }), + createAchievement({ title: 'Second Achievement', description: 'Do the second thing' }), + createAchievement({ title: 'Third Achievement', description: 'Do the third thing' }), + ], + }); + + render(); + + // ASSERT + expect(screen.getByText(/First Achievement/)).toBeVisible(); + expect(screen.getByText(/First Game/)).toBeVisible(); + expect(screen.getByText(/Do the first thing/)).toBeVisible(); + expect(screen.getByText(/Second Achievement/)).toBeVisible(); + expect(screen.getByText(/Do the second thing/)).toBeVisible(); + expect(screen.getByText(/Third Achievement/)).toBeVisible(); + expect(screen.getByText(/Do the third thing/)).toBeVisible(); + }); +}); diff --git a/resources/js/features/achievements/components/AchievementGroup/AchievementGroup.tsx b/resources/js/features/achievements/components/AchievementGroup/AchievementGroup.tsx new file mode 100644 index 0000000000..26f539dec5 --- /dev/null +++ b/resources/js/features/achievements/components/AchievementGroup/AchievementGroup.tsx @@ -0,0 +1,23 @@ +import type { FC } from 'react'; + +import { UnlockableAchievementAvatar } from '@/features/achievements/components/UnlockableAchievementAvatar'; + +interface AchievementGroupProps { + group: App.Community.Data.AchievementGroup; + showGame?: boolean; +} + +export const AchievementGroup: FC = ({ group, showGame = false }) => { + return ( + + {group.header} + {group.achievements.map((achievement) => ( + + ))} + + ); +}; diff --git a/resources/js/features/achievements/components/AchievementGroup/index.ts b/resources/js/features/achievements/components/AchievementGroup/index.ts new file mode 100644 index 0000000000..0525a68509 --- /dev/null +++ b/resources/js/features/achievements/components/AchievementGroup/index.ts @@ -0,0 +1 @@ +export * from './AchievementGroup'; diff --git a/resources/js/features/achievements/components/UnlockableAchievementAvatar/UnlockableAchievementAvatar.test.tsx b/resources/js/features/achievements/components/UnlockableAchievementAvatar/UnlockableAchievementAvatar.test.tsx new file mode 100644 index 0000000000..3f30ddaf47 --- /dev/null +++ b/resources/js/features/achievements/components/UnlockableAchievementAvatar/UnlockableAchievementAvatar.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from '@/test'; +import { createAchievement, createGame } from '@/test/factories'; + +import { UnlockableAchievementAvatar } from './UnlockableAchievementAvatar'; + +describe('Component: UnlockableAchievementAvatar', () => { + it('renders without crashing', () => { + // ARRANGE + const achievement = createAchievement(); + const { container } = render(); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('displays a locked achievement correctly', () => { + // ARRANGE + const achievement = createAchievement({ + title: 'Creative Name', + description: 'Do the thing', + }); + + render(); + + // ASSERT + const img = screen.getByRole('img') as HTMLImageElement; + expect(img).toBeVisible(); + expect(img.src).toContain(achievement.badgeLockedUrl); + + expect(screen.getByText(/Creative Name/)).toBeVisible(); + expect(screen.getByText(/Do the thing/)).toBeVisible(); + expect(screen.queryByText(/unlocked/i)).toBeNull(); + }); + + it('displays a softcore unlock correctly', () => { + // ARRANGE + const achievement = createAchievement({ + title: 'Creative Name', + description: 'Do the thing', + unlockedAt: '2024-08-12 16:24:36', + }); + + render(); + + // ASSERT + const img = screen.getByRole('img') as HTMLImageElement; + expect(img).toBeVisible(); + expect(img.src).toContain(achievement.badgeUnlockedUrl); + + expect(screen.getByText(/Creative Name/)).toBeVisible(); + expect(screen.getByText(/Unlocked Aug 12, 2024 4:24 PM/)).toBeVisible(); + }); + + it('displays a hardcore unlock correctly', () => { + // ARRANGE + const achievement = createAchievement({ + title: 'Creative Name', + description: 'Do the thing', + unlockedAt: '2024-08-12 16:24:36', + unlockedHardcoreAt: '2024-09-05 08:11:42', + }); + + render(); + + // ASSERT + const img = screen.getByRole('img') as HTMLImageElement; + expect(img).toBeVisible(); + expect(img.src).toContain(achievement.badgeUnlockedUrl); + + expect(screen.getByText(/Creative Name/)).toBeVisible(); + expect(screen.getByText(/Unlocked Sep 5, 2024 8:11 AM/)).toBeVisible(); + }); + + it('displays a hardcore unlock correctly', () => { + // ARRANGE + const achievement = createAchievement({ + title: 'Creative Name', + description: 'Do the thing', + unlockedAt: '2024-08-12 16:24:36', + unlockedHardcoreAt: '2024-09-05 08:11:42', + }); + + render(); + + // ASSERT + const img = screen.getByRole('img') as HTMLImageElement; + expect(img).toBeVisible(); + expect(img.src).toContain(achievement.badgeUnlockedUrl); + + expect(screen.getByText(/Creative Name/)).toBeVisible(); + expect(screen.getByText(/Unlocked Sep 5, 2024 8:11 AM/)).toBeVisible(); + }); + + it('displays the game correctly', () => { + // ARRANGE + const achievement = createAchievement({ + title: 'Creative Name', + description: 'Do the thing', + game: createGame({ title: 'Container Set' }), + }); + + render(); + + // ASSERT + const img = screen.getByRole('img') as HTMLImageElement; + expect(img).toBeVisible(); + expect(img.src).toContain(achievement.badgeLockedUrl); + + expect(screen.getByText(/Creative Name/)).toBeVisible(); + expect(screen.getByText(/Container Set/)).toBeVisible(); + expect(screen.getByText(/Do the thing/)).toBeVisible(); + expect(screen.queryByText(/unlocked/i)).toBeNull(); + }); + + it('hides the game correctly', () => { + // ARRANGE + const achievement = createAchievement({ + title: 'Creative Name', + description: 'Do the thing', + game: createGame({ title: 'Container Set' }), + }); + + render(); + + // ASSERT + const img = screen.getByRole('img') as HTMLImageElement; + expect(img).toBeVisible(); + expect(img.src).toContain(achievement.badgeLockedUrl); + + expect(screen.getByText(/Creative Name/)).toBeVisible(); + expect(screen.queryByText(/Container Set/)).toBeNull(); + expect(screen.getByText(/Do the thing/)).toBeVisible(); + expect(screen.queryByText(/unlocked/i)).toBeNull(); + }); +}); diff --git a/resources/js/features/achievements/components/UnlockableAchievementAvatar/UnlockableAchievementAvatar.tsx b/resources/js/features/achievements/components/UnlockableAchievementAvatar/UnlockableAchievementAvatar.tsx new file mode 100644 index 0000000000..11aa7b3934 --- /dev/null +++ b/resources/js/features/achievements/components/UnlockableAchievementAvatar/UnlockableAchievementAvatar.tsx @@ -0,0 +1,60 @@ +import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { AchievementAvatar } from '@/common/components/AchievementAvatar'; +import { GameAvatar } from '@/common/components/GameAvatar'; +import type { AvatarSize } from '@/common/models'; +import { UnlockedAtLabel } from '@/features/achievements/components/UnlockedAtLabel'; + +interface UnlockableAchievementAvatarProps { + achievement: App.Platform.Data.Achievement; + showGame?: boolean; + imageSize?: AvatarSize; +} + +export const UnlockableAchievementAvatar: FC = ({ + achievement, + showGame = false, + imageSize = 48, +}) => { + const { t } = useTranslation(); + + const badgeUrl = + !achievement.unlockedAt && !achievement.unlockedHardcoreAt + ? achievement.badgeLockedUrl + : achievement.badgeUnlockedUrl; + + return ( + + + + + + + + {showGame && achievement.game ? ( + <> + {t('from')} + + > + ) : null} + + + {achievement.description} + + {achievement.unlockedHardcoreAt ? ( + + ) : achievement.unlockedAt ? ( + + ) : null} + + + ); +}; diff --git a/resources/js/features/achievements/components/UnlockableAchievementAvatar/index.ts b/resources/js/features/achievements/components/UnlockableAchievementAvatar/index.ts new file mode 100644 index 0000000000..e674229cf0 --- /dev/null +++ b/resources/js/features/achievements/components/UnlockableAchievementAvatar/index.ts @@ -0,0 +1 @@ +export * from './UnlockableAchievementAvatar'; diff --git a/resources/js/features/achievements/components/UnlockedAtLabel/UnlockedAtLabel.tsx b/resources/js/features/achievements/components/UnlockedAtLabel/UnlockedAtLabel.tsx new file mode 100644 index 0000000000..1d2b5ac3f7 --- /dev/null +++ b/resources/js/features/achievements/components/UnlockedAtLabel/UnlockedAtLabel.tsx @@ -0,0 +1,18 @@ +import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { formatDate } from '@/common/utils/l10n/formatDate'; + +interface UnlockedAtLabelProps { + when: string; +} + +export const UnlockedAtLabel: FC = ({ when }) => { + const { t } = useTranslation(); + + return ( + + {t('Unlocked {{when}}', { when: formatDate(when, 'lll') })} + + ); +}; diff --git a/resources/js/features/achievements/components/UnlockedAtLabel/index.ts b/resources/js/features/achievements/components/UnlockedAtLabel/index.ts new file mode 100644 index 0000000000..0cc3a308f9 --- /dev/null +++ b/resources/js/features/achievements/components/UnlockedAtLabel/index.ts @@ -0,0 +1 @@ +export * from './UnlockedAtLabel'; diff --git a/resources/js/pages/user/[user]/achievement-checklist.tsx b/resources/js/pages/user/[user]/achievement-checklist.tsx new file mode 100644 index 0000000000..73637edf5c --- /dev/null +++ b/resources/js/pages/user/[user]/achievement-checklist.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next'; + +import { EmptyState } from '@/common/components/EmptyState'; +import { UserBreadcrumbs } from '@/common/components/UserBreadcrumbs'; +import { UserHeading } from '@/common/components/UserHeading'; +import { usePageProps } from '@/common/hooks/usePageProps'; +import { AppLayout } from '@/common/layouts/AppLayout'; +import type { AppPage } from '@/common/models'; +import { AchievementGroup } from '@/features/achievements/components/AchievementGroup'; + +const UserAchievementChecklist: AppPage = () => { + const { player, groups } = usePageProps(); + + const { t } = useTranslation(); + + return ( + <> + + + + {t('Achievement Checklist')} + + {groups.length > 0 ? ( + + {groups.map((group, index) => ( + + ))} + + ) : ( + {t('Invalid list')} + )} + + + > + ); +}; + +UserAchievementChecklist.layout = (page) => {page}; + +export default UserAchievementChecklist; diff --git a/resources/js/test/factories/createAchievementGroup.ts b/resources/js/test/factories/createAchievementGroup.ts new file mode 100644 index 0000000000..acd51add14 --- /dev/null +++ b/resources/js/test/factories/createAchievementGroup.ts @@ -0,0 +1,11 @@ +import { createFactory } from '../createFactory'; +import { createAchievement } from './createAchievement'; + +export const createAchievementGroup = createFactory( + (faker) => { + return { + header: faker.word.words(2), + achievements: [createAchievement(), createAchievement()], + }; + }, +); diff --git a/resources/js/test/factories/index.ts b/resources/js/test/factories/index.ts index 94f4b56e2c..aa6080bc1f 100644 --- a/resources/js/test/factories/index.ts +++ b/resources/js/test/factories/index.ts @@ -1,4 +1,5 @@ export * from './createAchievement'; +export * from './createAchievementGroup'; export * from './createAchievementSetClaim'; export * from './createActivePlayer'; export * from './createComment'; diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 628e5dbe69..aa5ec40551 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -1,10 +1,18 @@ declare namespace App.Community.Data { + export type AchievementChecklistPageProps = { + player: App.Data.User; + groups: Array; + }; export type AchievementCommentsPageProps = { achievement: App.Platform.Data.Achievement; paginatedComments: App.Data.PaginatedData; isSubscribed: boolean; canComment: boolean; }; + export type AchievementGroup = { + header: string; + achievements: Array; + }; export type ActivePlayer = { user: App.Data.User; game: App.Platform.Data.Game; @@ -571,7 +579,6 @@ declare namespace App.Platform.Data { }; } declare namespace App.Platform.Enums { - export type UnlockMode = 0 | 1; export type AchievementAuthorTask = 'artwork' | 'design' | 'logic' | 'testing' | 'writing'; export type AchievementFlag = 3 | 5; export type AchievementSetAuthorTask = 'artwork'; @@ -608,7 +615,8 @@ declare namespace App.Platform.Enums { | 'numVisibleLeaderboards' | 'numUnresolvedTickets' | 'progress'; - export type GameSetType = 'hub' | 'similar-games'; export type PlayerPreferredMode = 'softcore' | 'hardcore' | 'mixed'; + export type UnlockMode = 0 | 1; + export type GameSetType = 'hub' | 'similar-games'; export type ReleasedAtGranularity = 'day' | 'month' | 'year'; } diff --git a/tests/Feature/Community/Actions/BuildAchievementChecklistActionTest.php b/tests/Feature/Community/Actions/BuildAchievementChecklistActionTest.php new file mode 100644 index 0000000000..affe1dd797 --- /dev/null +++ b/tests/Feature/Community/Actions/BuildAchievementChecklistActionTest.php @@ -0,0 +1,249 @@ +seedGame(achievements: 3); + $game2 = $this->seedGame(achievements: 4); + $game3 = $this->seedGame(achievements: 3); + $achievement1 = $game1->achievements->get(0); + $achievement2 = $game1->achievements->get(1); + $achievement3 = $game1->achievements->get(2); + $achievement4 = $game2->achievements->get(0); + $achievement5 = $game2->achievements->get(1); + $achievement6 = $game2->achievements->get(2); + $achievement7 = $game2->achievements->get(3); + $achievement8 = $game3->achievements->get(0); + $achievement9 = $game3->achievements->get(1); + $achievement10 = $game3->achievements->get(2); + + // populate 3 users. user1 will have unlocked nothing. + // user2 will have unlocked even achievements. (achievement 6 only in softcore, achievement 8 promoted from softcore) + // user3 will have unlocked IDs multiple of 3. + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $user3 = User::factory()->create(); + + $time1 = Carbon::create(2024, 2, 6, 14, 15, 16); + $time2 = Carbon::create(2024, 3, 11, 5, 58, 52); + $time3 = Carbon::create(2024, 4, 9, 3, 43, 7); + $time4 = Carbon::create(2024, 5, 16, 13, 26, 28); + $time5 = Carbon::create(2024, 5, 17, 19, 17, 37); + $time6 = Carbon::create(2024, 6, 28, 22, 5, 34); + + $this->addHardcoreUnlock($user2, $achievement2, $time3); + $this->addHardcoreUnlock($user2, $achievement4, $time2); + $this->addSoftcoreUnlock($user2, $achievement6, $time1); + $this->addSoftcoreUnlock($user2, $achievement8, $time4); + $this->addHardcoreUnlock($user2, $achievement8, $time5); + $this->addHardcoreUnlock($user2, $achievement10, $time6); + + $this->addHardcoreUnlock($user3, $achievement3, $time2); + $this->addHardcoreUnlock($user3, $achievement6, $time4); + $this->addHardcoreUnlock($user3, $achievement9, $time6); + + // empty list + $result = (new BuildAchievementChecklistAction())->execute("", $user2); + $this->assertEquals([], $result); + + // one achievement, unearned + $result = (new BuildAchievementChecklistAction())->execute("4", $user1); + $this->assertAchievementGroups([ + [ + 'header' => '', + 'achievements' => [ + $this->wrapAchievement($achievement4), + ], + ], + ], $result); + + // unheadered CSV + $result = (new BuildAchievementChecklistAction())->execute("4,5,6", $user2); + $this->assertAchievementGroups([ + [ + 'header' => '', + 'achievements' => [ + $this->wrapAchievement($achievement4, $time2), + $this->wrapAchievement($achievement5), + $this->wrapAchievement($achievement6, null, $time1), + ], + ], + ], $result); + + // headered CSV + $result = (new BuildAchievementChecklistAction())->execute("Some Header:1,3,5", $user3); + $this->assertAchievementGroups([ + [ + 'header' => 'Some Header', + 'achievements' => [ + $this->wrapAchievement($achievement1), + $this->wrapAchievement($achievement3, $time2), + $this->wrapAchievement($achievement5), + ], + ], + ], $result); + + // multiple groups, unheadered + $result = (new BuildAchievementChecklistAction())->execute("1,2|4|7,8,9", $user2); + $this->assertAchievementGroups([ + [ + 'header' => '', + 'achievements' => [ + $this->wrapAchievement($achievement1), + $this->wrapAchievement($achievement2, $time3), + ], + ], + [ + 'header' => '', + 'achievements' => [ + $this->wrapAchievement($achievement4, $time2), + ], + ], + [ + 'header' => '', + 'achievements' => [ + $this->wrapAchievement($achievement7), + $this->wrapAchievement($achievement8, $time5, $time4), + $this->wrapAchievement($achievement9), + ], + ], + ], $result); + + // multiple groups, headered + $result = (new BuildAchievementChecklistAction())->execute("First:1|Second:3,4|Third:8,9,10", $user3); + $this->assertAchievementGroups([ + [ + 'header' => 'First', + 'achievements' => [ + $this->wrapAchievement($achievement1), + ], + ], + [ + 'header' => 'Second', + 'achievements' => [ + $this->wrapAchievement($achievement3, $time2), + $this->wrapAchievement($achievement4), + ], + ], + [ + 'header' => 'Third', + 'achievements' => [ + $this->wrapAchievement($achievement8), + $this->wrapAchievement($achievement9, $time6), + $this->wrapAchievement($achievement10), + ], + ], + ], $result); + + // multiple groups, headered (alternate user) + $result = (new BuildAchievementChecklistAction())->execute("First:1|Second:3,4|Third:8,9,10", $user2); + $this->assertAchievementGroups([ + [ + 'header' => 'First', + 'achievements' => [ + $this->wrapAchievement($achievement1), + ], + ], + [ + 'header' => 'Second', + 'achievements' => [ + $this->wrapAchievement($achievement3), + $this->wrapAchievement($achievement4, $time2), + ], + ], + [ + 'header' => 'Third', + 'achievements' => [ + $this->wrapAchievement($achievement8, $time5, $time4), + $this->wrapAchievement($achievement9), + $this->wrapAchievement($achievement10, $time6), + ], + ], + ], $result); + + // empty groups ignored + $result = (new BuildAchievementChecklistAction())->execute("|First:1||Third:8,9|", $user3); + $this->assertAchievementGroups([ + [ + 'header' => 'First', + 'achievements' => [ + $this->wrapAchievement($achievement1), + ], + ], + [ + 'header' => 'Third', + 'achievements' => [ + $this->wrapAchievement($achievement8), + $this->wrapAchievement($achievement9, $time6), + ], + ], + ], $result); + + // unknown achievements ignored + $result = (new BuildAchievementChecklistAction())->execute("1,15,3|Other:8,27,9|", $user2); + $this->assertAchievementGroups([ + [ + 'header' => '', + 'achievements' => [ + $this->wrapAchievement($achievement1), + $this->wrapAchievement($achievement3), + ], + ], + [ + 'header' => 'Other', + 'achievements' => [ + $this->wrapAchievement($achievement8, $time5, $time4), + $this->wrapAchievement($achievement9), + ], + ], + ], $result); + } + + private function assertAchievementGroups(array $expected, array $groups): void + { + $converted = []; + foreach ($groups as $group) { + $converted[] = $group->toArray(); + } + + $this->assertEquals($expected, $converted); + } + + private function wrapAchievement(Achievement $achievement, ?Carbon $hardcoreUnlock = null, ?Carbon $softcoreUnlock = null): array + { + $achievement->loadMissing('game'); + + return [ + 'id' => $achievement->ID, + 'title' => $achievement->Title, + 'description' => $achievement->Description, + 'points' => $achievement->Points, + 'badgeUnlockedUrl' => $achievement->badgeUnlockedUrl, + 'badgeLockedUrl' => $achievement->badgeLockedUrl, + 'unlockedAt' => $softcoreUnlock ? $softcoreUnlock->format('c') : ($hardcoreUnlock ? $hardcoreUnlock->format('c') : null), + 'unlockedHardcoreAt' => $hardcoreUnlock ? $hardcoreUnlock->format('c') : null, + 'game' => [ + 'id' => $achievement->game->id, + 'title' => $achievement->game->title, + 'badgeUrl' => $achievement->game->badgeUrl, + ], + ]; + } +}