diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 563cbeea..4c2d3a35 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -20,6 +20,7 @@ import { import type { GatewayApiContext } from './context.js'; import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js'; import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js'; +import { orderGatewayProfiles } from './profileOrder.js'; import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES); @@ -643,7 +644,7 @@ export const adminRouter = router({ if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); } - const profiles = await ctx.profiles.listProfiles(); + const profiles = orderGatewayProfiles(await ctx.profiles.listProfiles()); const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id); return { kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt), @@ -1352,7 +1353,7 @@ export const adminRouter = router({ profiles: router({ list: adminProcedure.query(async ({ ctx }) => { const adminAuth = requireAdminAuth(ctx); - const profiles = (await ctx.profiles.listProfiles()).filter((profile) => + const profiles = orderGatewayProfiles(await ctx.profiles.listProfiles()).filter((profile) => canReadProfile(adminAuth, profile.profileName) ); const profileNames = profiles.map((profile) => profile.profileName); diff --git a/app/gateway-api/src/lobby/profileStatusService.ts b/app/gateway-api/src/lobby/profileStatusService.ts index 47ffc19e..bcf165b7 100644 --- a/app/gateway-api/src/lobby/profileStatusService.ts +++ b/app/gateway-api/src/lobby/profileStatusService.ts @@ -4,6 +4,7 @@ import type { GatewayProfileRepository, GatewayProfileStatus, } from '../orchestrator/profileRepository.js'; +import { orderGatewayProfiles } from '../profileOrder.js'; export type LobbyMapSnapshot = { updatedAt: string | null; @@ -47,7 +48,7 @@ export class InMemoryProfileStatusService implements GatewayProfileStatusService } async listLobbyProfiles(): Promise { - return this.profiles; + return orderGatewayProfiles(this.profiles); } setProfiles(profiles: LobbyProfileStatus[]): void { @@ -63,7 +64,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi ) {} async listLobbyProfiles(): Promise { - const rows = await this.profiles.listProfiles(); + const rows = orderGatewayProfiles(await this.profiles.listProfiles()); const runtimeStates = await this.orchestrator.listRuntimeStates(rows.map((profile) => profile.profileName)); const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state])); return rows.map((row) => this.mapProfile(row, runtimeMap)); diff --git a/app/gateway-api/src/profileOrder.ts b/app/gateway-api/src/profileOrder.ts new file mode 100644 index 00000000..8762c48c --- /dev/null +++ b/app/gateway-api/src/profileOrder.ts @@ -0,0 +1,21 @@ +export const GATEWAY_PROFILE_ORDER = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe'] as const; + +const gatewayProfileOrder = new Map(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index])); + +export const compareGatewayProfiles = ( + left: { profile: string; scenario: string }, + right: { profile: string; scenario: string } +): number => { + const unknownRank = GATEWAY_PROFILE_ORDER.length; + const profileOrder = + (gatewayProfileOrder.get(left.profile) ?? unknownRank) - + (gatewayProfileOrder.get(right.profile) ?? unknownRank); + if (profileOrder !== 0) return profileOrder; + + const profileNameOrder = left.profile.localeCompare(right.profile); + if (profileNameOrder !== 0) return profileNameOrder; + return left.scenario.localeCompare(right.scenario); +}; + +export const orderGatewayProfiles = (profiles: readonly T[]): T[] => + [...profiles].sort(compareGatewayProfiles); diff --git a/app/gateway-api/test/profileOrder.test.ts b/app/gateway-api/test/profileOrder.test.ts new file mode 100644 index 00000000..c8aa04be --- /dev/null +++ b/app/gateway-api/test/profileOrder.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { GATEWAY_PROFILE_ORDER, orderGatewayProfiles } from '../src/profileOrder.js'; + +describe('orderGatewayProfiles', () => { + it('uses the public server order instead of alphabetical profile order', () => { + const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({ + profile, + scenario: 'default', + })); + + expect(orderGatewayProfiles(profiles).map(({ profile }) => profile)).toEqual(GATEWAY_PROFILE_ORDER); + }); + + it('orders scenarios within a profile and places unknown profiles afterward', () => { + const profiles = [ + { profile: 'zeta', scenario: 'default' }, + { profile: 'che', scenario: '20' }, + { profile: 'alpha', scenario: 'default' }, + { profile: 'che', scenario: '10' }, + ]; + + expect(orderGatewayProfiles(profiles)).toEqual([ + { profile: 'che', scenario: '10' }, + { profile: 'che', scenario: '20' }, + { profile: 'alpha', scenario: 'default' }, + { profile: 'zeta', scenario: 'default' }, + ]); + expect(profiles[0]?.profile).toBe('zeta'); + }); +}); diff --git a/app/gateway-frontend/e2e/public-map-tabs.spec.ts b/app/gateway-frontend/e2e/public-map-tabs.spec.ts index 17792e98..b1c82de2 100644 --- a/app/gateway-frontend/e2e/public-map-tabs.spec.ts +++ b/app/gateway-frontend/e2e/public-map-tabs.spec.ts @@ -42,6 +42,24 @@ const profiles: ProfileFixture[] = [ }, ]; +const orderedProfileData: ReadonlyArray = [ + ['che', '체', 15003], + ['kwe', '퀘', 15005], + ['pwe', '풰', 15007], + ['twe', '퉤', 15009], + ['nya', '냐', 15011], + ['pya', '퍄', 15013], + ['hwe', '훼', 15015], +]; +const orderedProfiles: ProfileFixture[] = orderedProfileData.map(([profile, korName, apiPort]) => ({ + profileName: `${profile}:default`, + profile, + korName, + color: '#b0b0b0', + status: 'STOPPED', + apiPort, +})); + const fulfill = async (route: Route, results: unknown[]): Promise => { await route.fulfill({ status: 200, @@ -212,3 +230,22 @@ test('treats an all-closed profile list as a normal empty login status', async ( expect(gameRequestCount).toBe(0); await page.screenshot({ path: testInfo.outputPath('login-no-public-server.png'), fullPage: true }); }); + +test('renders the Gateway profile order returned by the API', async ({ page }, testInfo) => { + await installGatewayFixture(page, orderedProfiles, true); + + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('lobby'); + + const renderedProfiles = await page.locator('tbody tr td:first-child > div:first-child').allTextContents(); + expect(renderedProfiles.map((name) => name.trim())).toEqual([ + '체섭', + '퀘섭', + '풰섭', + '퉤섭', + '냐섭', + '퍄섭', + '훼섭', + ]); + await page.screenshot({ path: testInfo.outputPath('gateway-profile-order.png'), fullPage: true }); +});