145 lines
5.8 KiB
TypeScript
145 lines
5.8 KiB
TypeScript
// Contract tests for the plugin-facing message-list category tabs
|
|
// (api.tabs.set → stores/message-list-tabs-store.ts).
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import {
|
|
useMessageListTabsStore,
|
|
validateTabsConfig,
|
|
resolveTabFilter,
|
|
} from '@/stores/message-list-tabs-store';
|
|
import type { MessageListTab } from '@/lib/plugin-types';
|
|
|
|
const GMAIL_STYLE = {
|
|
tabs: [
|
|
{ id: 'primary', label: 'Primary', keyword: null },
|
|
{ id: 'promotions', label: 'Promotions', keyword: '$category-promotions' },
|
|
{ id: 'social', label: 'Social', query: { operator: 'OR', conditions: [{ from: 'reddit.com' }, { from: 'linkedin.com' }] } },
|
|
{ id: 'updates', label: 'Updates', keyword: '$category-updates' },
|
|
],
|
|
};
|
|
|
|
function resetStore() {
|
|
useMessageListTabsStore.setState({
|
|
registrations: {},
|
|
tabs: [],
|
|
mailboxRoles: [],
|
|
activeTabId: null,
|
|
tabCounts: {},
|
|
isCountsLoading: false,
|
|
});
|
|
}
|
|
|
|
beforeEach(resetStore);
|
|
|
|
describe('validateTabsConfig', () => {
|
|
it('normalizes a valid config and defaults mailboxRoles to inbox', () => {
|
|
const v = validateTabsConfig(GMAIL_STYLE);
|
|
expect(v.tabs).toHaveLength(4);
|
|
expect(v.mailboxRoles).toEqual(['inbox']);
|
|
expect(v.tabs[1].keyword).toBe('$category-promotions');
|
|
expect(v.tabs[2].query).toEqual(GMAIL_STYLE.tabs[2].query);
|
|
});
|
|
|
|
it('lowercases keywords (IMAP keywords are case-insensitive)', () => {
|
|
const v = validateTabsConfig({
|
|
tabs: [
|
|
{ id: 'a', label: 'A', keyword: '$Category-Promotions' },
|
|
{ id: 'b', label: 'B', keyword: null },
|
|
],
|
|
});
|
|
expect(v.tabs[0].keyword).toBe('$category-promotions');
|
|
});
|
|
|
|
it('rejects reserved and tag keywords', () => {
|
|
expect(() => validateTabsConfig({
|
|
tabs: [{ id: 'a', label: 'A', keyword: '$seen' }, { id: 'b', label: 'B' }],
|
|
})).toThrow(/reserved/);
|
|
expect(() => validateTabsConfig({
|
|
tabs: [{ id: 'a', label: 'A', keyword: '$label.x' }, { id: 'b', label: 'B' }],
|
|
})).toThrow(/reserved/);
|
|
});
|
|
|
|
it('rejects duplicate ids, multiple default tabs, and single-tab configs', () => {
|
|
expect(() => validateTabsConfig({
|
|
tabs: [{ id: 'a', label: 'A' }, { id: 'a', label: 'B', keyword: '$x' }],
|
|
})).toThrow(/duplicate/);
|
|
expect(() => validateTabsConfig({
|
|
tabs: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }],
|
|
})).toThrow(/default/);
|
|
expect(() => validateTabsConfig({
|
|
tabs: [{ id: 'a', label: 'A', keyword: '$x' }],
|
|
})).toThrow(/at least 2/);
|
|
});
|
|
|
|
it('rejects non-object and oversized queries', () => {
|
|
expect(() => validateTabsConfig({
|
|
tabs: [{ id: 'a', label: 'A', query: 'from:x' as unknown as Record<string, unknown> }, { id: 'b', label: 'B' }],
|
|
})).toThrow(/filter object/);
|
|
const huge = { from: 'x'.repeat(20000) };
|
|
expect(() => validateTabsConfig({
|
|
tabs: [{ id: 'a', label: 'A', query: huge }, { id: 'b', label: 'B' }],
|
|
})).toThrow(/exceeds/);
|
|
});
|
|
});
|
|
|
|
describe('resolveTabFilter', () => {
|
|
const tabs = validateTabsConfig(GMAIL_STYLE).tabs as MessageListTab[];
|
|
|
|
it('uses hasKeyword for keyword tabs and the raw query for search tabs', () => {
|
|
expect(resolveTabFilter(tabs[1], tabs)).toEqual({ hasKeyword: '$category-promotions' });
|
|
expect(resolveTabFilter(tabs[2], tabs)).toEqual(GMAIL_STYLE.tabs[2].query);
|
|
});
|
|
|
|
it('resolves the default tab to NOT(all other positive fragments)', () => {
|
|
const filter = resolveTabFilter(tabs[0], tabs) as { operator: string; conditions: unknown[] };
|
|
expect(filter.operator).toBe('NOT');
|
|
expect(filter.conditions).toHaveLength(3);
|
|
expect(filter.conditions).toContainEqual({ hasKeyword: '$category-promotions' });
|
|
expect(filter.conditions).toContainEqual(GMAIL_STYLE.tabs[2].query);
|
|
});
|
|
});
|
|
|
|
describe('store registration', () => {
|
|
it('registers tabs, activates the default tab, and scopes to the inbox role', () => {
|
|
const store = useMessageListTabsStore.getState();
|
|
store.registerTabs('category-tabs', GMAIL_STYLE);
|
|
|
|
const s = useMessageListTabsStore.getState();
|
|
expect(s.tabs.map(t => t.id)).toEqual(['primary', 'promotions', 'social', 'updates']);
|
|
expect(s.activeTabId).toBe('primary');
|
|
expect(s.isEnabledForRole('inbox')).toBe(true);
|
|
expect(s.isEnabledForRole('trash')).toBe(false);
|
|
// Default tab active → NOT filter.
|
|
const filter = s.getCategoryFilter('inbox') as { operator: string };
|
|
expect(filter.operator).toBe('NOT');
|
|
expect(s.getCategoryFilter('trash')).toBeNull();
|
|
});
|
|
|
|
it('keeps only the first default tab when two plugins both register one', () => {
|
|
const store = useMessageListTabsStore.getState();
|
|
store.registerTabs('p1', { tabs: [{ id: 'main', label: 'Main', order: 1 }, { id: 'x', label: 'X', keyword: '$x' }] });
|
|
store.registerTabs('p2', { tabs: [{ id: 'other', label: 'Other', order: 2 }, { id: 'y', label: 'Y', keyword: '$y' }] });
|
|
const defaults = useMessageListTabsStore.getState().tabs.filter(t => !t.query && !t.keyword);
|
|
expect(defaults.map(t => t.id)).toEqual(['main']);
|
|
});
|
|
|
|
it('clears a plugin registration and resets counts when the strip empties', () => {
|
|
const store = useMessageListTabsStore.getState();
|
|
store.registerTabs('category-tabs', GMAIL_STYLE);
|
|
useMessageListTabsStore.setState({ tabCounts: { promotions: 5 } });
|
|
store.clearTabs('category-tabs');
|
|
const s = useMessageListTabsStore.getState();
|
|
expect(s.tabs).toHaveLength(0);
|
|
expect(s.activeTabId).toBeNull();
|
|
expect(s.tabCounts).toEqual({});
|
|
expect(s.getCategoryFilter('inbox')).toBeNull();
|
|
});
|
|
|
|
it('switches the active tab and exposes its filter', () => {
|
|
const store = useMessageListTabsStore.getState();
|
|
store.registerTabs('category-tabs', GMAIL_STYLE);
|
|
store.setActiveTab('promotions', 'inbox-id');
|
|
expect(useMessageListTabsStore.getState().getCategoryFilter('inbox')).toEqual({ hasKeyword: '$category-promotions' });
|
|
});
|
|
});
|