diff --git a/frontend/src/assets/img/app-icons/radio.svg b/frontend/src/assets/img/app-icons/radio.svg deleted file mode 100644 index 001fab2..0000000 --- a/frontend/src/assets/img/app-icons/radio.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/frontend/src/assets/img/app-icons/radio.webp b/frontend/src/assets/img/app-icons/radio.webp new file mode 100644 index 0000000..bbce3fa Binary files /dev/null and b/frontend/src/assets/img/app-icons/radio.webp differ diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 4052fd4..9cd0a33 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -47,7 +47,7 @@ import mapIcon from '@/assets/img/app-icons/map.webp' import messagesIcon from '@/assets/img/app-icons/sms.webp' import darkChatIcon from '@/assets/img/app-icons/darkchat.webp' import notesIcon from '@/assets/img/app-icons/notes.webp' -import radioIcon from '@/assets/img/app-icons/radio.svg' +import radioIcon from '@/assets/img/app-icons/radio.webp' import photosIcon from '@/assets/img/app-icons/gallery.webp' import phoneIcon from '@/assets/img/app-icons/phone.webp' import settingsIcon from '@/assets/img/app-icons/settings.svg' diff --git a/frontend/src/stores/radio.test.ts b/frontend/src/stores/radio.test.ts new file mode 100644 index 0000000..8af3358 --- /dev/null +++ b/frontend/src/stores/radio.test.ts @@ -0,0 +1,279 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useRadioStore } from '@/stores/radio' +import type { RadioData } from '@/types/radio' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) + +const mockNuiCall = vi.mocked(nuiCall) +const radioData: RadioData = { + badge: '231', + badgeEnabled: true, + badgeMaxLength: 8, + connected: false, + displayName: 'Unit 21', + displayNameAllowed: true, + displayNameEnabled: true, + displayNameMaxLength: 32, + frequency: 0, + frequencyMax: 999.9, + frequencyMin: 0.1, + frequencyStep: 0.1, + history: [{ primary: 120.5, secondary: 130.7 }], + members: [], + provider: 'yaca', + secondaryFrequency: 0, + secondarySupported: true, + settings: { autoRejoin: false, notifications: true }, + volume: 50, +} + +describe('radio store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('hydrates the complete server-authoritative radio state', async () => { + mockNuiCall.mockResolvedValueOnce({ data: radioData, success: true }) + const radio = useRadioStore() + + await radio.load() + + expect(radio.data).toMatchObject(radioData) + expect(radio.error).toBe('') + expect(mockNuiCall).toHaveBeenCalledWith('radio:get') + }) + + it('sends both selected channels and applies the accepted connection', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { + connected: true, + frequency: 120.5, + secondaryFrequency: 130.7, + }, + success: true, + }) + const radio = useRadioStore() + + expect(await radio.connect(120.5, 130.7)).toBe(true) + expect(radio.data.connected).toBe(true) + expect(mockNuiCall).toHaveBeenCalledWith('radio:connect', { + frequency: 120.5, + secondaryFrequency: 130.7, + }) + }) + + it('keeps the current connection when disconnect is rejected', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'voice_unavailable', + success: false, + }) + const radio = useRadioStore() + radio.data.connected = true + radio.data.frequency = 120.5 + + await radio.disconnect() + + expect(radio.data.connected).toBe(true) + expect(radio.data.frequency).toBe(120.5) + expect(radio.error).toBe('voice_unavailable') + expect(radio.isLoading).toBe(false) + }) + + it('rolls an optimistic setting change back after server rejection', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'invalid_setting', + success: false, + }) + const radio = useRadioStore() + + await radio.saveSetting('autoRejoin', true) + + expect(radio.data.settings.autoRejoin).toBe(false) + expect(radio.error).toBe('invalid_setting') + }) + + it('ignores a stale volume response that arrives after the newest value', async () => { + let resolveFirst!: (value: { + data: { volume: number } + success: true + }) => void + let resolveSecond!: (value: { + data: { volume: number } + success: true + }) => void + mockNuiCall + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve + }), + ) + const radio = useRadioStore() + + const first = radio.setVolume(25) + const second = radio.setVolume(75) + resolveSecond({ data: { volume: 75 }, success: true }) + await second + resolveFirst({ data: { volume: 25 }, success: true }) + await first + + expect(radio.data.volume).toBe(75) + }) + + it('applies canonical profile values accepted by the server', async () => { + mockNuiCall + .mockResolvedValueOnce({ data: { badge: 'A_1' }, success: true }) + .mockResolvedValueOnce({ + data: { displayName: 'Unit Seven' }, + success: true, + }) + const radio = useRadioStore() + + expect(await radio.saveBadge('a_1')).toBe(true) + expect(await radio.saveDisplayName(' Unit Seven ')).toBe(true) + + expect(radio.data.badge).toBe('A_1') + expect(radio.data.displayName).toBe('Unit Seven') + expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'radio:save-badge', { + badge: 'a_1', + }) + expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'radio:save-display-name', { + displayName: ' Unit Seven ', + }) + }) + + it('keeps the authoritative profile value when saving is rejected', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'rate_limited', + success: false, + }) + const radio = useRadioStore() + radio.data.badge = '231' + + expect(await radio.saveBadge('232')).toBe(false) + + expect(radio.data.badge).toBe('231') + expect(radio.error).toBe('rate_limited') + }) + + it('ignores stale badge responses after a newer save', async () => { + let resolveFirst!: (value: { + data: { badge: string } + success: true + }) => void + let resolveSecond!: (value: { + data: { badge: string } + success: true + }) => void + mockNuiCall + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve + }), + ) + const radio = useRadioStore() + + const first = radio.saveBadge('231') + const second = radio.saveBadge('232') + resolveSecond({ data: { badge: '232' }, success: true }) + await second + resolveFirst({ data: { badge: '231' }, success: true }) + await first + + expect(radio.data.badge).toBe('232') + }) + + it('ignores stale display-name responses after a newer save', async () => { + let resolveFirst!: (value: { + data: { displayName: string } + success: true + }) => void + let resolveSecond!: (value: { + data: { displayName: string } + success: true + }) => void + mockNuiCall + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve + }), + ) + const radio = useRadioStore() + + const first = radio.saveDisplayName('Unit One') + const second = radio.saveDisplayName('Unit Two') + resolveSecond({ data: { displayName: 'Unit Two' }, success: true }) + await second + resolveFirst({ data: { displayName: 'Unit One' }, success: true }) + await first + + expect(radio.data.displayName).toBe('Unit Two') + }) + + it('merges setting responses per key without clobbering another toggle', async () => { + let resolveAutoRejoin!: (value: { + data: RadioData['settings'] + success: true + }) => void + let resolveNotifications!: (value: { + data: RadioData['settings'] + success: true + }) => void + mockNuiCall + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveAutoRejoin = resolve + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveNotifications = resolve + }), + ) + const radio = useRadioStore() + + const autoRejoin = radio.saveSetting('autoRejoin', true) + const notifications = radio.saveSetting('notifications', true) + resolveNotifications({ + data: { autoRejoin: false, notifications: true }, + success: true, + }) + await notifications + resolveAutoRejoin({ + data: { autoRejoin: true, notifications: false }, + success: true, + }) + await autoRejoin + + expect(radio.data.settings).toEqual({ + autoRejoin: true, + notifications: true, + }) + }) +}) diff --git a/frontend/src/stores/radio.ts b/frontend/src/stores/radio.ts index 6f1fe3d..b259535 100644 --- a/frontend/src/stores/radio.ts +++ b/frontend/src/stores/radio.ts @@ -30,6 +30,13 @@ export const useRadioStore = defineStore('radio', () => { const data = reactive(structuredClone(defaults)) const error = ref('') const isLoading = ref(false) + const settingRequestIds: Record = { + autoRejoin: 0, + notifications: 0, + } + let badgeRequestId = 0 + let displayNameRequestId = 0 + let volumeRequestId = 0 function apply(next: Partial): void { Object.assign(data, next) @@ -61,10 +68,12 @@ export const useRadioStore = defineStore('radio', () => { } async function disconnect(): Promise { + isLoading.value = true error.value = '' const response = await nuiCall('radio:disconnect') if (!response.success) { error.value = response.error ?? 'request_failed' + isLoading.value = false return } apply({ @@ -73,13 +82,16 @@ export const useRadioStore = defineStore('radio', () => { members: [], secondaryFrequency: 0, }) + isLoading.value = false } async function setVolume(volume: number): Promise { + const requestId = ++volumeRequestId data.volume = volume const response = await nuiCall<{ volume: number }>('radio:set-volume', { volume, }) + if (requestId !== volumeRequestId) return if (response.success && response.data) data.volume = response.data.volume } @@ -87,35 +99,42 @@ export const useRadioStore = defineStore('radio', () => { key: keyof RadioSettings, value: boolean, ): Promise { + const requestId = ++settingRequestIds[key] const previous = data.settings[key] data.settings[key] = value const response = await nuiCall('radio:save-settings', { key, value, }) - if (response.success && response.data) data.settings = response.data - else { + if (requestId !== settingRequestIds[key]) return + if (response.success && response.data) { + data.settings[key] = response.data[key] + } else { data.settings[key] = previous error.value = response.error ?? 'request_failed' } } async function saveBadge(badge: string): Promise { + const requestId = ++badgeRequestId error.value = '' const response = await nuiCall<{ badge: string }>('radio:save-badge', { badge, }) + if (requestId !== badgeRequestId) return true if (response.success && response.data) data.badge = response.data.badge else error.value = response.error ?? 'request_failed' return response.success } async function saveDisplayName(displayName: string): Promise { + const requestId = ++displayNameRequestId error.value = '' const response = await nuiCall<{ displayName: string }>( 'radio:save-display-name', { displayName }, ) + if (requestId !== displayNameRequestId) return true if (response.success && response.data) data.displayName = response.data.displayName else error.value = response.error ?? 'request_failed' diff --git a/frontend/src/views/apps/RadioApp.vue b/frontend/src/views/apps/RadioApp.vue index ea762d9..8658fdb 100644 --- a/frontend/src/views/apps/RadioApp.vue +++ b/frontend/src/views/apps/RadioApp.vue @@ -1,20 +1,4 @@ - - - - {{ feedback }} - - + +