mirror of
https://github.com/robonen/lorem-blog.git
synced 2026-03-20 10:54:38 +00:00
feat(tests): add comprehensive tests for utility functions and blog context
This commit is contained in:
@@ -11,6 +11,9 @@ export default antfu({
|
|||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'unused-imports/no-unused-imports': 'error',
|
'unused-imports/no-unused-imports': 'error',
|
||||||
|
'regexp/no-obscure-range': ['error', {
|
||||||
|
allowed: 'all',
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
vue: true,
|
vue: true,
|
||||||
typescript: true,
|
typescript: true,
|
||||||
|
|||||||
141
src/shared/utils.test.ts
Normal file
141
src/shared/utils.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
calculateReadingTime,
|
||||||
|
formatDate,
|
||||||
|
formatDateTime,
|
||||||
|
kmpSearch,
|
||||||
|
pluralize,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
describe('calculateReadingTime', () => {
|
||||||
|
it('should calculate reading time for normal content', () => {
|
||||||
|
const content = 'This is a test content with exactly twenty words in it to test the reading time calculation function properly.';
|
||||||
|
expect(calculateReadingTime(content)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round up to nearest minute', () => {
|
||||||
|
const content = Array.from({ length: 201 }).fill('word').join(' '); // 201 words
|
||||||
|
expect(calculateReadingTime(content)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
expect(calculateReadingTime('')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single word', () => {
|
||||||
|
expect(calculateReadingTime('word')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple spaces', () => {
|
||||||
|
expect(calculateReadingTime('word1 word2 word3')).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('should format date correctly', () => {
|
||||||
|
const result = formatDate('2023-04-09T12:00:00Z');
|
||||||
|
expect(result).toMatch(/^\d{1,2} [А-Я][а-я]{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different date formats', () => {
|
||||||
|
const result = formatDate('2023-12-25');
|
||||||
|
expect(result).toMatch(/^\d{1,2} [А-Я][а-я]{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should capitalize first letter of month', () => {
|
||||||
|
const result = formatDate('2023-01-01T00:00:00Z');
|
||||||
|
expect(result.charAt(result.indexOf(' ') + 1)).toMatch(/[А-Я]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDateTime', () => {
|
||||||
|
it('should format datetime correctly', () => {
|
||||||
|
const result = formatDateTime('2023-04-09T15:30:00Z');
|
||||||
|
expect(result).toMatch(/^\d{1,2}\.\d{1,2}\.\d{4} в \d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different timezones', () => {
|
||||||
|
const result = formatDateTime('2023-12-25T23:59:59Z');
|
||||||
|
expect(result).toContain(' в ');
|
||||||
|
expect(result).toMatch(/:\d{2}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pluralize', () => {
|
||||||
|
const forms = ['яблоко', 'яблока', 'яблок'];
|
||||||
|
|
||||||
|
it('should return singular form for 1', () => {
|
||||||
|
expect(pluralize(1, forms)).toBe('яблоко');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return plural form for 2-4', () => {
|
||||||
|
expect(pluralize(2, forms)).toBe('яблока');
|
||||||
|
expect(pluralize(3, forms)).toBe('яблока');
|
||||||
|
expect(pluralize(4, forms)).toBe('яблока');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return genitive form for 0', () => {
|
||||||
|
expect(pluralize(0, forms)).toBe('яблок');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return genitive form for 5 and above', () => {
|
||||||
|
expect(pluralize(5, forms)).toBe('яблок');
|
||||||
|
expect(pluralize(10, forms)).toBe('яблок');
|
||||||
|
expect(pluralize(100, forms)).toBe('яблок');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large numbers', () => {
|
||||||
|
expect(pluralize(1000, forms)).toBe('яблок');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('kmpSearch', () => {
|
||||||
|
it('should find exact match', () => {
|
||||||
|
expect(kmpSearch('hello world', 'world')).toBe(true);
|
||||||
|
expect(kmpSearch('hello world', 'hello')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case insensitive', () => {
|
||||||
|
expect(kmpSearch('Hello World', 'WORLD')).toBe(true);
|
||||||
|
expect(kmpSearch('HELLO WORLD', 'hello')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-matching patterns', () => {
|
||||||
|
expect(kmpSearch('hello world', 'foo')).toBe(false);
|
||||||
|
expect(kmpSearch('hello world', 'worlds')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty pattern', () => {
|
||||||
|
expect(kmpSearch('hello world', '')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty text', () => {
|
||||||
|
expect(kmpSearch('', 'pattern')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle both empty', () => {
|
||||||
|
expect(kmpSearch('', '')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find pattern at the beginning', () => {
|
||||||
|
expect(kmpSearch('pattern text', 'pattern')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find pattern at the end', () => {
|
||||||
|
expect(kmpSearch('some text pattern', 'pattern')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle overlapping patterns', () => {
|
||||||
|
expect(kmpSearch('ababab', 'abab')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex patterns', () => {
|
||||||
|
expect(kmpSearch('ABABACABABAB', 'ABABAC')).toBe(true);
|
||||||
|
expect(kmpSearch('ABABACABABAB', 'ABABAD')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single character patterns', () => {
|
||||||
|
expect(kmpSearch('hello', 'e')).toBe(true);
|
||||||
|
expect(kmpSearch('hello', 'x')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
* console.log(time); // Outputs: 1
|
* console.log(time); // Outputs: 1
|
||||||
*/
|
*/
|
||||||
export function calculateReadingTime(content: string) {
|
export function calculateReadingTime(content: string) {
|
||||||
|
if (!content || content.trim().length === 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
const wordsPerMinute = 200;
|
const wordsPerMinute = 200;
|
||||||
const words = content.split(/\s+/).length;
|
const words = content.split(/\s+/).length;
|
||||||
|
|
||||||
|
|||||||
179
src/widgets/Blog/__tests__/context.test.ts
Normal file
179
src/widgets/Blog/__tests__/context.test.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { nextTick } from 'vue';
|
||||||
|
import { useProvidingPosts } from '../context';
|
||||||
|
|
||||||
|
const mockPosts = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Vue.js Best Practices',
|
||||||
|
content: 'Content about Vue.js',
|
||||||
|
tags: ['vue', 'javascript', 'frontend'],
|
||||||
|
comments: [
|
||||||
|
{ id: 1, postId: 1, content: 'Great post!', author: 'John' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'TypeScript Tips',
|
||||||
|
content: 'Content about TypeScript',
|
||||||
|
tags: ['typescript', 'javascript'],
|
||||||
|
comments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'React vs Vue',
|
||||||
|
content: 'Comparison of React and Vue',
|
||||||
|
tags: ['react', 'vue', 'comparison'],
|
||||||
|
comments: [
|
||||||
|
{ id: 2, postId: 3, content: 'Interesting comparison', author: 'Jane' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('blog Context', () => {
|
||||||
|
let mockLoader: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockLoader = vi.fn().mockResolvedValue({ data: mockPosts });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load posts on initialization', async () => {
|
||||||
|
const context = useProvidingPosts(mockLoader);
|
||||||
|
|
||||||
|
expect(context.loading.value).toBe(true);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mockLoader).toHaveBeenCalled();
|
||||||
|
expect(context.posts.value).toEqual(mockPosts);
|
||||||
|
expect(context.loading.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter posts by search term', async () => {
|
||||||
|
const context = useProvidingPosts(mockLoader);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
context.filters.search = 'Vue';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(context.filteredPosts.value).toHaveLength(2);
|
||||||
|
expect(context.filteredPosts.value.map(p => p.title)).toEqual([
|
||||||
|
'Vue.js Best Practices',
|
||||||
|
'React vs Vue',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter posts by tags', async () => {
|
||||||
|
const context = useProvidingPosts(mockLoader);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
context.filters.tags.add('javascript');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(context.filteredPosts.value).toHaveLength(2);
|
||||||
|
expect(context.filteredPosts.value.map(p => p.title)).toEqual([
|
||||||
|
'Vue.js Best Practices',
|
||||||
|
'TypeScript Tips',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter posts by multiple tags', async () => {
|
||||||
|
const context = useProvidingPosts(mockLoader);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
context.filters.tags.add('vue');
|
||||||
|
context.filters.tags.add('javascript');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(context.filteredPosts.value).toHaveLength(1);
|
||||||
|
expect(context.filteredPosts.value[0].title).toBe('Vue.js Best Practices');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should combine search and tag filters', async () => {
|
||||||
|
const context = useProvidingPosts(mockLoader);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
context.filters.search = 'Vue';
|
||||||
|
context.filters.tags.add('comparison');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(context.filteredPosts.value).toHaveLength(1);
|
||||||
|
expect(context.filteredPosts.value[0].title).toBe('React vs Vue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle tags correctly', async () => {
|
||||||
|
const context = useProvidingPosts(mockLoader);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(context.isTagActive('vue')).toBe(false);
|
||||||
|
|
||||||
|
context.toggleTag('vue');
|
||||||
|
expect(context.isTagActive('vue')).toBe(true);
|
||||||
|
|
||||||
|
context.toggleTag('vue');
|
||||||
|
expect(context.isTagActive('vue')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return available tags from all posts', async () => {
|
||||||
|
const context = useProvidingPosts(mockLoader);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const expectedTags = ['vue', 'javascript', 'frontend', 'typescript', 'react', 'comparison'];
|
||||||
|
expect(context.availableTags.value.sort()).toEqual(expectedTags.sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect when filters are active', async () => {
|
||||||
|
const context = useProvidingPosts(mockLoader);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(context.filtersActive.value).toBe(false);
|
||||||
|
|
||||||
|
context.filters.search = 'test';
|
||||||
|
await nextTick();
|
||||||
|
expect(context.filtersActive.value).toBe(true);
|
||||||
|
|
||||||
|
context.filters.search = '';
|
||||||
|
context.filters.tags.add('vue');
|
||||||
|
await nextTick();
|
||||||
|
expect(context.filtersActive.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset filters', async () => {
|
||||||
|
const context = useProvidingPosts(mockLoader);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
context.filters.search = 'test';
|
||||||
|
context.filters.tags.add('vue');
|
||||||
|
context.filters.tags.add('javascript');
|
||||||
|
|
||||||
|
context.resetFilters();
|
||||||
|
|
||||||
|
expect(context.filters.search).toBe('');
|
||||||
|
expect(context.filters.tags.size).toBe(0);
|
||||||
|
expect(context.filtersActive.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle loading errors', async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const errorLoader = vi.fn().mockRejectedValue(new Error('Failed to load'));
|
||||||
|
|
||||||
|
const context = useProvidingPosts(errorLoader);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load posts:', expect.any(Error));
|
||||||
|
expect(context.loading.value).toBe(false);
|
||||||
|
expect(context.posts.value).toEqual([]);
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow manual reload of posts', async () => {
|
||||||
|
const context = useProvidingPosts(mockLoader);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
mockLoader.mockClear();
|
||||||
|
|
||||||
|
await context.loadPosts();
|
||||||
|
|
||||||
|
expect(mockLoader).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,9 +4,9 @@ import { useInjectionStore } from '@robonen/vue';
|
|||||||
import { computed, reactive, ref, shallowRef } from 'vue';
|
import { computed, reactive, ref, shallowRef } from 'vue';
|
||||||
import { kmpSearch } from '@/shared/utils';
|
import { kmpSearch } from '@/shared/utils';
|
||||||
|
|
||||||
interface PostItem extends Post {
|
type PostItem = Post & {
|
||||||
comments: PostComment[];
|
comments: PostComment[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
useProvidingState: useProvidingPosts,
|
useProvidingState: useProvidingPosts,
|
||||||
@@ -23,7 +23,7 @@ export const {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filtersActive = computed(() => {
|
const filtersActive = computed(() => {
|
||||||
return filters.search || filters.tags.size > 0;
|
return filters.search.trim() !== '' || filters.tags.size > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredPosts = computed(() => {
|
const filteredPosts = computed(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user