initialize Electron desktop app and remove unused files
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import ArcoVue from '@arco-design/web-vue'
|
||||
|
||||
const routeState = vi.hoisted(() => ({
|
||||
query: { board: 'project-a' } as Record<string, string>,
|
||||
}))
|
||||
|
||||
const routerReplace = vi.hoisted(() => vi.fn())
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
tasks: [] as Array<{ id: string; title: string; status: string; created_at: number; assignee?: string | null }>,
|
||||
stats: { by_status: { todo: 1, done: 0 }, by_assignee: {}, total: 1 } as Record<string, any>,
|
||||
assignees: [] as Array<{ name: string; counts: Record<string, number> | null }>,
|
||||
activeBoards: [] as Array<{ slug: string; name: string; icon?: string; total?: number }>,
|
||||
loading: false,
|
||||
boardsLoading: false,
|
||||
selectedBoard: 'default',
|
||||
boardWarning: null as string | null,
|
||||
capabilities: null as Record<string, any> | null,
|
||||
filterStatus: null as string | null,
|
||||
filterAssignee: null as string | null,
|
||||
}))
|
||||
|
||||
const mockFetchBoards = vi.hoisted(() => vi.fn())
|
||||
const mockFetchCapabilities = vi.hoisted(() => vi.fn())
|
||||
const mockRefreshAll = vi.hoisted(() => vi.fn())
|
||||
const mockFetchTasks = vi.hoisted(() => vi.fn())
|
||||
const mockFetchStats = vi.hoisted(() => vi.fn())
|
||||
const mockSetFilter = vi.hoisted(() => vi.fn())
|
||||
const mockRecoverSelectedBoard = vi.hoisted(() => vi.fn())
|
||||
const mockCreateBoard = vi.hoisted(() => vi.fn())
|
||||
const mockArchiveSelectedBoard = vi.hoisted(() => vi.fn())
|
||||
const mockDispatch = vi.hoisted(() => vi.fn())
|
||||
const mockStartEventStream = vi.hoisted(() => vi.fn())
|
||||
const mockStopEventStream = vi.hoisted(() => vi.fn())
|
||||
const mockFetchProfiles = vi.hoisted(() => vi.fn())
|
||||
const profilesState = vi.hoisted(() => ({
|
||||
profiles: [] as Array<{ name: string; avatar?: Record<string, any> | null }>,
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
useRouter: () => ({ replace: routerReplace }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Arco's imperative Message API needs a live DOM, so mock the adapter composable.
|
||||
vi.mock('@/composables/useMessage', () => ({
|
||||
useMessage: () => ({ warning: vi.fn(), error: vi.fn(), success: vi.fn(), info: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/kanban', () => ({
|
||||
DEFAULT_KANBAN_BOARD: 'default',
|
||||
useKanbanStore: () => ({
|
||||
...storeState,
|
||||
fetchBoards: mockFetchBoards,
|
||||
fetchCapabilities: mockFetchCapabilities,
|
||||
refreshAll: mockRefreshAll,
|
||||
fetchTasks: mockFetchTasks,
|
||||
fetchStats: mockFetchStats,
|
||||
setFilter: mockSetFilter,
|
||||
recoverSelectedBoard: mockRecoverSelectedBoard,
|
||||
createBoard: mockCreateBoard,
|
||||
archiveSelectedBoard: mockArchiveSelectedBoard,
|
||||
dispatch: mockDispatch,
|
||||
startEventStream: mockStartEventStream,
|
||||
stopEventStream: mockStopEventStream,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/profiles', () => ({
|
||||
useProfilesStore: () => ({
|
||||
profiles: profilesState.profiles,
|
||||
fetchProfiles: mockFetchProfiles,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/kanban/KanbanTaskCard.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'KanbanTaskCard',
|
||||
props: { task: { type: Object, required: true }, assigneeAvatar: { type: Object, required: false } },
|
||||
template: '<div class="kanban-task-card-stub" :data-avatar-seed="assigneeAvatar?.seed || null">{{ task.title }}</div>',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/kanban/KanbanTaskDrawer.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'KanbanTaskDrawer',
|
||||
emits: ['updated', 'close'],
|
||||
template: '<button class="drawer-updated" @click="$emit(\'updated\')">drawer</button>',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/kanban/KanbanCreateForm.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'KanbanCreateForm',
|
||||
emits: ['created', 'close'],
|
||||
template: '<button class="form-created" @click="$emit(\'created\')">form</button>',
|
||||
}),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Arco's a-select renders a custom dropdown (no native <select>) and a-modal
|
||||
* teleports to body in jsdom, so both are stubbed under Arco's *internal*
|
||||
* component names — stubs key off those, not the `a-*` template tags, because
|
||||
* Arco is registered globally via `app.use(ArcoVue)`.
|
||||
*/
|
||||
const ASelectStub = defineComponent({
|
||||
name: 'Select',
|
||||
props: {
|
||||
modelValue: { default: null },
|
||||
options: { type: Array, default: () => [] },
|
||||
loading: Boolean,
|
||||
},
|
||||
emits: ['update:modelValue', 'change'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('select', {
|
||||
class: 'arco-select-stub',
|
||||
onChange: (event: Event) => {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
},
|
||||
}, (props.options as Array<{ label: string; value: string }>).map(option =>
|
||||
h('option', { key: option.value, value: option.value }, option.label),
|
||||
))
|
||||
},
|
||||
})
|
||||
|
||||
const AModalStub = defineComponent({
|
||||
name: 'Modal',
|
||||
props: ['visible'],
|
||||
template: '<div v-if="visible" class="a-modal-stub"><slot /><slot name="footer" /></div>',
|
||||
})
|
||||
|
||||
const arcoGlobal = {
|
||||
plugins: [ArcoVue],
|
||||
stubs: { Select: ASelectStub, Modal: AModalStub },
|
||||
}
|
||||
|
||||
/** Toolbar buttons are located by their label, never by DOM order. */
|
||||
function buttonByText(wrapper: ReturnType<typeof mount>, text: string) {
|
||||
const button = wrapper.findAll('button').find(node => node.text().trim() === text)
|
||||
if (!button) throw new Error(`button not found: ${text}`)
|
||||
return button
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a header select by an option value it offers, so the assertion keeps
|
||||
* targeting the intended filter even if the toolbar order changes.
|
||||
*/
|
||||
function selectOffering(wrapper: ReturnType<typeof mount>, value: string) {
|
||||
const select = wrapper
|
||||
.findAll('select.arco-select-stub')
|
||||
.find(node => node.findAll('option').some(option => option.attributes('value') === value))
|
||||
if (!select) throw new Error(`select offering "${value}" not found`)
|
||||
return select
|
||||
}
|
||||
|
||||
/** The board column for a status, matched via the `status-<name>` class the view sets. */
|
||||
function columnFor(wrapper: ReturnType<typeof mount>, status: string) {
|
||||
return wrapper.get(`.arco-collapse-item.status-${status}`)
|
||||
}
|
||||
|
||||
/** Statuses whose collapse panel is currently expanded. */
|
||||
function expandedColumns(wrapper: ReturnType<typeof mount>): string[] {
|
||||
return wrapper
|
||||
.findAll('.arco-collapse-item')
|
||||
.filter(item => item.classes().includes('arco-collapse-item-active'))
|
||||
.map(item => item.classes().find(name => name.startsWith('status-'))?.slice('status-'.length) ?? '')
|
||||
}
|
||||
|
||||
import KanbanView from '@/views/hermes/KanbanView.vue'
|
||||
|
||||
function mountView() {
|
||||
return mount(KanbanView, { global: arcoGlobal })
|
||||
}
|
||||
|
||||
describe('KanbanView', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
routeState.query = { board: 'project-a' }
|
||||
routerReplace.mockResolvedValue(undefined)
|
||||
storeState.tasks = [
|
||||
{ id: 'task-1', title: 'Task one', status: 'todo', created_at: 10 },
|
||||
{ id: 'task-2', title: 'Task two', status: 'done', created_at: 20 },
|
||||
]
|
||||
storeState.stats = {
|
||||
by_status: { triage: 0, todo: 1, ready: 0, running: 0, blocked: 0, done: 1, archived: 0 },
|
||||
by_assignee: {},
|
||||
total: 2,
|
||||
}
|
||||
storeState.assignees = []
|
||||
storeState.activeBoards = [
|
||||
{ slug: 'default', name: 'Default', total: 0 },
|
||||
{ slug: 'project-a', name: 'Project A', total: 2 },
|
||||
]
|
||||
storeState.loading = false
|
||||
storeState.boardsLoading = false
|
||||
storeState.selectedBoard = 'default'
|
||||
storeState.boardWarning = null
|
||||
storeState.capabilities = null
|
||||
storeState.filterStatus = null
|
||||
storeState.filterAssignee = null
|
||||
profilesState.profiles = []
|
||||
mockFetchBoards.mockResolvedValue(undefined)
|
||||
mockFetchCapabilities.mockResolvedValue(undefined)
|
||||
mockRefreshAll.mockResolvedValue(undefined)
|
||||
mockFetchTasks.mockResolvedValue(undefined)
|
||||
mockFetchStats.mockResolvedValue(undefined)
|
||||
mockFetchProfiles.mockResolvedValue(undefined)
|
||||
mockCreateBoard.mockResolvedValue({ slug: 'new-board' })
|
||||
mockArchiveSelectedBoard.mockResolvedValue(undefined)
|
||||
mockRecoverSelectedBoard.mockImplementation((candidate: string) => {
|
||||
storeState.selectedBoard = candidate || 'default'
|
||||
return { board: storeState.selectedBoard, recovered: false }
|
||||
})
|
||||
mockSetFilter.mockImplementation((key: 'status' | 'assignee', value: string | null) => {
|
||||
if (key === 'status') storeState.filterStatus = value
|
||||
else storeState.filterAssignee = value
|
||||
})
|
||||
mockDispatch.mockResolvedValue({ spawned: 1 })
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => 'visible',
|
||||
})
|
||||
})
|
||||
|
||||
it('initializes board from route query and refreshes stats alongside tasks', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockFetchBoards).toHaveBeenCalledOnce()
|
||||
expect(mockFetchCapabilities).toHaveBeenCalledOnce()
|
||||
expect(mockFetchProfiles).toHaveBeenCalledOnce()
|
||||
expect(mockRecoverSelectedBoard).toHaveBeenCalledWith('project-a')
|
||||
expect(mockRefreshAll).toHaveBeenCalledOnce()
|
||||
expect(routerReplace).not.toHaveBeenCalled()
|
||||
// Every board status gets a column, and all of them start expanded.
|
||||
expect(wrapper.findAll('.arco-collapse-item')).toHaveLength(9)
|
||||
expect(expandedColumns(wrapper)).toEqual([
|
||||
'triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived',
|
||||
])
|
||||
|
||||
await wrapper.find('.drawer-updated').trigger('click')
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetchStats).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15000)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockFetchBoards).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetchStats).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('renders board count labels and compact assignee profile labels', async () => {
|
||||
storeState.assignees = [{ name: 'alice', counts: { todo: 2, done: 1 } }]
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('kanban.title: Default · kanban.stats.tasks: 0')
|
||||
expect(wrapper.text()).toContain('kanban.title: Project A · kanban.stats.tasks: 2')
|
||||
// The assignee filter is the select offering the `alice` assignee.
|
||||
const assigneeSelect = selectOffering(wrapper, 'alice')
|
||||
expect(assigneeSelect.text()).toContain('alice')
|
||||
expect(assigneeSelect.text()).not.toContain('default')
|
||||
expect(wrapper.text()).not.toContain('kanban.detail.assignee: alice')
|
||||
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
|
||||
})
|
||||
|
||||
it('passes matching profile avatars to task cards', async () => {
|
||||
storeState.tasks = [{ id: 'task-1', title: 'Task one', status: 'todo', created_at: 10, assignee: 'alice' }]
|
||||
profilesState.profiles = [{ name: 'alice', avatar: { type: 'generated', seed: 'alice-seed' } }]
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.kanban-task-card-stub').attributes('data-avatar-seed')).toBe('alice-seed')
|
||||
})
|
||||
|
||||
it('filters the visible board columns from stats chips', async () => {
|
||||
storeState.filterStatus = 'done'
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
// Only the filtered status keeps a column, and it is the expanded one.
|
||||
expect(wrapper.findAll('.arco-collapse-item')).toHaveLength(1)
|
||||
expect(expandedColumns(wrapper)).toEqual(['done'])
|
||||
expect(columnFor(wrapper, 'done').classes()).toContain('kanban-column')
|
||||
expect(wrapper.text()).toContain('Task two')
|
||||
expect(wrapper.text()).not.toContain('Task one')
|
||||
|
||||
await wrapper.find('.stat-chip.todo').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSetFilter).toHaveBeenCalledWith('status', 'todo')
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1)
|
||||
|
||||
await wrapper.find('.stat-chip.total').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSetFilter).toHaveBeenCalledWith('status', null)
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('creates and archives boards from the board toolbar', async () => {
|
||||
storeState.selectedBoard = 'project-a'
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
await buttonByText(wrapper, 'common.add').trigger('click')
|
||||
await flushPromises()
|
||||
// The create-board modal is keyed by its own placeholder fields, not index.
|
||||
await wrapper.get('input[placeholder="kanban.board.slugPlaceholder"]').setValue('new-board')
|
||||
await wrapper.get('input[placeholder="kanban.board.namePlaceholder"]').setValue('New Board')
|
||||
await buttonByText(wrapper, 'common.create').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCreateBoard).toHaveBeenCalledWith({ slug: 'new-board', name: 'New Board' })
|
||||
expect(routerReplace).toHaveBeenCalledWith({ query: { board: 'new-board' } })
|
||||
|
||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||
await buttonByText(wrapper, 'kanban.board.archive').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockArchiveSelectedBoard).toHaveBeenCalled()
|
||||
expect(routerReplace).toHaveBeenCalledWith({ query: { board: 'default' } })
|
||||
})
|
||||
|
||||
it('makes default board explicit when route query is absent', async () => {
|
||||
routeState.query = {}
|
||||
mockRecoverSelectedBoard.mockImplementation(() => {
|
||||
storeState.selectedBoard = 'default'
|
||||
return { board: 'default', recovered: false }
|
||||
})
|
||||
|
||||
mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(routerReplace).toHaveBeenCalledWith({ query: { board: 'default' } })
|
||||
expect(mockRefreshAll).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user