From c1ac5b658dff480a9ca895b808814e6e95e4ce1a Mon Sep 17 00:00:00 2001 From: na2na-p Date: Mon, 20 Nov 2023 16:33:26 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Voice/internal/Voice.class.spec.ts | 277 ++++++++++++++++++ .../internal/Voice/internal/Voice.class.ts | 37 +-- .../Voice/internal/Voice.constants.ts | 7 - .../internal/funcs/checkJoinable/index.ts | 2 + .../internal/checkJoinable.constants.ts | 7 + .../internal/checkJoinable.func.ts | 19 ++ .../internal/checkJoinable.spec.ts | 35 +++ 7 files changed, 353 insertions(+), 31 deletions(-) create mode 100644 src/features/core/internal/Voice/internal/Voice.class.spec.ts create mode 100644 src/features/core/internal/Voice/internal/funcs/checkJoinable/index.ts create mode 100644 src/features/core/internal/Voice/internal/funcs/checkJoinable/internal/checkJoinable.constants.ts create mode 100644 src/features/core/internal/Voice/internal/funcs/checkJoinable/internal/checkJoinable.func.ts create mode 100644 src/features/core/internal/Voice/internal/funcs/checkJoinable/internal/checkJoinable.spec.ts diff --git a/src/features/core/internal/Voice/internal/Voice.class.spec.ts b/src/features/core/internal/Voice/internal/Voice.class.spec.ts new file mode 100644 index 00000000..ad5c603e --- /dev/null +++ b/src/features/core/internal/Voice/internal/Voice.class.spec.ts @@ -0,0 +1,277 @@ +import type { Mock } from 'vitest'; + +import { + joinVoiceChannel, + getVoiceConnection, +} from '@/features/library/index.js'; +import type { + ChatInputCommandInteraction, + GuildMember, + VoiceConnection, +} from '@/features/library/index.js'; +import { LogicException } from '@/features/others/Error/LogicException.js'; +import { getInteractionMemberId } from '@/features/others/discord/index.js'; + +import { Voice } from './Voice.class.js'; +import { + JOINABLE_TYPE_MAP, + checkJoinable, +} from './funcs/checkJoinable/index.js'; + +vi.mock('@/features/others/discord/index.js', async () => { + const actual = (await vi.importActual( + '@/features/others/discord/index.js' + )) as object; + return { + ...actual, + getInteractionMemberId: vi.fn(), + }; +}); + +vi.mock('@/features/library/index.js', async () => { + const actual = (await vi.importActual( + '@/features/library/index.js' + )) as object; + return { + ...actual, + joinVoiceChannel: vi.fn(), + getVoiceConnection: vi.fn(), + }; +}); + +vi.mock('./funcs/checkJoinable/index.js', async () => { + const actual = (await vi.importActual( + './funcs/checkJoinable/index.js' + )) as object; + return { + ...actual, + checkJoinable: vi.fn(), + }; +}); + +describe('Voice', () => { + let voice: Voice; + + beforeEach(() => { + voice = new Voice(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('join', () => { + it('should reply with an error message if the channel is not found', async () => { + const mockedInteraction = { + reply: vi.fn(), + } as unknown as Readonly; + + (getInteractionMemberId as Mock).mockResolvedValueOnce({ + voice: { channel: null }, + }); + (checkJoinable as Mock).mockReturnValueOnce(JOINABLE_TYPE_MAP.NOT_FOUND); + + await voice.join({ interaction: mockedInteraction }); + + expect(checkJoinable).toHaveBeenCalledWith({ channel: null }); + expect(mockedInteraction.reply).toHaveBeenCalledWith({ + content: '接続先のVCが見つかりません。', + ephemeral: false, + }); + }); + + it('should reply with an error message if the channel is not joinable', async () => { + const mockedInteraction = { + reply: vi.fn(), + } as unknown as Readonly; + + (getInteractionMemberId as Mock).mockResolvedValueOnce({ + voice: { channel: { joinable: true } }, + }); + (checkJoinable as Mock).mockReturnValueOnce( + JOINABLE_TYPE_MAP.NOT_JOINABLE + ); + + await voice.join({ interaction: mockedInteraction }); + + expect(checkJoinable).toHaveBeenCalledWith({ + channel: { joinable: true }, + }); + expect(mockedInteraction.reply).toHaveBeenCalledWith({ + content: '接続先のVCに参加できません。権限の見直しをしてください。', + ephemeral: true, + }); + }); + + it('should reply with an error message if the channel is not viewable', async () => { + const mockedInteraction = { + reply: vi.fn(), + } as unknown as Readonly; + + (getInteractionMemberId as Mock).mockResolvedValueOnce({ + voice: { channel: { joinable: true, viewable: false } }, + }); + (checkJoinable as Mock).mockReturnValueOnce( + JOINABLE_TYPE_MAP.NOT_VIEWABLE + ); + + await voice.join({ interaction: mockedInteraction }); + + expect(checkJoinable).toHaveBeenCalledWith({ + channel: { joinable: true, viewable: false }, + }); + expect(mockedInteraction.reply).toHaveBeenCalledWith({ + content: '接続先のVCに参加できません。権限の見直しをしてください。', + ephemeral: true, + }); + }); + + it('should throw an error if the channel is null', async () => { + const interaction = { + reply: vi.fn(), + } as unknown as Readonly; + + (getInteractionMemberId as Mock).mockRejectedValueOnce( + new LogicException('Channel is null. Please check checkJoinable()') + ); + + await expect(voice.join({ interaction })).rejects.toThrowError( + 'Channel is null. Please check checkJoinable()' + ); + }); + + it('should throw an error if the channel is null', async () => { + const mockedInteraction = { + reply: vi.fn(), + } as unknown as Readonly; + + (getInteractionMemberId as Mock).mockResolvedValueOnce({ + voice: { channel: null }, + }); + (checkJoinable as Mock).mockReturnValueOnce(JOINABLE_TYPE_MAP.JOINABLE); + + await expect( + voice.join({ interaction: mockedInteraction }) + ).rejects.toThrowError('Channel is null. Please check checkJoinable()'); + }); + + it('should join the voice channel and return true', async () => { + const mockedGuildMember = { + voice: { + channel: { + id: 'channelId', + guild: { + id: 'guildId', + voiceAdapterCreator: vi.fn(), + joinable: true, + viewable: true, + }, + }, + }, + } as const as unknown as Readonly; + + const mockedInteraction = { + reply: vi.fn(), + } as const as unknown as Readonly; + + (getInteractionMemberId as Mock).mockResolvedValueOnce(mockedGuildMember); + (joinVoiceChannel as Mock).mockReturnValueOnce('connection'); + (checkJoinable as Mock).mockReturnValueOnce(JOINABLE_TYPE_MAP.JOINABLE); + + const result = await voice.join({ interaction: mockedInteraction }); + + expect(checkJoinable).toHaveBeenCalledWith(mockedGuildMember.voice); + expect(joinVoiceChannel).toHaveBeenCalledWith({ + channelId: 'channelId', + guildId: 'guildId', + adapterCreator: expect.any(Function), + }); + expect(voice['connection']).toEqual([ + { guildId: 'guildId', connection: 'connection' }, + ]); + expect(result).toBe(true); + }); + + it('should throw an error if the joinable type is unknown', async () => { + const interaction = { + voice: { channel: { joinable: true, viewable: true } }, + reply: vi.fn(), + } as unknown as Readonly; + + (getInteractionMemberId as Mock).mockResolvedValueOnce({ + voice: { channel: null }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (checkJoinable as Mock).mockReturnValueOnce('unknown' as any); + + await expect(voice.join({ interaction })).rejects.toThrowError( + 'Unknown joinable type.' + ); + }); + }); + + describe('leave', () => { + it('should return false if guildId is null', async () => { + const interaction = { + guildId: null, + } as unknown as Readonly; + + const result = await voice.leave({ interaction }); + + expect(result).toBe(false); + }); + + it('should destroy the connection and return true if the target connection exists', async () => { + const interaction = { + guildId: 'guildId', + } as unknown as Readonly; + + voice['connection'] = [ + { + guildId: 'guildId', + connection: { + destroy: vi.fn(), + } as unknown as VoiceConnection, + }, + ]; + + const result = await voice.leave({ interaction }); + + expect(voice['connection']).toEqual([]); + expect(result).toBe(true); + }); + + it('should destroy the connection and return true if the target connection does not exist but the member has a connection', async () => { + const interaction = { + guildId: 'guildId', + } as unknown as Readonly; + + (getInteractionMemberId as Mock).mockResolvedValueOnce({ + guild: { id: 'guildId' }, + }); + (getVoiceConnection as Mock).mockReturnValueOnce({ + destroy: vi.fn(), + }); + + const result = await voice.leave({ interaction }); + + expect(getVoiceConnection).toHaveBeenCalledWith('guildId'); + expect(result).toBe(true); + }); + + it('should return false if the target connection does not exist and the member does not have a connection', async () => { + const interaction = { + guildId: 'guildId', + } as unknown as Readonly; + + (getInteractionMemberId as Mock).mockResolvedValueOnce({ + guild: { id: 'guildId' }, + }); + (getVoiceConnection as Mock).mockReturnValueOnce(null); + + const result = await voice.leave({ interaction }); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/features/core/internal/Voice/internal/Voice.class.ts b/src/features/core/internal/Voice/internal/Voice.class.ts index b40ebab4..71bea814 100644 --- a/src/features/core/internal/Voice/internal/Voice.class.ts +++ b/src/features/core/internal/Voice/internal/Voice.class.ts @@ -1,7 +1,6 @@ import type { VoiceConnection, ChatInputCommandInteraction, - VoiceBasedChannel, } from '@/features/library/index.js'; import { getVoiceConnection, @@ -11,10 +10,13 @@ import { import { LogicException } from '@/features/others/Error/LogicException.js'; import { getInteractionMemberId } from '@/features/others/discord/index.js'; -import { JOINABLE_TYPE_MAP } from './Voice.constants.js'; +import { + JOINABLE_TYPE_MAP, + checkJoinable, +} from './funcs/checkJoinable/index.js'; export class Voice { - #connection: Array<{ + connection: Array<{ guildId: string; connection: VoiceConnection; }> = []; @@ -27,7 +29,7 @@ export class Voice { const { voice: { channel }, } = await getInteractionMemberId(interaction); - const joinable = this.checkJoinable({ + const joinable = checkJoinable({ channel, }); @@ -64,15 +66,18 @@ export class Voice { adapterCreator: channel.guild.voiceAdapterCreator, }); - this.#connection.push({ + this.connection.push({ guildId: channel.guild.id, connection, }); return true; default: - throw new LogicException('Unknown joinable type.'); + // HACK: ここでrejectするとSwitch外のカバレッジがuncoveredになる + break; } + + return Promise.reject(new LogicException('Unknown joinable type.')); } public async leave({ @@ -83,7 +88,7 @@ export class Voice { const guildId = interaction.guildId; if (isNil(guildId)) return false; - const targetConnection = this.#connection.find( + const targetConnection = this.connection.find( connection => connection.guildId === guildId ); @@ -98,26 +103,10 @@ export class Voice { } } else { targetConnection.connection.destroy(); - this.#connection = this.#connection.filter( + this.connection = this.connection.filter( connection => connection.guildId !== guildId ); return true; } } - - checkJoinable({ - channel, - }: { - channel: VoiceBasedChannel | null; - }): (typeof JOINABLE_TYPE_MAP)[keyof typeof JOINABLE_TYPE_MAP] { - if (isNil(channel)) { - return JOINABLE_TYPE_MAP.NOT_FOUND; - } else if (!channel.joinable) { - return JOINABLE_TYPE_MAP.NOT_JOINABLE; - } else if (!channel.viewable) { - return JOINABLE_TYPE_MAP.NOT_VIEWABLE; - } else { - return JOINABLE_TYPE_MAP.JOINABLE; - } - } } diff --git a/src/features/core/internal/Voice/internal/Voice.constants.ts b/src/features/core/internal/Voice/internal/Voice.constants.ts index 7ae6264b..e69de29b 100644 --- a/src/features/core/internal/Voice/internal/Voice.constants.ts +++ b/src/features/core/internal/Voice/internal/Voice.constants.ts @@ -1,7 +0,0 @@ -export const JOINABLE_TYPE_MAP = { - JOINABLE: 'JOINABLE', - NOT_JOINABLE: 'NOT_JOINABLE', - NOT_FOUND: 'NOT_FOUND', - NOT_VIEWABLE: 'NOT_VIEWABLE', - ALREADY_JOINED: 'ALREADY_JOINED', -} as const; diff --git a/src/features/core/internal/Voice/internal/funcs/checkJoinable/index.ts b/src/features/core/internal/Voice/internal/funcs/checkJoinable/index.ts new file mode 100644 index 00000000..f5342245 --- /dev/null +++ b/src/features/core/internal/Voice/internal/funcs/checkJoinable/index.ts @@ -0,0 +1,2 @@ +export { checkJoinable } from './internal/checkJoinable.func.js'; +export { JOINABLE_TYPE_MAP } from './internal/checkJoinable.constants.js'; diff --git a/src/features/core/internal/Voice/internal/funcs/checkJoinable/internal/checkJoinable.constants.ts b/src/features/core/internal/Voice/internal/funcs/checkJoinable/internal/checkJoinable.constants.ts new file mode 100644 index 00000000..7ae6264b --- /dev/null +++ b/src/features/core/internal/Voice/internal/funcs/checkJoinable/internal/checkJoinable.constants.ts @@ -0,0 +1,7 @@ +export const JOINABLE_TYPE_MAP = { + JOINABLE: 'JOINABLE', + NOT_JOINABLE: 'NOT_JOINABLE', + NOT_FOUND: 'NOT_FOUND', + NOT_VIEWABLE: 'NOT_VIEWABLE', + ALREADY_JOINED: 'ALREADY_JOINED', +} as const; diff --git a/src/features/core/internal/Voice/internal/funcs/checkJoinable/internal/checkJoinable.func.ts b/src/features/core/internal/Voice/internal/funcs/checkJoinable/internal/checkJoinable.func.ts new file mode 100644 index 00000000..0ddd2b01 --- /dev/null +++ b/src/features/core/internal/Voice/internal/funcs/checkJoinable/internal/checkJoinable.func.ts @@ -0,0 +1,19 @@ +import { isNil, type VoiceBasedChannel } from '@/features/library/index.js'; + +import { JOINABLE_TYPE_MAP } from './checkJoinable.constants.js'; + +export const checkJoinable = ({ + channel, +}: { + channel: VoiceBasedChannel | null; +}): (typeof JOINABLE_TYPE_MAP)[keyof typeof JOINABLE_TYPE_MAP] => { + if (isNil(channel)) { + return JOINABLE_TYPE_MAP.NOT_FOUND; + } else if (!channel.joinable) { + return JOINABLE_TYPE_MAP.NOT_JOINABLE; + } else if (!channel.viewable) { + return JOINABLE_TYPE_MAP.NOT_VIEWABLE; + } else { + return JOINABLE_TYPE_MAP.JOINABLE; + } +}; diff --git a/src/features/core/internal/Voice/internal/funcs/checkJoinable/internal/checkJoinable.spec.ts b/src/features/core/internal/Voice/internal/funcs/checkJoinable/internal/checkJoinable.spec.ts new file mode 100644 index 00000000..46921183 --- /dev/null +++ b/src/features/core/internal/Voice/internal/funcs/checkJoinable/internal/checkJoinable.spec.ts @@ -0,0 +1,35 @@ +import type { VoiceBasedChannel } from '@/features/library/index.js'; + +import { checkJoinable } from './checkJoinable.func.js'; + +describe('checkJoinable', () => { + it('should return NOT_FOUND if the channel is null', () => { + const result = checkJoinable({ channel: null }); + + expect(result).toBe('NOT_FOUND'); + }); + + it('should return NOT_JOINABLE if the channel is not joinable', () => { + const result = checkJoinable({ + channel: { joinable: false } as VoiceBasedChannel, + }); + + expect(result).toBe('NOT_JOINABLE'); + }); + + it('should return NOT_VIEWABLE if the channel is not viewable', () => { + const result = checkJoinable({ + channel: { joinable: true, viewable: false } as VoiceBasedChannel, + }); + + expect(result).toBe('NOT_VIEWABLE'); + }); + + it('should return JOINABLE if the channel is joinable and viewable', () => { + const result = checkJoinable({ + channel: { joinable: true, viewable: true } as VoiceBasedChannel, + }); + + expect(result).toBe('JOINABLE'); + }); +});