feat: first cut at menu editor
This commit is contained in:
110
tests/engine/MenuEngine.test.ts
Normal file
110
tests/engine/MenuEngine.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockFiles = new Map<string, string>();
|
||||
const mockDirs = new Set<string>();
|
||||
|
||||
const normalizePath = (value: string): string => value.replace(/\\/g, '/');
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(async (filePath: string) => {
|
||||
const normalizedPath = normalizePath(filePath);
|
||||
if (mockFiles.has(normalizedPath)) {
|
||||
return mockFiles.get(normalizedPath);
|
||||
}
|
||||
|
||||
const err = new Error(`ENOENT: no such file or directory, open '${filePath}'`) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}),
|
||||
writeFile: vi.fn(async (filePath: string, content: string) => {
|
||||
mockFiles.set(normalizePath(filePath), content);
|
||||
}),
|
||||
mkdir: vi.fn(async (dirPath: string) => {
|
||||
mockDirs.add(normalizePath(dirPath));
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => '/mock/userData'),
|
||||
},
|
||||
}));
|
||||
|
||||
import { MenuEngine } from '../../src/main/engine/MenuEngine';
|
||||
|
||||
describe('MenuEngine', () => {
|
||||
let menuEngine: MenuEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFiles.clear();
|
||||
mockDirs.clear();
|
||||
menuEngine = new MenuEngine();
|
||||
menuEngine.setProjectContext('project-1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns an empty menu when no OPML file exists', async () => {
|
||||
const result = await menuEngine.getMenu();
|
||||
|
||||
expect(result.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses nested OPML outlines into menu items', async () => {
|
||||
const menuPath = normalizePath(`${menuEngine.getMetaDir()}/menu.opml`);
|
||||
mockFiles.set(
|
||||
menuPath,
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n<opml version="2.0">\n <head><title>Blog Menu</title></head>\n <body>\n <outline id="home" text="Home" type="page" pageSlug="home"/>\n <outline id="docs" text="Docs" type="submenu">\n <outline id="about" text="About" type="page" pageSlug="about"/>\n </outline>\n </body>\n</opml>`,
|
||||
);
|
||||
|
||||
const result = await menuEngine.getMenu();
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
id: 'home',
|
||||
title: 'Home',
|
||||
kind: 'page',
|
||||
pageSlug: 'home',
|
||||
});
|
||||
expect(result.items[1]).toMatchObject({
|
||||
id: 'docs',
|
||||
title: 'Docs',
|
||||
kind: 'submenu',
|
||||
});
|
||||
expect(result.items[1].children[0]).toMatchObject({
|
||||
id: 'about',
|
||||
title: 'About',
|
||||
kind: 'page',
|
||||
pageSlug: 'about',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes menu state as OPML and can read it back', async () => {
|
||||
const saved = await menuEngine.saveMenu({
|
||||
items: [
|
||||
{
|
||||
id: 'top',
|
||||
title: 'Top',
|
||||
kind: 'submenu',
|
||||
children: [
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'First Page',
|
||||
kind: 'page',
|
||||
pageSlug: 'first-page',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(saved.items[0].title).toBe('Top');
|
||||
|
||||
const roundTrip = await menuEngine.getMenu();
|
||||
expect(roundTrip).toEqual(saved);
|
||||
});
|
||||
});
|
||||
@@ -136,6 +136,12 @@ const mockTagEngine = {
|
||||
searchTags: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMenuEngine = {
|
||||
setProjectContext: vi.fn(),
|
||||
getMenu: vi.fn(),
|
||||
saveMenu: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPostMediaEngine = {
|
||||
on: vi.fn(),
|
||||
setProjectContext: vi.fn(),
|
||||
@@ -245,6 +251,10 @@ vi.mock('../../src/main/engine/TagEngine', () => ({
|
||||
getTagEngine: vi.fn(() => mockTagEngine),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/MenuEngine', () => ({
|
||||
getMenuEngine: vi.fn(() => mockMenuEngine),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/PostMediaEngine', () => ({
|
||||
getPostMediaEngine: vi.fn(() => mockPostMediaEngine),
|
||||
}));
|
||||
@@ -1252,6 +1262,51 @@ describe('IPC Handlers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Menu Handlers ============
|
||||
describe('Menu Handlers', () => {
|
||||
describe('menu:get', () => {
|
||||
it('loads menu for active project context', async () => {
|
||||
const activeProject = createMockProject({ id: 'project-42', dataPath: '/custom/data' });
|
||||
const menuDocument = {
|
||||
items: [
|
||||
{ id: 'home', title: 'Home', kind: 'page', pageSlug: 'home', children: [] },
|
||||
],
|
||||
};
|
||||
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/resolved/project-data');
|
||||
mockMenuEngine.getMenu.mockResolvedValue(menuDocument);
|
||||
|
||||
const result = await invokeHandler('menu:get');
|
||||
|
||||
expect(mockMenuEngine.setProjectContext).toHaveBeenCalledWith('project-42', '/resolved/project-data');
|
||||
expect(mockMenuEngine.getMenu).toHaveBeenCalled();
|
||||
expect(result).toEqual(menuDocument);
|
||||
});
|
||||
});
|
||||
|
||||
describe('menu:save', () => {
|
||||
it('saves menu for active project context', async () => {
|
||||
const activeProject = createMockProject({ id: 'project-24', dataPath: '/custom/data' });
|
||||
const menuDocument = {
|
||||
items: [
|
||||
{ id: 'docs', title: 'Docs', kind: 'submenu', children: [] },
|
||||
],
|
||||
};
|
||||
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/resolved/project-data');
|
||||
mockMenuEngine.saveMenu.mockResolvedValue(menuDocument);
|
||||
|
||||
const result = await invokeHandler('menu:save', menuDocument);
|
||||
|
||||
expect(mockMenuEngine.setProjectContext).toHaveBeenCalledWith('project-24', '/resolved/project-data');
|
||||
expect(mockMenuEngine.saveMenu).toHaveBeenCalledWith(menuDocument);
|
||||
expect(result).toEqual(menuDocument);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Task Handlers ============
|
||||
describe('Task Handlers', () => {
|
||||
describe('tasks:getAll', () => {
|
||||
|
||||
63
tests/renderer/components/menuAutoExpand.test.ts
Normal file
63
tests/renderer/components/menuAutoExpand.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createAutoExpandController } from '../../../src/renderer/components/MenuEditorView/menuAutoExpand';
|
||||
|
||||
describe('createAutoExpandController', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('runs callback after configured delay', () => {
|
||||
const controller = createAutoExpandController(300);
|
||||
const callback = vi.fn();
|
||||
|
||||
controller.schedule('node-a', callback);
|
||||
vi.advanceTimersByTime(299);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels scheduled callback for a node', () => {
|
||||
const controller = createAutoExpandController(300);
|
||||
const callback = vi.fn();
|
||||
|
||||
controller.schedule('node-a', callback);
|
||||
controller.cancel('node-a');
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces existing schedule for same node id', () => {
|
||||
const controller = createAutoExpandController(300);
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
|
||||
controller.schedule('node-a', first);
|
||||
controller.schedule('node-a', second);
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels all pending callbacks', () => {
|
||||
const controller = createAutoExpandController(300);
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
|
||||
controller.schedule('node-a', first);
|
||||
controller.schedule('node-b', second);
|
||||
controller.cancelAll();
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
94
tests/renderer/components/menuPagePicker.test.ts
Normal file
94
tests/renderer/components/menuPagePicker.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PostData } from '../../../src/main/shared/electronApi';
|
||||
import {
|
||||
createMenuPageItemFromPost,
|
||||
filterPagePosts,
|
||||
getNextPickerIndex,
|
||||
isPickerCloseKey,
|
||||
isPickerFocusShortcut,
|
||||
} from '../../../src/renderer/components/MenuEditorView/menuPagePicker';
|
||||
|
||||
function createPost(overrides: Partial<PostData>): PostData {
|
||||
return {
|
||||
id: 'post-1',
|
||||
projectId: 'project-1',
|
||||
title: 'Sample Page',
|
||||
slug: 'sample-page',
|
||||
content: '',
|
||||
status: 'draft',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
tags: [],
|
||||
categories: ['page'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('menuPagePicker', () => {
|
||||
it('filters to page-category posts only', () => {
|
||||
const posts = [
|
||||
createPost({ id: 'page-1', categories: ['page'] }),
|
||||
createPost({ id: 'article-1', categories: ['article'] }),
|
||||
];
|
||||
|
||||
const result = filterPagePosts(posts, '');
|
||||
|
||||
expect(result.map((post) => post.id)).toEqual(['page-1']);
|
||||
});
|
||||
|
||||
it('filters by title and slug using case-insensitive query', () => {
|
||||
const posts = [
|
||||
createPost({ id: 'alpha', title: 'About Us', slug: 'about-us' }),
|
||||
createPost({ id: 'beta', title: 'Imprint', slug: 'impressum' }),
|
||||
];
|
||||
|
||||
expect(filterPagePosts(posts, 'about').map((post) => post.id)).toEqual(['alpha']);
|
||||
expect(filterPagePosts(posts, 'IMPRESS').map((post) => post.id)).toEqual(['beta']);
|
||||
});
|
||||
|
||||
it('creates a menu page node with linked page metadata', () => {
|
||||
const post = createPost({
|
||||
id: 'page-3',
|
||||
title: 'Contact',
|
||||
slug: 'contact',
|
||||
categories: ['page'],
|
||||
});
|
||||
|
||||
const item = createMenuPageItemFromPost(post);
|
||||
|
||||
expect(item.kind).toBe('page');
|
||||
expect(item.title).toBe('Contact');
|
||||
expect(item.pageId).toBe('page-3');
|
||||
expect(item.pageSlug).toBe('contact');
|
||||
expect(item.children).toEqual([]);
|
||||
expect(item.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('moves active index with arrow navigation and wraps around', () => {
|
||||
expect(getNextPickerIndex(-1, 'ArrowDown', 3)).toBe(0);
|
||||
expect(getNextPickerIndex(0, 'ArrowDown', 3)).toBe(1);
|
||||
expect(getNextPickerIndex(2, 'ArrowDown', 3)).toBe(0);
|
||||
|
||||
expect(getNextPickerIndex(-1, 'ArrowUp', 3)).toBe(2);
|
||||
expect(getNextPickerIndex(2, 'ArrowUp', 3)).toBe(1);
|
||||
expect(getNextPickerIndex(0, 'ArrowUp', 3)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns -1 when there are no picker items', () => {
|
||||
expect(getNextPickerIndex(-1, 'ArrowDown', 0)).toBe(-1);
|
||||
expect(getNextPickerIndex(1, 'ArrowUp', 0)).toBe(-1);
|
||||
});
|
||||
|
||||
it('detects escape as picker close key', () => {
|
||||
expect(isPickerCloseKey('Escape')).toBe(true);
|
||||
expect(isPickerCloseKey('Enter')).toBe(false);
|
||||
});
|
||||
|
||||
it('detects cmd/ctrl+k as picker focus shortcut', () => {
|
||||
expect(isPickerFocusShortcut({ key: 'k', metaKey: true, ctrlKey: false })).toBe(true);
|
||||
expect(isPickerFocusShortcut({ key: 'K', metaKey: false, ctrlKey: true })).toBe(true);
|
||||
expect(isPickerFocusShortcut({ key: 'k', metaKey: false, ctrlKey: false })).toBe(false);
|
||||
expect(isPickerFocusShortcut({ key: 'p', metaKey: true, ctrlKey: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
81
tests/renderer/components/menuTreeMove.test.ts
Normal file
81
tests/renderer/components/menuTreeMove.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { applyTreeMove, type MenuTreeItem } from '../../../src/renderer/components/MenuEditorView/menuTreeMove';
|
||||
|
||||
function createTree(): MenuTreeItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'home',
|
||||
title: 'Home',
|
||||
kind: 'page',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
title: 'Docs',
|
||||
kind: 'submenu',
|
||||
children: [
|
||||
{
|
||||
id: 'about',
|
||||
title: 'About',
|
||||
kind: 'page',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'blog',
|
||||
title: 'Blog',
|
||||
kind: 'submenu',
|
||||
children: [
|
||||
{
|
||||
id: 'post-1',
|
||||
title: 'Post 1',
|
||||
kind: 'page',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'post-2',
|
||||
title: 'Post 2',
|
||||
kind: 'page',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe('applyTreeMove', () => {
|
||||
it('moves a page into a submenu', () => {
|
||||
const moved = applyTreeMove(createTree(), {
|
||||
dragIds: ['home'],
|
||||
parentId: 'docs',
|
||||
index: 1,
|
||||
});
|
||||
|
||||
const docs = moved.find((item) => item.id === 'docs');
|
||||
expect(docs?.children.map((child) => child.id)).toEqual(['about', 'home']);
|
||||
expect(moved.map((item) => item.id)).toEqual(['docs', 'blog']);
|
||||
});
|
||||
|
||||
it('moves a whole subtree without losing children', () => {
|
||||
const moved = applyTreeMove(createTree(), {
|
||||
dragIds: ['blog'],
|
||||
parentId: null,
|
||||
index: 0,
|
||||
});
|
||||
|
||||
expect(moved[0].id).toBe('blog');
|
||||
expect(moved[0].children.map((child) => child.id)).toEqual(['post-1', 'post-2']);
|
||||
});
|
||||
|
||||
it('reorders siblings within same parent', () => {
|
||||
const moved = applyTreeMove(createTree(), {
|
||||
dragIds: ['post-2'],
|
||||
parentId: 'blog',
|
||||
index: 0,
|
||||
});
|
||||
|
||||
const blog = moved.find((item) => item.id === 'blog');
|
||||
expect(blog?.children.map((child) => child.id)).toEqual(['post-2', 'post-1']);
|
||||
});
|
||||
});
|
||||
@@ -63,4 +63,15 @@ describe('Help menu documentation entry', () => {
|
||||
it('maps Edit Preferences to a renderer menu event', () => {
|
||||
expect(APP_MENU_ACTION_EVENT_MAP.editPreferences).toBe('menu:editPreferences');
|
||||
});
|
||||
|
||||
it('includes Edit Menu action in Blog menu', () => {
|
||||
const blogGroup = APP_MENU_GROUPS.find((group) => group.label === 'Blog');
|
||||
|
||||
expect(blogGroup).toBeDefined();
|
||||
expect(blogGroup?.items.some((item) => item.action === 'editMenu')).toBe(true);
|
||||
});
|
||||
|
||||
it('maps Edit Menu to a renderer menu event', () => {
|
||||
expect(APP_MENU_ACTION_EVENT_MAP.editMenu).toBe('menu:editMenu');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('editorRouting', () => {
|
||||
tags: 'tags',
|
||||
chat: 'chat',
|
||||
import: 'import',
|
||||
'menu-editor': 'menu-editor',
|
||||
'metadata-diff': 'metadata-diff',
|
||||
'git-diff': 'git-diff',
|
||||
documentation: 'documentation',
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('tabPolicy', () => {
|
||||
expect(getSingletonToolTabSpec('settings')).toEqual({ type: 'settings', id: 'settings', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('tags')).toEqual({ type: 'tags', id: 'tags', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('style')).toEqual({ type: 'style', id: 'style', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('menu-editor')).toEqual({ type: 'menu-editor', id: 'menu-editor', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('documentation')).toEqual({ type: 'documentation', id: 'documentation', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('metadata-diff')).toEqual({ type: 'metadata-diff', id: 'metadata-diff', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('site-validation')).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false });
|
||||
@@ -33,8 +34,9 @@ describe('tabPolicy', () => {
|
||||
let captured: { type: string; id: string; isTransient: boolean } | null = null;
|
||||
|
||||
openSingletonToolTab(openTab, 'site-validation');
|
||||
openSingletonToolTab(openTab, 'menu-editor');
|
||||
|
||||
expect(captured).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false });
|
||||
expect(captured).toEqual({ type: 'menu-editor', id: 'menu-editor', isTransient: false });
|
||||
});
|
||||
|
||||
it('provides canonical entity tab spec for preview and pin intents', () => {
|
||||
|
||||
Reference in New Issue
Block a user