feat: unify app ordering and simplify app settings access
This commit is contained in:
@@ -44,6 +44,50 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ApplicationSettingsStore', () => {
|
||||
it.each([
|
||||
{ order: ['local-inference', 'magic-notes'], expected: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] },
|
||||
{ order: ['local-inference'], expected: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] },
|
||||
{ order: ['magic-notes', 'knowledge'], expected: ['heartbeat', 'magic-notes', 'knowledge', 'local-inference'] },
|
||||
{ order: [], expected: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference'] },
|
||||
{ order: undefined, expected: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference'] },
|
||||
{ order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'], expected: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] },
|
||||
])('normalizes stored order $order without changing existing relative order', async ({ order, expected }) => {
|
||||
const { filePath, store } = await createStore()
|
||||
await writeFile(filePath, JSON.stringify({
|
||||
...defaultApplicationSettings, version: 12, lastSeenReleaseNotesVersion: null,
|
||||
magicNotesEnabled: false, applicationNavigation: { order, pinned: { 'magic-notes': false } },
|
||||
}))
|
||||
const settings = await store.get()
|
||||
expect(settings.warnings).toBeUndefined()
|
||||
expect(settings.magicNotesEnabled).toBe(false)
|
||||
expect(settings.applicationNavigation).toEqual({ order: expected, pinned: { 'magic-notes': false, 'local-inference': false } })
|
||||
expect(applicationSettingsSchema.parse(settings)).toEqual(settings)
|
||||
await store.update({ checkUpdatesOnStartup: false })
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8')).applicationNavigation).toEqual(settings.applicationNavigation)
|
||||
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(settings.applicationNavigation)
|
||||
})
|
||||
|
||||
it('validates full-order writes, persists and publishes them, and rejects partial writes without mutation', async () => {
|
||||
const { store, filePath } = await createStore()
|
||||
const applicationNavigation = {
|
||||
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'],
|
||||
pinned: { 'magic-notes': false, 'local-inference': false },
|
||||
}
|
||||
const changed = vi.fn()
|
||||
store.onChanged(changed)
|
||||
const saved = await store.update({ applicationNavigation })
|
||||
expect(applicationSettingsSchema.parse(saved).applicationNavigation).toEqual(applicationNavigation)
|
||||
expect(changed).toHaveBeenCalledExactlyOnceWith(saved)
|
||||
const contents = await readFile(filePath, 'utf8')
|
||||
for (const order of [[], ['local-inference', 'magic-notes'], ['knowledge', 'knowledge', 'magic-notes', 'local-inference'], ['knowledge', 'heartbeat', 'magic-notes', 'invalid']]) {
|
||||
expect(applicationSettingsSchema.safeParse({ ...saved, applicationNavigation: { ...applicationNavigation, order } }).success).toBe(false)
|
||||
await expect(store.update({ applicationNavigation: { ...applicationNavigation, order } })).rejects.toThrow()
|
||||
}
|
||||
expect(await readFile(filePath, 'utf8')).toBe(contents)
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(applicationNavigation)
|
||||
})
|
||||
|
||||
it('keeps the confirmed snapshot when an atomic save fails', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const confirmed = await store.get()
|
||||
@@ -55,14 +99,27 @@ describe('ApplicationSettingsStore', () => {
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults missing released Notes fields to enabled without resetting unrelated values', async () => {
|
||||
it.each([11, 12])('defaults missing navigation and Notes fields in version %s without resetting unrelated values', async (version) => {
|
||||
const { filePath, store } = await createStore()
|
||||
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
|
||||
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
|
||||
for (const key of ['applicationNavigation', 'localInferenceEnabled', 'magicNotesEnabled', 'magicNotesShowIncompleteTodoCount']) delete legacy[key]
|
||||
await writeFile(filePath, JSON.stringify(legacy), 'utf8')
|
||||
expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false })
|
||||
expect(await store.get()).toMatchObject({ localInferenceEnabled: true, applicationNavigation: defaultApplicationSettings.applicationNavigation })
|
||||
expect(await createApplicationSettingsStore(filePath).get()).toEqual(await store.get())
|
||||
})
|
||||
it.each([true, false])('preserves a saved local inference pin of %s through migration, updates and reload', async (pinned) => {
|
||||
const { filePath, store } = await createStore()
|
||||
const applicationNavigation = {
|
||||
order: ['local-inference', 'magic-notes'],
|
||||
pinned: { 'magic-notes': false, 'local-inference': pinned }
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, applicationNavigation, version: 11, lastSeenReleaseNotesVersion: null }), 'utf8')
|
||||
const normalized = { ...applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] }
|
||||
expect((await store.get()).applicationNavigation).toEqual(normalized)
|
||||
await store.update({ checkUpdatesOnStartup: false })
|
||||
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized)
|
||||
})
|
||||
it('migrates released settings without replacing explicit false values or comment choices', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' }
|
||||
@@ -82,8 +139,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
const navigation = defaultApplicationSettings.applicationNavigation
|
||||
for (const invalid of [
|
||||
{ ...navigation, order: navigation.order.slice(1) },
|
||||
{ ...navigation, order: ['magic-notes', 'magic-notes'] },
|
||||
{ ...navigation, order: ['unknown', 'local-inference'] },
|
||||
{ ...navigation, order: ['knowledge', 'heartbeat', 'magic-notes', 'magic-notes'] },
|
||||
{ ...navigation, order: ['knowledge', 'heartbeat', 'unknown', 'local-inference'] },
|
||||
{ ...navigation, order: ['magic-notes', 'local-inference'] },
|
||||
{ ...navigation, order: ['knowledge', 'local-inference'] },
|
||||
{ ...navigation, order: ['heartbeat', 'local-inference'] },
|
||||
{ ...navigation, pinned: { ...navigation.pinned, heartbeat: true } },
|
||||
@@ -97,6 +155,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
const { directory, store } = await createStore()
|
||||
|
||||
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
|
||||
expect(await store.get()).toMatchObject({ localInferenceEnabled: true, applicationNavigation: defaultApplicationSettings.applicationNavigation })
|
||||
await expect(readdir(directory)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { z } from 'zod'
|
||||
import {
|
||||
applicationSettingsSchema,
|
||||
applicationSettingsUpdateSchema,
|
||||
applicationNavigationSchema,
|
||||
builtInApplicationIds,
|
||||
defaultApplicationNavigation,
|
||||
type ApplicationSettings
|
||||
} from '../shared/application-settings-contracts'
|
||||
@@ -144,6 +146,20 @@ const versionTenStoredApplicationSettingsSchema =
|
||||
|
||||
const storedApplicationSettingsSchema = applicationSettingsSchema
|
||||
.extend({
|
||||
// Stored partial orders are upgraded here; public settings writes remain strict.
|
||||
applicationNavigation: applicationNavigationSchema.extend({
|
||||
order: z.array(z.enum(builtInApplicationIds))
|
||||
.refine(ids => new Set(ids).size === ids.length, 'Duplicate application')
|
||||
.default([])
|
||||
.transform(ids => [
|
||||
...(['knowledge', 'heartbeat'] as const).filter(id => !ids.includes(id)),
|
||||
...ids,
|
||||
...defaultApplicationNavigation.order.filter(id => id !== 'knowledge' && id !== 'heartbeat' && !ids.includes(id))
|
||||
]),
|
||||
pinned: applicationNavigationSchema.shape.pinned.partial()
|
||||
.default({})
|
||||
.transform(pinned => ({ ...defaultApplicationNavigation.pinned, ...pinned }))
|
||||
}).default(defaultApplicationNavigation),
|
||||
version: z.literal(CURRENT_SETTINGS_VERSION),
|
||||
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
|
||||
})
|
||||
|
||||
@@ -13267,7 +13267,38 @@ describe("App", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('refreshes externally changed navigation before editing and keeps Knowledge in its fixed slot', async () => {
|
||||
it('shares card reorder saves with the launcher and filtered sidebar across reopening', async () => {
|
||||
const updates = api.updates!
|
||||
await updates.updateSettings({ magicNotesEnabled: true })
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: '魔法笔记' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
const move = screen.getByRole('button', { name: '下移 知识库' })
|
||||
await waitFor(() => expect(move).toBeEnabled())
|
||||
fireEvent.click(move)
|
||||
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
|
||||
'智能心跳', '知识库', '魔法笔记', '本机推理监控',
|
||||
]))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled())
|
||||
const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article')
|
||||
fireEvent.dragStart(cards[3]!)
|
||||
fireEvent.drop(cards[1]!)
|
||||
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
|
||||
'智能心跳', '本机推理监控', '知识库', '魔法笔记',
|
||||
]))
|
||||
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes'])
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
|
||||
expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([
|
||||
'对话', '智能心跳', '知识库', '魔法笔记', '运行记录',
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['智能心跳', '本机推理监控', '知识库', '魔法笔记', '管理应用'])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['智能心跳', '本机推理监控', '知识库', '魔法笔记'])
|
||||
})
|
||||
|
||||
it('refreshes externally changed all-app order before editing and filters sidebar pins', async () => {
|
||||
const updates = api.updates!
|
||||
const original = await updates.getSettings()
|
||||
render(<App />)
|
||||
@@ -13276,24 +13307,25 @@ describe("App", () => {
|
||||
...original,
|
||||
applicationNavigation: {
|
||||
...original.applicationNavigation,
|
||||
order: ['local-inference', 'magic-notes'] as const,
|
||||
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] as const,
|
||||
pinned: { ...original.applicationNavigation.pinned, 'local-inference': true },
|
||||
},
|
||||
}
|
||||
vi.mocked(updates.getSettings).mockResolvedValue({ ...external, applicationNavigation: { ...external.applicationNavigation, order: [...external.applicationNavigation.order] } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
|
||||
expect(pin).toBeDisabled()
|
||||
await waitFor(() => expect(pin).not.toBeDisabled())
|
||||
const nav = screen.getByRole('navigation', { name: '主导航' })
|
||||
expect(within(nav).getAllByRole('button').map(button => button.textContent)).toEqual([
|
||||
'对话', '知识库', '智能心跳', '本机推理', '运行记录',
|
||||
'对话', '本机推理监控', '智能心跳', '知识库', '运行记录',
|
||||
])
|
||||
fireEvent.click(pin)
|
||||
await waitFor(() => expect(updates.updateSettings).toHaveBeenCalledWith({
|
||||
applicationNavigation: {
|
||||
order: ['local-inference', 'magic-notes'],
|
||||
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'],
|
||||
pinned: { ...original.applicationNavigation.pinned, 'local-inference': false },
|
||||
},
|
||||
}))
|
||||
@@ -13301,6 +13333,7 @@ describe("App", () => {
|
||||
|
||||
it.each(['read', 'mutation'] as const)('keeps a settings event authoritative over a stale %s reply', async (operation) => {
|
||||
const updates = api.updates!
|
||||
await updates.updateSettings({ applicationNavigation: { ...defaultApplicationNavigation, pinned: { ...defaultApplicationNavigation.pinned, 'local-inference': true } } })
|
||||
const original = await updates.getSettings()
|
||||
let changed!: Parameters<typeof updates.onSettingsChanged>[0]
|
||||
vi.mocked(updates.onSettingsChanged).mockImplementation(listener => {
|
||||
@@ -13313,7 +13346,7 @@ describe("App", () => {
|
||||
if (operation === 'read') vi.mocked(updates.getSettings).mockReturnValueOnce(reply.promise)
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
|
||||
if (operation === 'mutation') {
|
||||
await waitFor(() => expect(pin).not.toBeDisabled())
|
||||
@@ -13323,7 +13356,7 @@ describe("App", () => {
|
||||
const external: ApplicationSettings = {
|
||||
...original,
|
||||
applicationNavigation: {
|
||||
order: ['local-inference', 'magic-notes'],
|
||||
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'],
|
||||
pinned: { ...original.applicationNavigation.pinned, 'local-inference': false },
|
||||
},
|
||||
}
|
||||
@@ -13332,7 +13365,7 @@ describe("App", () => {
|
||||
await act(async () => reply.resolve(original))
|
||||
await waitFor(() => expect(pin).not.toBeDisabled())
|
||||
expect(pin).not.toBeChecked()
|
||||
expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理' })).not.toBeInTheDocument()
|
||||
expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理监控' })).not.toBeInTheDocument()
|
||||
expect(within(screen.getByRole('navigation', { name: '主导航' })).getByRole('button', { name: '智能心跳' })).toBeInTheDocument()
|
||||
fireEvent.click(pin)
|
||||
expect(updates.updateSettings).toHaveBeenLastCalledWith({
|
||||
@@ -13340,6 +13373,14 @@ describe("App", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Magic Notes free of a generic application settings shortcut', async () => {
|
||||
await api.updates!.updateSettings({ magicNotesEnabled: true })
|
||||
render(<App />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '魔法笔记' }))
|
||||
await waitFor(() => expect(document.querySelector('.magic-notes-page')).toBeInTheDocument())
|
||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps fixed apps available when application settings cannot be loaded', async () => {
|
||||
vi.mocked(api.updates!.getSettings).mockRejectedValue(new Error('unavailable'))
|
||||
render(<App />)
|
||||
@@ -13353,8 +13394,11 @@ describe("App", () => {
|
||||
})
|
||||
|
||||
it.each(['sidebar', 'menu', 'center'])('opens local inference from %s as a modal preserving the workspace', async (entry) => {
|
||||
if (entry === 'sidebar') await api.updates!.updateSettings({ applicationNavigation: { ...defaultApplicationNavigation, pinned: { ...defaultApplicationNavigation.pinned, 'local-inference': true } } })
|
||||
render(<App />)
|
||||
const nav = await screen.findByRole('button', { name: '本机推理' })
|
||||
await waitFor(() => expect(api.updates!.getSettings).toHaveBeenCalled())
|
||||
const nav = entry === 'sidebar' ? await screen.findByRole('button', { name: '本机推理监控' }) : screen.getByRole('button', { name: '应用中心' })
|
||||
if (entry !== 'sidebar') expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual(['对话', '知识库', '智能心跳', '运行记录'])
|
||||
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
|
||||
const workspace = await screen.findByLabelText('知识工作区')
|
||||
if (entry === 'sidebar') {
|
||||
@@ -13362,19 +13406,19 @@ describe("App", () => {
|
||||
fireEvent.click(nav)
|
||||
} else {
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
if (entry === 'menu') fireEvent.click(screen.getByRole('menuitem', { name: '本机推理' }))
|
||||
if (entry === 'menu') fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' }))
|
||||
else {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
const item = screen.getByRole('button', { name: '本机推理 应用设置' }).closest('article')!
|
||||
const item = screen.getByRole('button', { name: '本机推理监控 应用设置' }).closest('article')!
|
||||
fireEvent.click(within(item).getByRole('button', { name: '打开' }))
|
||||
}
|
||||
}
|
||||
const dialog = screen.getByRole('dialog', { name: '本机推理' })
|
||||
const dialog = screen.getByRole('dialog', { name: '本机推理监控' })
|
||||
expect(workspace).toBeInTheDocument()
|
||||
expect(workspace.closest('.app-shell')).toHaveProperty('inert', true)
|
||||
expect(nav).not.toHaveAttribute('aria-current', 'page')
|
||||
expect(dialog.closest('main')).toBeNull()
|
||||
const close = within(dialog).getByRole('button', { name: '关闭本机推理' })
|
||||
const close = within(dialog).getByRole('button', { name: '关闭本机推理监控' })
|
||||
expect(close).toHaveFocus()
|
||||
fireEvent.click(close)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
@@ -13384,16 +13428,17 @@ describe("App", () => {
|
||||
})
|
||||
|
||||
it('opens an unpinned app and recovers a disabled current page without remounting it', async () => {
|
||||
await api.updates!.updateSettings({ applicationNavigation: { ...defaultApplicationNavigation, pinned: { ...defaultApplicationNavigation.pinned, 'local-inference': true } } })
|
||||
let changed!: Parameters<NonNullable<typeof api.updates>['onSettingsChanged']>[0]
|
||||
vi.mocked(api.updates!.onSettingsChanged).mockImplementation(listener => {
|
||||
changed = listener
|
||||
return vi.fn()
|
||||
})
|
||||
render(<App />)
|
||||
const inferenceNav = await screen.findByRole('button', { name: '本机推理' })
|
||||
const inferenceNav = await screen.findByRole('button', { name: '本机推理监控' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
|
||||
await waitFor(() => expect(pin).not.toBeDisabled())
|
||||
fireEvent.click(pin)
|
||||
@@ -13403,21 +13448,30 @@ describe("App", () => {
|
||||
expect(trigger).toHaveFocus()
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' }))
|
||||
const workspace = await screen.findByRole('region', { name: '本机推理服务' })
|
||||
expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理' })).not.toBeInTheDocument()
|
||||
expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理监控' })).not.toBeInTheDocument()
|
||||
await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: false })))
|
||||
expect(screen.getByText('应用已关闭')).toBeInTheDocument()
|
||||
expect(workspace).toBeInTheDocument()
|
||||
expect(workspace.closest('.local-inference-workspace')).toHaveAttribute('hidden')
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用设置' }))
|
||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||
await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: true })))
|
||||
expect(screen.getByRole('region', { name: '本机推理服务' })).toBe(workspace)
|
||||
expect(workspace.closest('.local-inference-workspace')).not.toHaveAttribute('hidden')
|
||||
expect((await api.updates!.getSettings()).applicationNavigation.pinned['local-inference']).toBe(false)
|
||||
await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: false })))
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭本机推理监控' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeDisabled())
|
||||
fireEvent.click(screen.getByRole('switch', { name: '启用应用' }))
|
||||
await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).toBeChecked())
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
|
||||
expect(screen.getByRole('region', { name: '本机推理服务' })).toBe(workspace)
|
||||
expect(workspace.closest('.local-inference-workspace')).not.toHaveAttribute('hidden')
|
||||
expect((await api.updates!.getSettings()).applicationNavigation.pinned['local-inference']).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' }))
|
||||
expect(await screen.findByRole('region', { name: '本机推理服务' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps confirmed application preferences and locks edits until an unknown save is read back', async () => {
|
||||
@@ -13426,14 +13480,14 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
const updates = api.updates!
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
await waitFor(() => expect(screen.getByRole('switch', { name: '常驻左侧菜单' })).not.toBeDisabled())
|
||||
vi.mocked(updates.updateSettings).mockRejectedValueOnce(new Error('response lost'))
|
||||
vi.mocked(updates.getSettings).mockRejectedValueOnce(new Error('read unavailable'))
|
||||
fireEvent.click(screen.getByRole('switch', { name: '常驻左侧菜单' }))
|
||||
await screen.findByText('无法确认保存结果,请重新读取后再修改。')
|
||||
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
|
||||
expect(pin).toBeChecked()
|
||||
expect(pin).not.toBeChecked()
|
||||
expect(pin).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新读取' }))
|
||||
await waitFor(() => expect(pin).not.toBeDisabled())
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ApplicationMenu } from './ApplicationMenu';
|
||||
import {
|
||||
ApplicationAvailability,
|
||||
ApplicationCenter,
|
||||
ApplicationSettingsNavigation,
|
||||
applicationDefinitions,
|
||||
isApplicationEnabled,
|
||||
} from "./ApplicationCenter";
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
defaultApplicationNavigation,
|
||||
type ApplicationSettings,
|
||||
type ApplicationSettingsUpdate,
|
||||
type EditableApplicationId,
|
||||
} from "../../shared/application-settings-contracts";
|
||||
import type { ImageOperation } from "../../shared/image-generation-contracts";
|
||||
import {
|
||||
@@ -2421,7 +2419,6 @@ function App(): React.JSX.Element {
|
||||
const [applicationCenterOpen, setApplicationCenterOpen] = useState(false);
|
||||
const [applicationMenuOpen, setApplicationMenuOpen] = useState(false);
|
||||
const applicationMenuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [applicationSettingsTarget, setApplicationSettingsTarget] = useState<EditableApplicationId>();
|
||||
const [applicationSettings, setApplicationSettings] = useState<ApplicationSettings>();
|
||||
const [applicationSettingsPending, setApplicationSettingsPending] = useState(false);
|
||||
const [applicationSettingsUnconfirmed, setApplicationSettingsUnconfirmed] = useState(false);
|
||||
@@ -2603,7 +2600,7 @@ function App(): React.JSX.Element {
|
||||
] = useState(true);
|
||||
const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = useState(0);
|
||||
const applicationNavigation = applicationSettings?.applicationNavigation ?? defaultApplicationNavigation;
|
||||
const visibleApplications = useMemo(() => ['knowledge' as const, 'heartbeat' as const, ...applicationNavigation.order].filter(id =>
|
||||
const visibleApplications = useMemo(() => applicationNavigation.order.filter(id =>
|
||||
id === 'knowledge' || id === 'heartbeat' || (applicationNavigation.pinned[id] && (id === 'magic-notes' ? magicNotesEnabled : isApplicationEnabled(applicationSettings, id)))
|
||||
), [applicationNavigation, applicationSettings, magicNotesEnabled]);
|
||||
const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => {
|
||||
@@ -2661,19 +2658,6 @@ function App(): React.JSX.Element {
|
||||
setApplicationSettingsPending(--applicationSettingsPendingRef.current > 0);
|
||||
}
|
||||
}, [applicationSettingsUnconfirmed, applyApplicationSettings, t]);
|
||||
const openApplicationSettings = useCallback((id: EditableApplicationId): void => {
|
||||
const open = (): void => {
|
||||
setApplicationSettingsTarget(id);
|
||||
setApplicationMenuOpen(false);
|
||||
setApplicationCenterOpen(true);
|
||||
void reloadApplicationSettings();
|
||||
};
|
||||
if (settingsOpenRef.current) {
|
||||
const leave = (): void => { commitView(viewRef.current); requestAnimationFrame(open); };
|
||||
if (settingsLeaveRequesterRef.current) settingsLeaveRequesterRef.current(leave);
|
||||
else leave();
|
||||
} else open();
|
||||
}, [commitView, reloadApplicationSettings]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
@@ -8821,7 +8805,6 @@ function App(): React.JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<ApplicationSettingsNavigation value={openApplicationSettings}>
|
||||
<div className="app-shell">
|
||||
<aside
|
||||
aria-label={
|
||||
@@ -10948,7 +10931,7 @@ function App(): React.JSX.Element {
|
||||
active={view === "magic-notes"}
|
||||
route="magic-notes"
|
||||
>
|
||||
<ApplicationAvailability id="magic-notes" enabled={magicNotesEnabled}>
|
||||
<ApplicationAvailability enabled={magicNotesEnabled}>
|
||||
<PageShell variant="master-detail">
|
||||
<RouteErrorBoundary
|
||||
key="magic-notes"
|
||||
@@ -11375,7 +11358,6 @@ function App(): React.JSX.Element {
|
||||
pending={applicationSettingsPending}
|
||||
locked={applicationSettingsUnconfirmed}
|
||||
error={applicationSettingsError}
|
||||
initialApplication={applicationSettingsTarget}
|
||||
onClose={() => setApplicationCenterOpen(false)}
|
||||
onOpen={(id) => {
|
||||
if (!isApplicationEnabled(applicationSettings, id)) return;
|
||||
@@ -11399,7 +11381,6 @@ function App(): React.JSX.Element {
|
||||
if (window.innerWidth < 900) setSidebarOpen(false);
|
||||
}}
|
||||
onManage={() => {
|
||||
setApplicationSettingsTarget(undefined);
|
||||
setApplicationCenterOpen(true);
|
||||
}}
|
||||
onRetry={() => void reloadApplicationSettings()}
|
||||
@@ -11716,7 +11697,6 @@ function App(): React.JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ApplicationSettingsNavigation>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import {
|
||||
ApplicationAvailability,
|
||||
ApplicationCenter,
|
||||
ApplicationSettingsNavigation,
|
||||
ApplicationSettingsView,
|
||||
} from './ApplicationCenter'
|
||||
|
||||
@@ -34,6 +33,13 @@ const props = () => ({
|
||||
onUpdate: vi.fn(async () => true),
|
||||
onRetry: vi.fn(),
|
||||
})
|
||||
const pinnedSettings = {
|
||||
...settings,
|
||||
applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
pinned: { 'magic-notes': true, 'local-inference': true },
|
||||
},
|
||||
}
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Application Center', () => {
|
||||
@@ -54,19 +60,25 @@ describe('Application Center', () => {
|
||||
expect(dialog).not.toHaveClass('application-center--detail')
|
||||
})
|
||||
|
||||
it.each([['知识库', 'knowledge'], ['智能心跳', 'heartbeat']])('keeps %s fixed with Open and no settings, pin, or order controls', (title, id) => {
|
||||
it.each([['知识库', 'knowledge'], ['智能心跳', 'heartbeat']])('keeps %s always shown with sorting but no settings or toggles', (title, id) => {
|
||||
const handlers = props()
|
||||
render(<ApplicationCenter {...handlers} />)
|
||||
const card = screen.getByText(title, { exact: true }).closest('article')!
|
||||
expect(card).toHaveAttribute('draggable', 'false')
|
||||
expect(card).toHaveAttribute('draggable', 'true')
|
||||
expect(within(card).getByText('始终显示')).toBeInTheDocument()
|
||||
expect(within(card).queryByRole('switch')).not.toBeInTheDocument()
|
||||
expect(within(card).getAllByRole('button')).toHaveLength(1)
|
||||
expect(within(card).getAllByRole('button')).toHaveLength(3)
|
||||
fireEvent.click(within(card).getByRole('button', { name: '打开' }))
|
||||
expect(handlers.onOpen).toHaveBeenCalledWith(id)
|
||||
const notes = screen.getByText('魔法笔记', { exact: true }).closest('article')!
|
||||
fireEvent.dragStart(notes)
|
||||
fireEvent.drop(card)
|
||||
expect(handlers.onUpdate).not.toHaveBeenCalled()
|
||||
expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
order: id === 'knowledge'
|
||||
? ['magic-notes', 'knowledge', 'heartbeat', 'local-inference']
|
||||
: ['knowledge', 'magic-notes', 'heartbeat', 'local-inference'],
|
||||
} })
|
||||
})
|
||||
it('searches disabled and unpinned applications, opens without pinning, and restores focus', () => {
|
||||
const handlers = props()
|
||||
@@ -100,7 +112,7 @@ describe('Application Center', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开' }),
|
||||
).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeChecked()
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
expect(handlers.onClose).toHaveBeenCalledOnce()
|
||||
@@ -109,60 +121,97 @@ describe('Application Center', () => {
|
||||
trigger.remove()
|
||||
})
|
||||
|
||||
it('saves complete order and independent pin preferences without optimistic changes', () => {
|
||||
it('sorts disabled and unpinned apps and saves pin preferences without optimistic changes', () => {
|
||||
const handlers = props()
|
||||
render(<ApplicationCenter {...handlers} />)
|
||||
render(<ApplicationCenter {...handlers} settings={{ ...settings, localInferenceEnabled: false }} />)
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
|
||||
const inference = screen.getByText('本机推理监控', { exact: true }).closest('article')!
|
||||
expect(inference).toHaveAttribute('draggable', 'true')
|
||||
expect(within(inference).getAllByRole('button', { name: /上移|下移/ })).toHaveLength(2)
|
||||
expect(screen.getByRole('button', { name: '上移 魔法笔记' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '下移 魔法笔记' })).toBeEnabled()
|
||||
const notes = screen.getByText('魔法笔记', { exact: true }).closest('article')!
|
||||
fireEvent.dragStart(inference)
|
||||
fireEvent.drop(notes)
|
||||
fireEvent.dragStart(notes)
|
||||
fireEvent.drop(inference)
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: {
|
||||
...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
|
||||
} })
|
||||
fireEvent.click(within(inference).getByRole('button', { name: '打开' }))
|
||||
expect(handlers.onOpen).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeChecked()
|
||||
expect(screen.getByRole('switch', { name: '常驻左侧菜单' })).not.toBeChecked()
|
||||
fireEvent.click(screen.getByRole('switch', { name: '常驻左侧菜单' }))
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
|
||||
applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
pinned: { ...defaultApplicationNavigation.pinned, 'local-inference': false },
|
||||
pinned: { ...defaultApplicationNavigation.pinned, 'local-inference': true },
|
||||
},
|
||||
})
|
||||
expect(
|
||||
screen.getByRole('switch', { name: '常驻左侧菜单' }),
|
||||
).toBeChecked()
|
||||
fireEvent.click(screen.getByRole('button', { name: '上移 本机推理' }))
|
||||
).not.toBeChecked()
|
||||
expect(screen.queryByRole('button', { name: /上移|下移/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('orders all apps on cards, disables full-order boundaries, and waits for confirmed settings', async () => {
|
||||
const handlers = props()
|
||||
const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
|
||||
expect(screen.getByRole('button', { name: '上移 知识库' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled()
|
||||
const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' })
|
||||
expect(moveUp.closest('article')).not.toBeNull()
|
||||
expect(moveUp).toHaveAttribute('title', '上移 本机推理监控')
|
||||
fireEvent.click(moveUp)
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
|
||||
applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
order: ['local-inference', 'magic-notes'],
|
||||
...pinnedSettings.applicationNavigation,
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
|
||||
},
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '下移 本机推理' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '魔法笔记 应用设置' }))
|
||||
expect(screen.getByRole('button', { name: '上移 魔法笔记' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled()
|
||||
expect(await screen.findByRole('status')).not.toBeEmptyDOMElement()
|
||||
rerender(<ApplicationCenter {...handlers} settings={{
|
||||
...pinnedSettings,
|
||||
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] },
|
||||
}} />)
|
||||
expect(screen.getByRole('button', { name: '下移 魔法笔记' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' }))
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation })
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
expect(screen.queryByRole('button', { name: /上移|下移/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the same order patch for drag and keyboard controls', () => {
|
||||
const handlers = props()
|
||||
render(<ApplicationCenter {...handlers} />)
|
||||
render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
|
||||
const notes = screen
|
||||
.getByText('魔法笔记', { exact: true })
|
||||
.closest('article')!
|
||||
const inference = screen
|
||||
.getByText('本机推理', { exact: true })
|
||||
.getByText('本机推理监控', { exact: true })
|
||||
.closest('article')!
|
||||
fireEvent.dragStart(inference)
|
||||
fireEvent.dragOver(notes)
|
||||
fireEvent.drop(notes)
|
||||
expect(handlers.onUpdate).toHaveBeenCalledWith({
|
||||
applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
order: ['local-inference', 'magic-notes'],
|
||||
...pinnedSettings.applicationNavigation,
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('locks all preference controls while saving or awaiting a confirmed read', () => {
|
||||
const handlers = props()
|
||||
const { rerender } = render(<ApplicationCenter {...handlers} pending initialApplication="local-inference" />)
|
||||
const { rerender } = render(<ApplicationCenter {...handlers} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
rerender(<ApplicationCenter {...handlers} pending />)
|
||||
for (const toggle of screen.getAllByRole('switch'))
|
||||
expect(toggle).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '上移 本机推理' })).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: /上移|下移/ })).not.toBeInTheDocument()
|
||||
rerender(
|
||||
<ApplicationCenter {...handlers} locked error="无法确认保存结果" />,
|
||||
)
|
||||
@@ -173,6 +222,34 @@ describe('Application Center', () => {
|
||||
expect(handlers.onRetry).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['pending', 'locked'] as const)('disables card sorting when %s', (state) => {
|
||||
const handlers = props()
|
||||
render(<ApplicationCenter {...handlers} settings={pinnedSettings} {...{ [state]: true }} />)
|
||||
for (const button of screen.getAllByRole('button', { name: /上移|下移/ }))
|
||||
expect(button).toBeDisabled()
|
||||
const cards = screen.getAllByRole('article')
|
||||
for (const card of cards) expect(card).toHaveAttribute('draggable', 'false')
|
||||
fireEvent.dragStart(cards[3]!)
|
||||
fireEvent.drop(cards[2]!)
|
||||
expect(handlers.onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps hidden entries in the full order during search while disabling drag', () => {
|
||||
const handlers = props()
|
||||
render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '推理' } })
|
||||
const card = screen.getByRole('article')
|
||||
expect(card).toHaveAttribute('draggable', 'false')
|
||||
expect(within(card).getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled()
|
||||
fireEvent.click(within(card).getByRole('button', { name: '上移 本机推理监控' }))
|
||||
expect(handlers.onUpdate).toHaveBeenCalledWith({
|
||||
applicationNavigation: {
|
||||
...pinnedSettings.applicationNavigation,
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('provides a recoverable search empty state and no market or system tools', () => {
|
||||
render(<ApplicationCenter {...props()} />)
|
||||
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
|
||||
@@ -212,24 +289,21 @@ describe('Application Center', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps disabled page drafts mounted with a recovery entry', () => {
|
||||
const open = vi.fn()
|
||||
it('keeps disabled page drafts mounted without a generic settings shortcut', () => {
|
||||
const page = (enabled: boolean) => (
|
||||
<ApplicationSettingsNavigation value={open}>
|
||||
<ApplicationAvailability id="local-inference" enabled={enabled}>
|
||||
<input aria-label="draft" defaultValue="original" />
|
||||
</ApplicationAvailability>
|
||||
</ApplicationSettingsNavigation>
|
||||
<ApplicationAvailability enabled={enabled}>
|
||||
<input aria-label="draft" defaultValue="original" />
|
||||
</ApplicationAvailability>
|
||||
)
|
||||
const { rerender, container } = render(page(true))
|
||||
const { rerender } = render(page(true))
|
||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||
const draft = screen.getByRole('textbox', { name: 'draft' })
|
||||
fireEvent.change(draft, { target: { value: 'keep this draft' } })
|
||||
rerender(page(false))
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(draft).toBeInTheDocument()
|
||||
expect(screen.getByText('应用已关闭')).toBeInTheDocument()
|
||||
fireEvent.click(within(container).getByRole('button', { name: '应用设置' }))
|
||||
expect(open).toHaveBeenCalledWith('local-inference')
|
||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||
rerender(page(true))
|
||||
expect(screen.getByRole('textbox')).toHaveValue('keep this draft')
|
||||
})
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -63,41 +61,19 @@ export function isApplicationEnabled(
|
||||
)
|
||||
}
|
||||
|
||||
export const ApplicationSettingsNavigation = createContext<
|
||||
((id: EditableApplicationId) => void) | undefined
|
||||
>(undefined)
|
||||
|
||||
export function ApplicationSettingsLink({
|
||||
id,
|
||||
}: {
|
||||
id: EditableApplicationId
|
||||
}): React.JSX.Element | null {
|
||||
const open = useContext(ApplicationSettingsNavigation)
|
||||
const { t } = useTranslation('app')
|
||||
return open ? (
|
||||
<button className="secondary-button" type="button" onClick={() => open(id)}>
|
||||
<Settings size={15} aria-hidden="true" />
|
||||
{t('applications.settings')}
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
|
||||
export function ApplicationAvailability({
|
||||
id,
|
||||
enabled,
|
||||
children,
|
||||
}: {
|
||||
id: EditableApplicationId
|
||||
enabled: boolean
|
||||
children: ReactNode
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
return (
|
||||
<div className="application-page">
|
||||
<div className="application-page-controls">
|
||||
{!enabled && <strong>{t('applications.disabledPage')}</strong>}
|
||||
<ApplicationSettingsLink id={id} />
|
||||
</div>
|
||||
{!enabled && <div className="application-page-controls">
|
||||
<strong>{t('applications.disabledPage')}</strong>
|
||||
</div>}
|
||||
<div
|
||||
className="application-page-content"
|
||||
hidden={!enabled}
|
||||
@@ -267,7 +243,6 @@ export function ApplicationCenter({
|
||||
pending,
|
||||
locked = false,
|
||||
error,
|
||||
initialApplication,
|
||||
onClose,
|
||||
onOpen,
|
||||
onUpdate,
|
||||
@@ -277,7 +252,6 @@ export function ApplicationCenter({
|
||||
pending: boolean
|
||||
locked?: boolean
|
||||
error?: string
|
||||
initialApplication?: EditableApplicationId
|
||||
onClose: () => void
|
||||
onOpen: (id: BuiltInApplicationId) => void
|
||||
onUpdate: (patch: ApplicationSettingsUpdate) => Promise<boolean>
|
||||
@@ -285,8 +259,8 @@ export function ApplicationCenter({
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const [query, setQuery] = useState('')
|
||||
const [selected, setSelected] = useState(initialApplication)
|
||||
const [dragged, setDragged] = useState<EditableApplicationId>()
|
||||
const [selected, setSelected] = useState<EditableApplicationId>()
|
||||
const [dragged, setDragged] = useState<BuiltInApplicationId>()
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const dialog = useRef<HTMLElement>(null)
|
||||
const body = useRef<HTMLDivElement>(null)
|
||||
@@ -305,7 +279,7 @@ export function ApplicationCenter({
|
||||
const editingDisabled = pending || locked
|
||||
const results = useMemo(
|
||||
() =>
|
||||
['knowledge' as const, 'heartbeat' as const, ...navigation.order].filter((id) =>
|
||||
navigation.order.filter((id) =>
|
||||
`${t(applicationDefinitions[id].title)} ${t(`applications.descriptions.${id}`)}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(query.trim().toLocaleLowerCase()),
|
||||
@@ -313,8 +287,8 @@ export function ApplicationCenter({
|
||||
[navigation.order, query, t],
|
||||
)
|
||||
const move = async (
|
||||
from: EditableApplicationId,
|
||||
to: EditableApplicationId,
|
||||
from: BuiltInApplicationId,
|
||||
to: BuiltInApplicationId,
|
||||
): Promise<void> => {
|
||||
if (editingDisabled || from === to) return
|
||||
const order = [...navigation.order]
|
||||
@@ -396,35 +370,12 @@ export function ApplicationCenter({
|
||||
{!settings ? (
|
||||
<p role="status">{t('route.loading')}</p>
|
||||
) : selected ? (
|
||||
<>
|
||||
<ApplicationSettingsView
|
||||
id={selected}
|
||||
settings={settings}
|
||||
pending={editingDisabled}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
<div className="application-center__order">
|
||||
{([-1, 1] as const).map((offset) => {
|
||||
const target =
|
||||
navigation.order[navigation.order.indexOf(selected) + offset]
|
||||
const Icon = offset === -1 ? ArrowUp : ArrowDown
|
||||
return (
|
||||
<button
|
||||
key={offset}
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
disabled={editingDisabled || !target}
|
||||
onClick={() => { if (target) void move(selected, target) }}
|
||||
>
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
{t(offset === -1 ? 'applications.moveUp' : 'applications.moveDown', {
|
||||
name: t(applicationDefinitions[selected].title),
|
||||
})}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
<ApplicationSettingsView
|
||||
id={selected}
|
||||
settings={settings}
|
||||
pending={editingDisabled}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<label className="application-center__search">
|
||||
@@ -452,20 +403,20 @@ export function ApplicationCenter({
|
||||
const definition = applicationDefinitions[id]
|
||||
const Icon = definition.icon
|
||||
const enabled = isApplicationEnabled(settings, id)
|
||||
const fixed = id === 'knowledge' || id === 'heartbeat'
|
||||
const alwaysShown = id === 'knowledge' || id === 'heartbeat'
|
||||
return (
|
||||
<article
|
||||
className="application-center__item"
|
||||
key={id}
|
||||
draggable={!fixed && !editingDisabled && !query}
|
||||
onDragStart={() => { if (!fixed) setDragged(id) }}
|
||||
draggable={!editingDisabled && !query}
|
||||
onDragStart={() => { if (!editingDisabled && !query) setDragged(id) }}
|
||||
onDragEnd={() => setDragged(undefined)}
|
||||
onDragOver={(event) => {
|
||||
if (!fixed && dragged && !editingDisabled) event.preventDefault()
|
||||
if (dragged && !editingDisabled && !query) event.preventDefault()
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
if (!fixed && dragged) void move(dragged, id)
|
||||
if (dragged && !query) void move(dragged, id)
|
||||
setDragged(undefined)
|
||||
}}
|
||||
>
|
||||
@@ -475,8 +426,8 @@ export function ApplicationCenter({
|
||||
<div className="application-center__title">
|
||||
<strong>{t(definition.title)}</strong>
|
||||
<small>
|
||||
{fixed
|
||||
? t('applications.fixed')
|
||||
{alwaysShown
|
||||
? t('applications.alwaysShown')
|
||||
: `${t('applications.optional')} · ${t(enabled ? 'applications.enabled' : 'applications.disabled')}`}
|
||||
</small>
|
||||
</div>
|
||||
@@ -492,7 +443,7 @@ export function ApplicationCenter({
|
||||
>
|
||||
{t('applications.open')}
|
||||
</button>
|
||||
{!fixed && (
|
||||
{!alwaysShown && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
@@ -504,6 +455,28 @@ export function ApplicationCenter({
|
||||
{t('applications.settings')}
|
||||
</button>
|
||||
)}
|
||||
<div className="application-center__order">
|
||||
{([-1, 1] as const).map((offset) => {
|
||||
const target = navigation.order[navigation.order.indexOf(id) + offset]
|
||||
const MoveIcon = offset === -1 ? ArrowUp : ArrowDown
|
||||
const label = t(offset === -1 ? 'applications.moveUp' : 'applications.moveDown', {
|
||||
name: t(definition.title),
|
||||
})
|
||||
return (
|
||||
<button
|
||||
key={offset}
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={editingDisabled || !target}
|
||||
onClick={() => { if (target) void move(id, target) }}
|
||||
>
|
||||
<MoveIcon size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -22,17 +22,17 @@ function Harness({ value = settings, pending = false, error, onOpen = vi.fn(), o
|
||||
}
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
it('lists enabled apps regardless of pins and keeps Knowledge and Heartbeat fixed before filtering', () => {
|
||||
it('filters the shared order by enablement only, including reordered always-shown apps', () => {
|
||||
const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true,
|
||||
applicationNavigation: { order: ['local-inference', 'magic-notes'] as ApplicationSettings['applicationNavigation']['order'],
|
||||
applicationNavigation: { order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] as ApplicationSettings['applicationNavigation']['order'],
|
||||
pinned: { 'magic-notes': false, 'local-inference': false } } }
|
||||
const onOpen = vi.fn()
|
||||
const { rerender } = render(<Harness value={value} onOpen={onOpen} />)
|
||||
fireEvent.click(screen.getByText('Launcher'))
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['知识库', '智能心跳', '本机推理', '魔法笔记', '管理应用'])
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['本机推理监控', '智能心跳', '魔法笔记', '知识库', '管理应用'])
|
||||
rerender(<Harness value={{ ...value, magicNotesEnabled: false }} onOpen={onOpen} />)
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['知识库', '智能心跳', '本机推理', '管理应用'])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理' }))
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['本机推理监控', '智能心跳', '知识库', '管理应用'])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' }))
|
||||
expect(onOpen).toHaveBeenCalledWith('local-inference')
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ApplicationMenu({ anchorRef, settings, pending, error, onClose,
|
||||
const { t } = useTranslation('app')
|
||||
const applications = useMemo(() => {
|
||||
const order = (settings?.applicationNavigation ?? defaultApplicationNavigation).order
|
||||
return ['knowledge' as const, 'heartbeat' as const, ...order]
|
||||
return order
|
||||
.filter(id => isApplicationEnabled(settings, id))
|
||||
}, [settings])
|
||||
return <AnchoredMenu anchorRef={anchorRef} id="application-menu" label={t('applications.title')} onClose={onClose}>
|
||||
|
||||
@@ -12,12 +12,13 @@ const snapshot: LocalInferenceSnapshot = {
|
||||
services: [{ id: 'embedding', name: '向量生成', engine: 'Granite', ownership: 'managed-process', state: 'running', detail: '共享执行服务', actions: ['stop', 'restart'] }],
|
||||
tasks: [{ id: 'real-task', serviceId: 'embedding', source: '知识索引', state: 'running', startedAt: 1 }]
|
||||
}
|
||||
const api = { getSnapshot: vi.fn(), act: vi.fn(), cancel: vi.fn() }
|
||||
const api = { getSnapshot: vi.fn(), act: vi.fn(), cancel: vi.fn(), openSettings: vi.fn() }
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
api.getSnapshot.mockResolvedValue(snapshot)
|
||||
api.act.mockResolvedValue(undefined)
|
||||
api.cancel.mockResolvedValue(undefined)
|
||||
api.openSettings.mockResolvedValue(undefined)
|
||||
vi.stubGlobal('goodbuddy', { localInference: api })
|
||||
})
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
@@ -51,6 +52,9 @@ describe('LocalInferencePage', () => {
|
||||
const toolbar = screen.getByText('每 5 秒刷新 · CPU 100% = 一个逻辑核心').parentElement!
|
||||
expect(toolbar).toHaveClass('local-inference-toolbar')
|
||||
expect(within(toolbar).getByRole('button', { name: '刷新状态' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '模型与连接设置' }))
|
||||
expect(api.openSettings).toHaveBeenCalledOnce()
|
||||
expect(styles).toMatch(/\.local-inference-toolbar\s*\{[^}]*flex: 0 0 auto/)
|
||||
expect(screen.getByRole('region', { name: '本机推理服务' })).toBeInTheDocument()
|
||||
})
|
||||
@@ -132,12 +136,12 @@ describe('LocalInferencePage', () => {
|
||||
it('isolates nested Escape, traps focus, and restores the service action', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<LocalInferencePage onClose={onClose} />)
|
||||
expect(screen.getByRole('button', { name: '关闭本机推理' })).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: '关闭本机推理监控' })).toHaveFocus()
|
||||
await screen.findByRole('heading', { name: '向量生成' })
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: '关闭本机推理' }), { key: 'Tab', shiftKey: true })
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: '关闭本机推理监控' }), { key: 'Tab', shiftKey: true })
|
||||
expect(screen.getByRole('button', { name: '重启服务' })).toHaveFocus()
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: '重启服务' }), { key: 'Tab' })
|
||||
expect(screen.getByRole('button', { name: '关闭本机推理' })).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: '关闭本机推理监控' })).toHaveFocus()
|
||||
const stop = screen.getByRole('button', { name: '停止服务' })
|
||||
stop.focus()
|
||||
fireEvent.click(stop)
|
||||
@@ -169,12 +173,12 @@ describe('LocalInferencePage', () => {
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.keyDown(confirm, { key: 'Escape' })
|
||||
expect(screen.getByRole('button', { name: '保留运行状态' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '关闭本机推理' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '关闭本机推理监控' })).toBeDisabled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(api.act).toHaveBeenCalledOnce()
|
||||
resolve()
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: '停止服务:向量生成' })).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: '关闭本机推理' })).not.toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '关闭本机推理监控' })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('restores fallback focus when the entry was removed and clears background isolation', () => {
|
||||
@@ -213,12 +217,12 @@ describe('LocalInferencePage', () => {
|
||||
expect(release).toHaveBeenCalledOnce()
|
||||
expect(api.act).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('dialog', { name: '本机推理' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog', { name: '本机推理监控' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps keyboard focus in the modal when the application content is disabled', () => {
|
||||
render(<LocalInferencePage onClose={vi.fn()} enabled={false} />)
|
||||
const close = screen.getByRole('button', { name: '关闭本机推理' })
|
||||
const close = screen.getByRole('button', { name: '关闭本机推理监控' })
|
||||
fireEvent.keyDown(close, { key: 'Tab', shiftKey: true })
|
||||
expect(close).toHaveFocus()
|
||||
fireEvent.keyDown(close, { key: 'Tab' })
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { InferenceAction, InferenceService, InferenceTask, LocalInferenceSn
|
||||
import { PageHeader } from './WorkspacePrimitives'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { getOcrInferenceState, loadOcrInference, releaseOcrInference } from './document-ocr-bridge'
|
||||
import { ApplicationSettingsLink } from './ApplicationCenter'
|
||||
import './local-inference.css'
|
||||
|
||||
const stateLabels: Record<string, string> = {
|
||||
@@ -112,7 +111,7 @@ export default function LocalInferencePage({ onClose, enabled = true, restoreFoc
|
||||
else setControl(selected)
|
||||
}
|
||||
|
||||
return createPortal(<div className="custom-task-dialog"><section className="custom-task-dialog__surface local-inference-modal" role="dialog" aria-modal="true" aria-label="本机推理" tabIndex={-1} onKeyDown={(event) => {
|
||||
return createPortal(<div className="custom-task-dialog"><section className="custom-task-dialog__surface local-inference-modal" role="dialog" aria-modal="true" aria-label="本机推理监控" tabIndex={-1} onKeyDown={(event) => {
|
||||
if (event.target instanceof Element && event.target.closest('[aria-modal="true"]') !== event.currentTarget) return
|
||||
trapTabFocus(event, event.currentTarget)
|
||||
if (event.key === 'Escape') {
|
||||
@@ -122,11 +121,11 @@ export default function LocalInferencePage({ onClose, enabled = true, restoreFoc
|
||||
}
|
||||
}}>
|
||||
<div className="custom-task-dialog__header">
|
||||
<PageHeader headingId="local-inference-title" title="本机推理" scope={{ kind: 'global' }}
|
||||
actions={<button ref={close} type="button" className="icon-button" aria-label="关闭本机推理" title="关闭本机推理" disabled={busy || Boolean(control)} onClick={() => { if (!operation.current && !control) onClose() }}><X size={20} aria-hidden="true" /></button>} />
|
||||
<PageHeader headingId="local-inference-title" title="本机推理监控" scope={{ kind: 'global' }}
|
||||
actions={<button ref={close} type="button" className="icon-button" aria-label="关闭本机推理监控" title="关闭本机推理监控" disabled={busy || Boolean(control)} onClick={() => { if (!operation.current && !control) onClose() }}><X size={20} aria-hidden="true" /></button>} />
|
||||
</div>
|
||||
<div className="local-inference-modal__body">
|
||||
{!enabled && <div className="local-inference-actions"><strong>应用已关闭</strong><ApplicationSettingsLink id="local-inference" /></div>}
|
||||
{!enabled && <div className="local-inference-actions"><strong>应用已关闭</strong></div>}
|
||||
<div className="local-inference-workspace" hidden={!enabled} inert={!enabled}>
|
||||
<div className="local-inference-toolbar">
|
||||
<p className="local-inference-note">每 5 秒刷新 · CPU 100% = 一个逻辑核心</p>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
}
|
||||
.application-center__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.application-center__summary {
|
||||
@@ -108,11 +108,9 @@
|
||||
}
|
||||
.application-center__order {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-1);
|
||||
margin-left: auto;
|
||||
}
|
||||
.application-settings {
|
||||
display: grid;
|
||||
@@ -156,9 +154,6 @@
|
||||
.application-center__body {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.application-center__grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.application-settings .platform-feature-option {
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { app as chineseApp } from '../zh-CN/app'
|
||||
|
||||
export const app = {
|
||||
applications: {
|
||||
fixed: 'Fixed entry', optional: 'Optional',
|
||||
alwaysShown: 'Always shown', optional: 'Optional',
|
||||
manage: 'Manage Apps',
|
||||
title: 'Application Center', description: 'Open built-in applications and manage optional apps, pins, and order. Knowledge and Smart Heartbeat have fixed sidebar entries.',
|
||||
localInference: 'Local Inference', settings: 'Application settings', enable: 'Enable application', pin: 'Pin to sidebar',
|
||||
title: 'Application Center', description: 'Open and reorder built-in applications, and manage optional apps and pins. Knowledge and Smart Heartbeat are always shown and can be moved.',
|
||||
localInference: 'Local Inference Monitor', settings: 'Application settings', enable: 'Enable application', pin: 'Pin to sidebar',
|
||||
enableHelp: 'Pinning only controls the sidebar shortcut. Disabling preserves data and pins without stopping existing requests or background services.',
|
||||
disabledPage: 'Application is disabled', close: 'Close application center', retry: 'Reload settings', back: 'Back to application center',
|
||||
search: 'Search built-in applications', noResults: 'No matching applications', clear: 'Clear search', builtin: 'Built-in',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const app = {
|
||||
applications: {
|
||||
fixed: '固定入口', optional: '可选',
|
||||
alwaysShown: '始终显示', optional: '可选',
|
||||
manage: '管理应用',
|
||||
title: '应用中心', description: '打开内置应用,管理可选应用的启用、常驻和顺序。知识库和智能心跳固定显示在左侧菜单。',
|
||||
localInference: '本机推理', settings: '应用设置', enable: '启用应用', pin: '常驻左侧菜单',
|
||||
title: '应用中心', description: '打开和排列内置应用,管理可选应用的启用与常驻。知识库和智能心跳始终显示,位置可调整。',
|
||||
localInference: '本机推理监控', settings: '应用设置', enable: '启用应用', pin: '常驻左侧菜单',
|
||||
enableHelp: '常驻只控制左侧捷径;关闭应用保留数据和常驻偏好,不停止已有请求或后台服务。',
|
||||
disabledPage: '应用已关闭', close: '关闭应用中心', retry: '重新读取', back: '返回应用中心',
|
||||
search: '搜索内置应用', noResults: '未找到相关应用', clear: '清除搜索', builtin: '内置',
|
||||
|
||||
@@ -35,12 +35,12 @@ export type BuiltInApplicationId = typeof builtInApplicationIds[number]
|
||||
export const editableApplicationIds = ['magic-notes', 'local-inference'] as const
|
||||
export type EditableApplicationId = typeof editableApplicationIds[number]
|
||||
export const defaultApplicationNavigation = {
|
||||
order: [...editableApplicationIds],
|
||||
pinned: { 'magic-notes': true, 'local-inference': true }
|
||||
order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference'] as BuiltInApplicationId[],
|
||||
pinned: { 'magic-notes': true, 'local-inference': false }
|
||||
}
|
||||
export const applicationNavigationSchema = z.object({
|
||||
order: z.array(z.enum(editableApplicationIds)).length(editableApplicationIds.length)
|
||||
.refine(ids => new Set(ids).size === editableApplicationIds.length, 'Each application must occur exactly once'),
|
||||
order: z.array(z.enum(builtInApplicationIds)).length(builtInApplicationIds.length)
|
||||
.refine(ids => new Set(ids).size === builtInApplicationIds.length, 'Each application must occur exactly once'),
|
||||
pinned: z.object({
|
||||
'magic-notes': z.boolean(), 'local-inference': z.boolean()
|
||||
}).strict()
|
||||
|
||||
@@ -18,6 +18,22 @@ import { defaultLocalToolEnvironmentSettings } from './local-tool-environment-co
|
||||
import { defaultApplicationNavigation } from './application-settings-contracts'
|
||||
|
||||
describe('GoodBuddy configuration contracts', () => {
|
||||
it('uses the authoritative full application order for configuration-tool writes', () => {
|
||||
const operation = {
|
||||
operation: 'application.update',
|
||||
updates: { applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'],
|
||||
} },
|
||||
}
|
||||
expect(goodbuddyConfigOperationSchema.parse(operation)).toEqual(operation)
|
||||
for (const order of [['magic-notes', 'local-inference'], ['knowledge', 'heartbeat', 'magic-notes', 'magic-notes'], ['knowledge', 'heartbeat', 'magic-notes', 'invalid']]) {
|
||||
expect(goodbuddyConfigOperationSchema.safeParse({ ...operation, updates: {
|
||||
applicationNavigation: { ...defaultApplicationNavigation, order },
|
||||
} }).success).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes one valid generated example for every operation', () => {
|
||||
const operationNames = goodbuddyConfigOperationNameSchema.options
|
||||
|
||||
|
||||
Reference in New Issue
Block a user