more tests

This commit is contained in:
Henry Jameson 2026-09-16 20:44:41 +03:00
commit d0b00db195
8 changed files with 384 additions and 103 deletions

View file

@ -25,7 +25,8 @@ export const MASTODON_FOLLOWERS_URL = (
`/api/v1/accounts/${id}/followers${paramsString({ minId, maxId, sinceId, limit, withRelationships })}` `/api/v1/accounts/${id}/followers${paramsString({ minId, maxId, sinceId, limit, withRelationships })}`
export const MASTODON_STATUS_URL = (id) => `/api/v1/statuses/${id}` export const MASTODON_STATUS_URL = (id) => `/api/v1/statuses/${id}`
const MASTODON_STATUS_CONTEXT_URL = (id) => `/api/v1/statuses/${id}/context` export const MASTODON_STATUS_CONTEXT_URL = (id) =>
`/api/v1/statuses/${id}/context`
export const MASTODON_STATUS_SOURCE_URL = (id) => export const MASTODON_STATUS_SOURCE_URL = (id) =>
`/api/v1/statuses/${id}/source` `/api/v1/statuses/${id}/source`
export const MASTODON_STATUS_HISTORY_URL = (id) => export const MASTODON_STATUS_HISTORY_URL = (id) =>

View file

@ -114,16 +114,9 @@ export default {
const conversationLite = computed(() => const conversationLite = computed(() =>
conversation.value.map(({ id }) => ({ id })), conversation.value.map(({ id }) => ({ id })),
) )
provide('focusedId', focusedId)
watch( provide('conversation', conversation)
expanded, provide('replies', replies)
(value) => {
if (value) {
fetchConversation()
}
},
{ flush: 'post' },
)
// Component created // Component created
if (isPage.value) { if (isPage.value) {

View file

@ -1,5 +1,5 @@
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, nextTick, provide, ref, watch } from 'vue' import { computed, nextTick, ref, watch, toValue } from 'vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js' import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js' import { useOAuthStore } from 'src/stores/oauth.js'
@ -52,7 +52,7 @@ export function useConversation(statusId, expanded) {
} }
const fullyLoaded = ref(false) const fullyLoaded = ref(false)
const conversationId = computed( const conversationId = computed(
() => mainStatus.value?.statusnet_conversation_id, () => mainStatus.value?.statusnet_conversation_id ?? null,
) )
watch(conversationId, (neu, old) => { watch(conversationId, (neu, old) => {
if (neu !== old) fullyLoaded.value = false if (neu !== old) fullyLoaded.value = false
@ -62,7 +62,7 @@ export function useConversation(statusId, expanded) {
return [] return []
} }
if (!expanded.value || !fullyLoaded.value) { if (!toValue(expanded) || !fullyLoaded.value) {
return [currentStatus.value] return [currentStatus.value]
} }
@ -124,8 +124,6 @@ export function useConversation(statusId, expanded) {
), ),
) )
const getReplies = (id) => replies.value.get(id) ?? new Set() const getReplies = (id) => replies.value.get(id) ?? new Set()
provide('conversation', conversation)
provide('replies', replies)
const fetchConversation = async () => { const fetchConversation = async () => {
if (currentStatus.value) { if (currentStatus.value) {
@ -133,7 +131,7 @@ export function useConversation(statusId, expanded) {
data: { ancestors, descendants }, data: { ancestors, descendants },
timestamp, timestamp,
} = await apiFetchConversation({ } = await apiFetchConversation({
id: statusId.value, id: toValue(statusId),
credentials: useOAuthStore().token, credentials: useOAuthStore().token,
}) })
@ -150,7 +148,7 @@ export function useConversation(statusId, expanded) {
loadError.value = null loadError.value = null
const { data: status } = await apiFetchStatus({ const { data: status } = await apiFetchStatus({
id: statusId.value, id: toValue(statusId),
credentials: useOAuthStore().token, credentials: useOAuthStore().token,
}) })
@ -164,6 +162,16 @@ export function useConversation(statusId, expanded) {
} }
} }
watch(
expanded,
(value) => {
if (value) {
fetchConversation()
}
},
{ flush: 'post' },
)
// # Focus // # Focus
const focused = ref(null) const focused = ref(null)
const { mainStatus: focusedStatus } = useMainStatus(focused) const { mainStatus: focusedStatus } = useMainStatus(focused)
@ -173,29 +181,28 @@ export function useConversation(statusId, expanded) {
watch(statusId, (val) => setFocused(val), { immediate: true }) watch(statusId, (val) => setFocused(val), { immediate: true })
const focusedId = computed(() => const focusedId = computed(() =>
expanded.value && fullyLoaded.value ? focusedStatus.value?.id : null, (toValue(expanded) && fullyLoaded.value) ? focusedStatus.value?.id : null,
) )
provide('focusedId', focusedId)
watch( watch(
focusedStatus, focusedId,
(newVal, oldVal) => { (newVal, oldVal) => {
if (!newVal) return if (!newVal) return
if (newVal?.id === oldVal?.id) return // prevents infinite loop if (newVal === oldVal) return // prevents infinite loop
if (!streamingEnabled.value) { if (!streamingEnabled.value) {
useStatusesStore().fetchStatus(newVal.id) useStatusesStore().fetchStatus(newVal)
} }
useStatusesStore().fetchFavsAndRepeats(newVal.id) useStatusesStore().fetchFavsAndRepeats(newVal)
useStatusesStore().fetchEmojiReactions(newVal.id) useStatusesStore().fetchEmojiReactions(newVal)
}, },
{ immediate: true }, { immediate: true },
) )
return { return {
focusedId, focusedId,
conversationId,
setFocused, setFocused,
conversationId,
currentStatus, currentStatus,
mainStatus, mainStatus,
conversation, conversation,

View file

@ -6,7 +6,7 @@ export function useMainStatus(statusId) {
const statusesStore = useStatusesStore() const statusesStore = useStatusesStore()
const getStatusObject = (id) => statusesStore.allStatuses.get(id) const getStatusObject = (id) => statusesStore.allStatuses.get(id)
const status = computed(() => getStatusObject(toValue(statusId))) const status = computed(() => getStatusObject(toValue(statusId)) ?? null)
const mainStatus = computed(() => { const mainStatus = computed(() => {
if (!status.value) return null if (!status.value) return null

93
test/fixtures/masto_api.js vendored Normal file
View file

@ -0,0 +1,93 @@
export const userId = '1'
export const userScreenName = 'user'
export const userName = 'Guy'
export const userUrl = 'http://localhost/user'
export const fetchOptions = (url, method = 'GET') => [
url,
{
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
]
export const mockMastoAPIUser = ({
screen_name = userScreenName,
name = userName,
url = userUrl,
id = userId,
} = {}) => ({
id,
acct: screen_name,
display_name: name,
fields: [],
avatar: '',
url,
pleroma: {
emoji_reactions: [],
},
})
export const mockMastoAPIStatus = ({
id = '1',
text,
type = 'status',
statusUser = mockMastoAPIUser(),
in_reply_to_status_id = null,
statusnet_conversation_id = 'c1',
} = {}) => ({
id,
account: statusUser,
name: 'status',
content: text ?? `Text number ${id}`,
uri: '',
type,
attentions: [],
pleroma: {
conversation_id: statusnet_conversation_id,
},
in_reply_to_id: in_reply_to_status_id,
})
export const mockUser = ({
screen_name = userScreenName,
id = userId,
name = userName,
url = userUrl,
} = {}) => ({
_original: mockMastoAPIUser({
screen_name,
id,
name,
url,
}),
id,
name,
screen_name,
url,
relationship: undefined,
})
export const mockStatus = ({
id = '1',
text,
type = 'status',
statusUser = mockUser(),
in_reply_to_status_id = null,
statusnet_conversation_id = 'c1',
} = {}) => ({
id,
user: statusUser,
name: 'status',
text: text ?? `Text number ${id}`,
uri: '',
type,
attentions: [],
statusnet_conversation_id,
emoji_reactions: [],
in_reply_to_status_id,
})

View file

@ -0,0 +1,251 @@
import { createTestingPinia } from '@pinia/testing'
import { flushPromises, mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { ref } from 'vue'
import {
mockMastoAPIStatus,
mockMastoAPIUser,
mockStatus,
mockUser,
userId,
userName,
userScreenName,
userUrl,
} from 'test/fixtures/masto_api.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useConversation } from 'src/composables/useConversation.js'
import {
MASTODON_STATUS_CONTEXT_URL,
MASTODON_STATUS_URL,
MASTODON_STATUS_FAVORITEDBY_URL,
MASTODON_STATUS_REBLOGGEDBY_URL,
PLEROMA_EMOJI_REACTIONS_URL,
} from 'src/api/public.js'
describe('useConversation', () => {
const constructStatus = (index, convoId = '1000') => {
const stringId = index.toString()
const object = { id: stringId, statusnet_conversation_id: convoId }
if (index > 0) {
object.in_reply_to_status_id = (index - 1).toString()
}
return mockStatus(object)
}
const constructStatusAPI = (index, convoId = '1000') => {
const stringId = index.toString()
const object = { id: stringId, statusnet_conversation_id: convoId }
if (index > 0) {
object.in_reply_to_status_id = (index - 1).toString()
}
return mockMastoAPIStatus(object)
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
useStatusesStore().resetStatuses()
})
afterEach(() => {
vi.useRealTimers()
vi.resetAllMocks()
})
it('should work if status is unknown yet', () => {
useStatusesStore().allStatuses = new Map()
const result = useConversation(ref('1'), ref(false))
expect(result.focusedId.value).to.eql(null)
expect(result.currentStatus.value).to.eql(null)
expect(result.mainStatus.value).to.eql(null)
expect(result.replies.value).to.eql(new Map())
expect(result.conversationId.value).to.eql(null)
expect(result.conversation.value).to.eql([])
expect(result.loadError.value).to.eql(null)
})
it('should return single item that is already known when not expanded', () => {
useStatusesStore().allStatuses = new Map(
[...new Array(20)].map((i, index) => [index.toString(), constructStatus(index)])
)
const result = useConversation(ref('1'), ref(false))
const expectedStatus = constructStatus(1)
expect(result.focusedId.value).to.eql(null)
expect(result.currentStatus.value).to.eql(expectedStatus)
expect(result.mainStatus.value).to.eql(expectedStatus)
expect(result.conversation.value).to.eql([expectedStatus])
expect(result.conversationId.value).to.eql('1000')
})
it('should return entire conversation when fethed', async () => {
const convo = [...new Array(20)].map((i, index) => constructStatus(index))
const convoAPI = [...new Array(20)].map((i, index) => constructStatusAPI(index))
const mockFetch = vi.fn()
vi.when(mockFetch, { onUnmatched: 'throw' })
.calledWith(MASTODON_STATUS_URL('4'), expect.anything())
.thenResolveOnce(new Response(JSON.stringify(convoAPI[4]), {
headers: { 'Content-Type': 'application/json' },
}))
.calledWith(MASTODON_STATUS_CONTEXT_URL('4'), expect.anything())
.thenResolveOnce(new Response(JSON.stringify({
ancestors: convoAPI.slice(0,4),
descendants: convoAPI.slice(5),
}), {
headers: { 'Content-Type': 'application/json' },
}))
vi.stubGlobal('fetch', mockFetch)
const result = useConversation(ref('4'), ref(true))
await result.fetchConversation()
expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_URL('4'), expect.anything())
expect(result.conversation.value).to.have.length(1)
expect(result.conversation.value[0]).to.have.property('id', '4')
expect(result.conversationId.value).to.have.eql('1000')
await vi.advanceTimersToNextTimerAsync()
expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_CONTEXT_URL('4'), expect.anything())
expect(result.conversation.value).to.have.length(20)
expect(result.conversation.value[0]).to.have.property('id', '0')
})
it('should fetch entire conversation when expanded', async () => {
const convo = [...new Array(20)].map((i, index) => constructStatus(index))
const convoAPI = [...new Array(20)].map((i, index) => constructStatusAPI(index))
useStatusesStore().addNewStatuses({ statuses: [convo[4]], timestamp: Date.now() })
const mockFetch = vi.fn()
vi.when(mockFetch)
.calledWith(MASTODON_STATUS_CONTEXT_URL('4'), expect.anything())
.thenResolveOnce(new Response(JSON.stringify({
ancestors: convoAPI.slice(0,4),
descendants: convoAPI.slice(5),
}), {
headers: { 'Content-Type': 'application/json' },
}))
vi.stubGlobal('fetch', mockFetch)
const expanded = ref(false)
const result = useConversation(ref('4'), expanded)
expect(result.conversation.value).to.have.length(1)
expect(result.conversation.value[0]).to.have.property('id', '4')
expect(result.focusedId.value).to.eql(null)
expanded.value = true
await vi.advanceTimersToNextTimerAsync()
expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_CONTEXT_URL('4'), expect.anything())
await vi.advanceTimersToNextTimerAsync()
expect(result.focusedId.value).to.eql('4')
expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_FAVORITEDBY_URL('4'), expect.anything())
expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_REBLOGGEDBY_URL('4'), expect.anything())
expect(mockFetch).to.have.been.calledWith(PLEROMA_EMOJI_REACTIONS_URL('4'), expect.anything())
await vi.advanceTimersToNextTimerAsync()
expect(result.conversation.value).to.have.length(20)
expect(result.conversation.value[0]).to.have.property('id', '0')
})
it('should reset and fetch another conversation when statusId changes', async () => {
const aConvo = [...new Array(20)].map((i, index) => constructStatus(index + 'a', '1000'))
const aConvoAPI = [...new Array(20)].map((i, index) => constructStatusAPI(index + 'a', '1000'))
const bConvo = [...new Array(20)].map((i, index) => constructStatus(index + 'b', '2000'))
const bConvoAPI = [...new Array(20)].map((i, index) => constructStatusAPI(index + 'b', '2000'))
const mockFetch = vi.fn()
vi.when(mockFetch, { onUnmatched: 'throw' })
.calledWith(MASTODON_STATUS_URL('4a'), expect.anything())
.thenResolveOnce(new Response(JSON.stringify(aConvoAPI[4]), {
headers: { 'Content-Type': 'application/json' },
}))
.calledWith(MASTODON_STATUS_CONTEXT_URL('4a'), expect.anything())
.thenResolveOnce(new Response(JSON.stringify({
ancestors: aConvoAPI.slice(0,4),
descendants: aConvoAPI.slice(5),
}), {
headers: { 'Content-Type': 'application/json' },
}))
.calledWith(MASTODON_STATUS_URL('4b'), expect.anything())
.thenResolveOnce(new Response(JSON.stringify(bConvoAPI[4]), {
headers: { 'Content-Type': 'application/json' },
}))
.calledWith(MASTODON_STATUS_CONTEXT_URL('4b'), expect.anything())
.thenResolveOnce(new Response(JSON.stringify({
ancestors: bConvoAPI.slice(0,4),
descendants: bConvoAPI.slice(5),
}), {
headers: { 'Content-Type': 'application/json' },
}))
vi.stubGlobal('fetch', mockFetch)
const statusId = ref('4a')
const result = useConversation(statusId, true)
await result.fetchConversation()
expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_URL('4a'), expect.anything())
expect(result.conversation.value).to.have.length(1)
expect(result.conversation.value[0]).to.have.property('id', '4a')
expect(result.conversationId.value).to.have.eql('1000')
await vi.advanceTimersToNextTimerAsync()
expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_CONTEXT_URL('4a'), expect.anything())
expect(result.conversation.value).to.have.length(20)
expect(result.conversation.value[0]).to.have.property('id', '0a')
statusId.value = '4b'
await vi.advanceTimersToNextTimerAsync()
expect(result.conversation.value).to.have.length(0)
await vi.advanceTimersToNextTimerAsync()
expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_URL('4b'), expect.anything())
expect(result.conversation.value).to.have.length(1)
expect(result.conversation.value[0]).to.have.property('id', '4b')
expect(result.conversationId.value).to.have.eql('2000')
await vi.advanceTimersToNextTimerAsync()
expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_CONTEXT_URL('4b'), expect.anything())
expect(result.conversation.value).to.have.length(20)
expect(result.conversation.value[0]).to.have.property('id', '0b')
})
it('should form replies object', async () => {
const convo = [...new Array(4)].map((i, index) => constructStatus(index))
const convoAPI = [...new Array(4)].map((i, index) => constructStatusAPI(index))
useStatusesStore().addNewStatuses({ statuses: convo, timestamp: Date.now() })
const mockFetch = vi.fn()
vi.when(mockFetch)
.calledWith(MASTODON_STATUS_CONTEXT_URL('2'), expect.anything())
.thenResolveOnce(new Response(JSON.stringify({
ancestors: convoAPI.slice(0,1),
descendants: convoAPI.slice(2),
}), {
headers: { 'Content-Type': 'application/json' },
}))
vi.stubGlobal('fetch', mockFetch)
const result = useConversation('2', true)
await result.fetchConversation()
await vi.advanceTimersToNextTimerAsync()
await vi.advanceTimersToNextTimerAsync()
await vi.advanceTimersToNextTimerAsync()
expect(result.conversation.value).to.have.length(4)
expect(result.replies.value).to.eql(new Map([
['0', new Set([{ name: '#1', id: '1' }])],
['1', new Set([{ name: '#2', id: '2' }])],
['2', new Set([{ name: '#3', id: '3' }])],
]))
})
})

View file

@ -10,7 +10,7 @@ describe('useMainStatus', () => {
setActivePinia(createTestingPinia()) setActivePinia(createTestingPinia())
useStatusesStore().allStatuses = new Map([ useStatusesStore().allStatuses = new Map([
[1, { id: 1 }], [1, { id: 1 }],
[2, { id: 2, retweeted_status: { id: 1 }}], [2, { id: 2, retweeted_status: { id: 1 } }],
]) ])
}) })
@ -24,7 +24,7 @@ describe('useMainStatus', () => {
it('repeat', () => { it('repeat', () => {
const { status, mainStatus } = useMainStatus(2) const { status, mainStatus } = useMainStatus(2)
expect(status.value).to.eql({ id: 2, retweeted_status: { id: 1 }}) expect(status.value).to.eql({ id: 2, retweeted_status: { id: 1 } })
expect(mainStatus.value).to.eql({ id: 1 }) expect(mainStatus.value).to.eql({ id: 1 })
}) })
}) })

View file

@ -1,6 +1,16 @@
import { createTestingPinia } from '@pinia/testing' import { createTestingPinia } from '@pinia/testing'
import { snakeCase } from 'lodash-es' import { snakeCase } from 'lodash-es'
import { setActivePinia } from 'pinia' import { setActivePinia } from 'pinia'
import {
mockMastoAPIStatus,
mockMastoAPIUser,
mockStatus,
mockUser,
userId,
userName,
userScreenName,
userUrl,
} from 'test/fixtures/masto_api.js'
import { useStatusesStore } from 'src/stores/statuses.js' import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js' import { useStreamingStore } from 'src/stores/streaming.js'
@ -9,80 +19,6 @@ import { useUsersStore } from 'src/stores/users.js'
import * as PUBLIC_API from 'src/api/public.js' import * as PUBLIC_API from 'src/api/public.js'
import * as USER_API from 'src/api/user.js' import * as USER_API from 'src/api/user.js'
const userId = '1'
const userScreenName = 'user'
const userName = 'Guy'
const userUrl = 'http://localhost/user'
const mockMastoAPIUser = ({
screen_name = userScreenName,
name = userName,
url = userUrl,
id = userId,
} = {}) => ({
id,
acct: screen_name,
display_name: name,
fields: [],
avatar: '',
url,
pleroma: {
emoji_reactions: [],
},
})
const mockUser = ({
screen_name = userScreenName,
id = userId,
name = userName,
url = userUrl,
} = {}) => ({
_original: mockMastoAPIUser({
screen_name,
id,
name,
url,
}),
id,
name,
screen_name,
url,
relationship: undefined,
})
const mockStatus = ({
id = '1',
text,
type = 'status',
statusUser = mockUser(),
} = {}) => ({
id,
user: statusUser,
name: 'status',
text: text ?? `Text number ${id}`,
uri: '',
type,
attentions: [],
statusnet_conversation_id: 'c1',
emoji_reactions: [],
})
const mockMastoAPIStatus = ({
id = '1',
text,
type = 'status',
statusUser = mockMastoAPIUser(),
} = {}) => ({
id,
account: statusUser,
name: 'status',
content: text ?? `Text number ${id}`,
uri: '',
type,
attentions: [],
statusnet_conversation_id: 'c1',
})
const DEFAULT_OPTIONS = (method = 'GET') => ({ const DEFAULT_OPTIONS = (method = 'GET') => ({
method, method,
credentials: 'same-origin', credentials: 'same-origin',