From e526dc26a181b9d7227dec0ae8d849c372ca5132 Mon Sep 17 00:00:00 2001 From: robonen Date: Tue, 17 Jun 2025 16:27:43 +0700 Subject: [PATCH] feat(tests): add comprehensive tests for utility functions and blog context --- eslint.config.mjs | 3 + src/shared/utils.test.ts | 141 ++++++++++++++++ src/shared/utils.ts | 3 + src/widgets/Blog/__tests__/context.test.ts | 179 +++++++++++++++++++++ src/widgets/Blog/context.ts | 6 +- 5 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 src/shared/utils.test.ts create mode 100644 src/widgets/Blog/__tests__/context.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index be1a4b5..c32cf89 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,9 @@ export default antfu({ }, rules: { 'unused-imports/no-unused-imports': 'error', + 'regexp/no-obscure-range': ['error', { + allowed: 'all', + }], }, vue: true, typescript: true, diff --git a/src/shared/utils.test.ts b/src/shared/utils.test.ts new file mode 100644 index 0000000..ed2f980 --- /dev/null +++ b/src/shared/utils.test.ts @@ -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); + }); +}); diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 5128fd0..5ccb48b 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -10,6 +10,9 @@ * console.log(time); // Outputs: 1 */ export function calculateReadingTime(content: string) { + if (!content || content.trim().length === 0) + return 0; + const wordsPerMinute = 200; const words = content.split(/\s+/).length; diff --git a/src/widgets/Blog/__tests__/context.test.ts b/src/widgets/Blog/__tests__/context.test.ts new file mode 100644 index 0000000..b1d344a --- /dev/null +++ b/src/widgets/Blog/__tests__/context.test.ts @@ -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; + + 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(); + }); +}); diff --git a/src/widgets/Blog/context.ts b/src/widgets/Blog/context.ts index 483fe77..d5ceba6 100644 --- a/src/widgets/Blog/context.ts +++ b/src/widgets/Blog/context.ts @@ -4,9 +4,9 @@ import { useInjectionStore } from '@robonen/vue'; import { computed, reactive, ref, shallowRef } from 'vue'; import { kmpSearch } from '@/shared/utils'; -interface PostItem extends Post { +type PostItem = Post & { comments: PostComment[]; -} +}; export const { useProvidingState: useProvidingPosts, @@ -23,7 +23,7 @@ export const { }); const filtersActive = computed(() => { - return filters.search || filters.tags.size > 0; + return filters.search.trim() !== '' || filters.tags.size > 0; }); const filteredPosts = computed(() => {