402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
/**
|
|
* Tests for tab management in the app store
|
|
* Validates tabbed interface behavior: transient tabs, pinned tabs, persistence
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { useAppStore, PostData, MediaData, Tab } from '../../../src/renderer/store/appStore';
|
|
|
|
// Helper to create a mock post
|
|
const createMockPost = (overrides: Partial<PostData> = {}): PostData => ({
|
|
id: `post-${Date.now()}-${Math.random().toString(36).substring(7)}`,
|
|
projectId: 'project-1',
|
|
title: 'Test Post',
|
|
slug: 'test-post',
|
|
content: '# Test Content',
|
|
status: 'draft',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tags: [],
|
|
categories: [],
|
|
...overrides,
|
|
});
|
|
|
|
// Helper to create a mock media
|
|
const createMockMedia = (overrides: Partial<MediaData> = {}): MediaData => ({
|
|
id: `media-${Date.now()}-${Math.random().toString(36).substring(7)}`,
|
|
projectId: 'project-1',
|
|
filename: 'test.jpg',
|
|
originalName: 'test.jpg',
|
|
mimeType: 'image/jpeg',
|
|
size: 1024,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tags: [],
|
|
...overrides,
|
|
});
|
|
|
|
// Direct store access without React rendering
|
|
const getStore = () => useAppStore.getState();
|
|
const setState = useAppStore.setState;
|
|
|
|
describe('Tab Management', () => {
|
|
beforeEach(() => {
|
|
// Reset store state before each test
|
|
setState({
|
|
tabs: [],
|
|
activeTabId: null,
|
|
posts: [],
|
|
media: [],
|
|
dirtyPosts: new Set(),
|
|
});
|
|
});
|
|
|
|
describe('Opening Tabs', () => {
|
|
it('should open a post in a transient tab on single click', () => {
|
|
const post = createMockPost({ id: 'post-1', title: 'Test Post' });
|
|
getStore().addPost(post);
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: true });
|
|
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
expect(getStore().tabs[0].type).toBe('post');
|
|
expect(getStore().tabs[0].id).toBe('post-1');
|
|
expect(getStore().tabs[0].isTransient).toBe(true);
|
|
expect(getStore().activeTabId).toBe('post-1');
|
|
});
|
|
|
|
it('should open a media file in a transient tab on single click', () => {
|
|
const media = createMockMedia({ id: 'media-1' });
|
|
getStore().addMedia(media);
|
|
|
|
getStore().openTab({ type: 'media', id: 'media-1', isTransient: true });
|
|
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
expect(getStore().tabs[0].type).toBe('media');
|
|
expect(getStore().tabs[0].id).toBe('media-1');
|
|
expect(getStore().tabs[0].isTransient).toBe(true);
|
|
expect(getStore().activeTabId).toBe('media-1');
|
|
});
|
|
|
|
it('should replace transient tab when opening another item with single click', () => {
|
|
const post1 = createMockPost({ id: 'post-1', title: 'Post 1' });
|
|
const post2 = createMockPost({ id: 'post-2', title: 'Post 2' });
|
|
getStore().addPost(post1);
|
|
getStore().addPost(post2);
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: true });
|
|
getStore().openTab({ type: 'post', id: 'post-2', isTransient: true });
|
|
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
expect(getStore().tabs[0].id).toBe('post-2');
|
|
expect(getStore().activeTabId).toBe('post-2');
|
|
});
|
|
|
|
it('should not replace pinned tabs when opening with single click', () => {
|
|
const post1 = createMockPost({ id: 'post-1', title: 'Post 1' });
|
|
const post2 = createMockPost({ id: 'post-2', title: 'Post 2' });
|
|
getStore().addPost(post1);
|
|
getStore().addPost(post2);
|
|
|
|
// Double click opens pinned tab
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
// Single click opens transient tab
|
|
getStore().openTab({ type: 'post', id: 'post-2', isTransient: true });
|
|
|
|
expect(getStore().tabs).toHaveLength(2);
|
|
expect(getStore().tabs[0].id).toBe('post-1');
|
|
expect(getStore().tabs[0].isTransient).toBe(false);
|
|
expect(getStore().tabs[1].id).toBe('post-2');
|
|
expect(getStore().tabs[1].isTransient).toBe(true);
|
|
});
|
|
|
|
it('should open a pinned tab on double click', () => {
|
|
const post = createMockPost({ id: 'post-1', title: 'Test Post' });
|
|
getStore().addPost(post);
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
expect(getStore().tabs[0].isTransient).toBe(false);
|
|
});
|
|
|
|
it('should convert transient tab to pinned on double click', () => {
|
|
const post = createMockPost({ id: 'post-1', title: 'Test Post' });
|
|
getStore().addPost(post);
|
|
|
|
// First single click - transient
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: true });
|
|
expect(getStore().tabs[0].isTransient).toBe(true);
|
|
|
|
// Double click - convert to pinned
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
expect(getStore().tabs[0].isTransient).toBe(false);
|
|
});
|
|
|
|
it('should switch to existing tab if already open', () => {
|
|
const post = createMockPost({ id: 'post-1' });
|
|
const post2 = createMockPost({ id: 'post-2' });
|
|
getStore().addPost(post);
|
|
getStore().addPost(post2);
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
getStore().openTab({ type: 'post', id: 'post-2', isTransient: false });
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
|
|
expect(getStore().tabs).toHaveLength(2);
|
|
expect(getStore().activeTabId).toBe('post-1');
|
|
});
|
|
|
|
it('should open git diff in a transient tab on single click', () => {
|
|
getStore().openTab({ type: 'git-diff', id: 'git-diff:posts/first.md', isTransient: true });
|
|
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
expect(getStore().tabs[0]).toMatchObject({
|
|
type: 'git-diff',
|
|
id: 'git-diff:posts/first.md',
|
|
isTransient: true,
|
|
});
|
|
});
|
|
|
|
it('should reuse transient git diff tab on subsequent single click', () => {
|
|
getStore().openTab({ type: 'git-diff', id: 'git-diff:posts/first.md', isTransient: true });
|
|
getStore().openTab({ type: 'git-diff', id: 'git-diff:posts/second.md', isTransient: true });
|
|
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
expect(getStore().tabs[0]).toMatchObject({
|
|
type: 'git-diff',
|
|
id: 'git-diff:posts/second.md',
|
|
isTransient: true,
|
|
});
|
|
});
|
|
|
|
it('should open git diff in a persistent tab on double click', () => {
|
|
getStore().openTab({ type: 'git-diff', id: 'git-diff:posts/first.md', isTransient: false });
|
|
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
expect(getStore().tabs[0]).toMatchObject({
|
|
type: 'git-diff',
|
|
id: 'git-diff:posts/first.md',
|
|
isTransient: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Closing Tabs', () => {
|
|
it('should close a tab by id', () => {
|
|
const post = createMockPost({ id: 'post-1' });
|
|
getStore().addPost(post);
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
getStore().closeTab('post-1');
|
|
|
|
expect(getStore().tabs).toHaveLength(0);
|
|
expect(getStore().activeTabId).toBeNull();
|
|
});
|
|
|
|
it('should activate the next tab when closing the active tab', () => {
|
|
const post1 = createMockPost({ id: 'post-1' });
|
|
const post2 = createMockPost({ id: 'post-2' });
|
|
getStore().addPost(post1);
|
|
getStore().addPost(post2);
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
getStore().openTab({ type: 'post', id: 'post-2', isTransient: false });
|
|
getStore().setActiveTab('post-1');
|
|
getStore().closeTab('post-1');
|
|
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
expect(getStore().activeTabId).toBe('post-2');
|
|
});
|
|
|
|
it('should activate the previous tab when closing the last tab', () => {
|
|
const post1 = createMockPost({ id: 'post-1' });
|
|
const post2 = createMockPost({ id: 'post-2' });
|
|
getStore().addPost(post1);
|
|
getStore().addPost(post2);
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
getStore().openTab({ type: 'post', id: 'post-2', isTransient: false });
|
|
getStore().closeTab('post-2');
|
|
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
expect(getStore().activeTabId).toBe('post-1');
|
|
});
|
|
|
|
it('should not remove dirty posts from dirtyPosts when closing tab', () => {
|
|
const post = createMockPost({ id: 'post-1' });
|
|
getStore().addPost(post);
|
|
getStore().markDirty('post-1');
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
getStore().closeTab('post-1');
|
|
|
|
// Dirty state is preserved - the post still has unsaved changes
|
|
expect(getStore().isDirty('post-1')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Tab Switching', () => {
|
|
it('should set active tab', () => {
|
|
const post1 = createMockPost({ id: 'post-1' });
|
|
const post2 = createMockPost({ id: 'post-2' });
|
|
getStore().addPost(post1);
|
|
getStore().addPost(post2);
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
getStore().openTab({ type: 'post', id: 'post-2', isTransient: false });
|
|
getStore().setActiveTab('post-1');
|
|
|
|
expect(getStore().activeTabId).toBe('post-1');
|
|
});
|
|
|
|
it('should not change active tab when switching to non-existent tab', () => {
|
|
const post = createMockPost({ id: 'post-1' });
|
|
getStore().addPost(post);
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
getStore().setActiveTab('non-existent');
|
|
|
|
expect(getStore().activeTabId).toBe('post-1');
|
|
});
|
|
});
|
|
|
|
describe('Pin Tab', () => {
|
|
it('should pin a transient tab', () => {
|
|
const post = createMockPost({ id: 'post-1' });
|
|
getStore().addPost(post);
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: true });
|
|
getStore().pinTab('post-1');
|
|
|
|
expect(getStore().tabs[0].isTransient).toBe(false);
|
|
});
|
|
|
|
it('should convert transient tab to pinned when editing starts', () => {
|
|
const post = createMockPost({ id: 'post-1' });
|
|
getStore().addPost(post);
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: true });
|
|
getStore().markDirty('post-1');
|
|
getStore().pinTab('post-1');
|
|
|
|
expect(getStore().tabs[0].isTransient).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Tab Persistence', () => {
|
|
it('should return tabs state for project persistence', () => {
|
|
const post1 = createMockPost({ id: 'post-1' });
|
|
const post2 = createMockPost({ id: 'post-2' });
|
|
getStore().addPost(post1);
|
|
getStore().addPost(post2);
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
getStore().openTab({ type: 'post', id: 'post-2', isTransient: false });
|
|
|
|
const tabState = getStore().getTabState();
|
|
|
|
expect(tabState.tabs).toHaveLength(2);
|
|
expect(tabState.activeTabId).toBe('post-2');
|
|
});
|
|
|
|
it('should restore tabs from persisted state', () => {
|
|
const tabState = {
|
|
tabs: [
|
|
{ type: 'post' as const, id: 'post-1', isTransient: false },
|
|
{ type: 'media' as const, id: 'media-1', isTransient: false },
|
|
],
|
|
activeTabId: 'media-1',
|
|
};
|
|
|
|
getStore().restoreTabState(tabState);
|
|
|
|
expect(getStore().tabs).toHaveLength(2);
|
|
expect(getStore().activeTabId).toBe('media-1');
|
|
});
|
|
|
|
it('should clear tabs when switching projects', () => {
|
|
const post = createMockPost({ id: 'post-1' });
|
|
getStore().addPost(post);
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
|
|
getStore().clearTabs();
|
|
|
|
expect(getStore().tabs).toHaveLength(0);
|
|
expect(getStore().activeTabId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Settings Tab', () => {
|
|
it('should open settings as a special tab', () => {
|
|
getStore().openTab({ type: 'settings', id: 'settings', isTransient: false });
|
|
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
expect(getStore().tabs[0].type).toBe('settings');
|
|
expect(getStore().activeTabId).toBe('settings');
|
|
});
|
|
|
|
it('should not allow multiple settings tabs', () => {
|
|
getStore().openTab({ type: 'settings', id: 'settings', isTransient: false });
|
|
getStore().openTab({ type: 'settings', id: 'settings', isTransient: false });
|
|
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
});
|
|
|
|
it('should open style as a dedicated tab', () => {
|
|
getStore().openTab({ type: 'style', id: 'style', isTransient: false });
|
|
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
expect(getStore().tabs[0].type).toBe('style');
|
|
expect(getStore().activeTabId).toBe('style');
|
|
});
|
|
|
|
it('should restore style tab from persisted state', () => {
|
|
const tabState = {
|
|
tabs: [
|
|
{ type: 'style' as const, id: 'style', isTransient: false },
|
|
],
|
|
activeTabId: 'style',
|
|
};
|
|
|
|
getStore().restoreTabState(tabState);
|
|
|
|
expect(getStore().tabs).toHaveLength(1);
|
|
expect(getStore().tabs[0].type).toBe('style');
|
|
expect(getStore().activeTabId).toBe('style');
|
|
});
|
|
});
|
|
|
|
describe('Multiple Tab Types', () => {
|
|
it('should handle mixed posts and media tabs', () => {
|
|
const post = createMockPost({ id: 'post-1' });
|
|
const media = createMockMedia({ id: 'media-1' });
|
|
getStore().addPost(post);
|
|
getStore().addMedia(media);
|
|
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
|
getStore().openTab({ type: 'media', id: 'media-1', isTransient: false });
|
|
|
|
expect(getStore().tabs).toHaveLength(2);
|
|
expect(getStore().tabs[0].type).toBe('post');
|
|
expect(getStore().tabs[1].type).toBe('media');
|
|
});
|
|
|
|
it('should keep transient tabs separate by type', () => {
|
|
const post = createMockPost({ id: 'post-1' });
|
|
const media = createMockMedia({ id: 'media-1' });
|
|
getStore().addPost(post);
|
|
getStore().addMedia(media);
|
|
|
|
// Open transient post tab
|
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: true });
|
|
// Open transient media tab - should NOT replace the post tab
|
|
getStore().openTab({ type: 'media', id: 'media-1', isTransient: true });
|
|
|
|
// Both transient tabs should exist since they're different types
|
|
expect(getStore().tabs).toHaveLength(2);
|
|
});
|
|
});
|
|
});
|