diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7152a772..79610ee7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -113,7 +113,7 @@ importers:
version: 0.45.0
postcss:
specifier: ^8.4.39
- version: 8.5.15
+ version: 8.5.17
prettier:
specifier: 3.5.3
version: 3.5.3
@@ -3352,8 +3352,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- nanoid@3.3.12:
- resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+ nanoid@3.3.15:
+ resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -3556,8 +3556,12 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
- postcss@8.5.15:
- resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+ postcss@8.5.16:
+ resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ postcss@8.5.17:
+ resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1:
@@ -5332,7 +5336,7 @@ snapshots:
'@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.3.0
'@tailwindcss/oxide': 4.3.0
- postcss: 8.5.15
+ postcss: 8.5.16
tailwindcss: 4.3.0
'@testing-library/dom@10.4.1':
@@ -8155,7 +8159,7 @@ snapshots:
ms@2.1.3: {}
- nanoid@3.3.12: {}
+ nanoid@3.3.15: {}
natural-compare@1.4.0: {}
@@ -8340,9 +8344,9 @@ snapshots:
postcss-resolve-nested-selector@0.1.6: {}
- postcss-safe-parser@7.0.1(postcss@8.5.15):
+ postcss-safe-parser@7.0.1(postcss@8.5.16):
dependencies:
- postcss: 8.5.15
+ postcss: 8.5.16
postcss-selector-parser@7.1.1:
dependencies:
@@ -8353,13 +8357,19 @@ snapshots:
postcss@8.4.31:
dependencies:
- nanoid: 3.3.12
+ nanoid: 3.3.15
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ postcss@8.5.16:
+ dependencies:
+ nanoid: 3.3.15
picocolors: 1.1.1
source-map-js: 1.2.1
- postcss@8.5.15:
+ postcss@8.5.17:
dependencies:
- nanoid: 3.3.12
+ nanoid: 3.3.15
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -8893,9 +8903,9 @@ snapshots:
micromatch: 4.0.8
normalize-path: 3.0.0
picocolors: 1.1.1
- postcss: 8.5.15
+ postcss: 8.5.16
postcss-resolve-nested-selector: 0.1.6
- postcss-safe-parser: 7.0.1(postcss@8.5.15)
+ postcss-safe-parser: 7.0.1(postcss@8.5.16)
postcss-selector-parser: 7.1.1
postcss-value-parser: 4.2.0
resolve-from: 5.0.0
diff --git a/src/features/about/activity/CardItem.spec.tsx b/src/features/about/activity/CardItem.spec.tsx
new file mode 100644
index 00000000..75054459
--- /dev/null
+++ b/src/features/about/activity/CardItem.spec.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from '@testing-library/react';
+
+import { ActivityType, type Activity } from './models';
+import { CardItem } from './CardItem';
+
+const activity: Activity = {
+ name: 'FEConf',
+ type: ActivityType.conference,
+ startDate: 2024,
+ url: 'https://feconf.kr',
+};
+
+describe('CardItem', () => {
+ it('should render activity type and external link', () => {
+ render(
+
,
+ );
+
+ expect(screen.getByText(ActivityType.conference)).toBeVisible();
+ const link = screen.getByRole('link', { name: 'FEConf' });
+ expect(link).toHaveAttribute('href', 'https://feconf.kr');
+ expect(link).toHaveAttribute('target', '_blank');
+ });
+});
diff --git a/src/features/about/shared/Label.spec.tsx b/src/features/about/shared/Label.spec.tsx
new file mode 100644
index 00000000..4fec2061
--- /dev/null
+++ b/src/features/about/shared/Label.spec.tsx
@@ -0,0 +1,34 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { Label } from './Label';
+
+describe('Label', () => {
+ it('should render static heading when onClick is omitted', () => {
+ render();
+
+ expect(screen.getByText('WORK EXPERIENCE')).toBeVisible();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('should render interactive button and handle click/keyboard', async () => {
+ const onClick = jest.fn();
+ render();
+
+ const button = screen.getByRole('button', { name: 'WORK EXPERIENCE' });
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+
+ await userEvent.click(button);
+ expect(onClick).toHaveBeenCalledTimes(1);
+
+ button.focus();
+ await userEvent.keyboard('{Enter}');
+ expect(onClick).toHaveBeenCalledTimes(2);
+
+ await userEvent.keyboard(' ');
+ expect(onClick).toHaveBeenCalledTimes(3);
+
+ await userEvent.keyboard('a');
+ expect(onClick).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/src/features/about/work/card/content/ProjectDate.spec.tsx b/src/features/about/work/card/content/ProjectDate.spec.tsx
new file mode 100644
index 00000000..c06dee6a
--- /dev/null
+++ b/src/features/about/work/card/content/ProjectDate.spec.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@testing-library/react';
+
+import { ProjectDate } from './ProjectDate';
+
+describe('ProjectDate', () => {
+ it('should render start and end dates', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('2020')).toBeVisible();
+ expect(screen.getByText('2022')).toBeVisible();
+ expect(screen.getByText(/-/)).toBeVisible();
+ });
+
+ it('should render Present when endDate is missing', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(screen.getByText('2023')).toBeVisible();
+ expect(container).toHaveTextContent('Present');
+ });
+});
diff --git a/src/features/about/work/card/content/Tags.spec.tsx b/src/features/about/work/card/content/Tags.spec.tsx
new file mode 100644
index 00000000..e3ad859f
--- /dev/null
+++ b/src/features/about/work/card/content/Tags.spec.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/react';
+
+import { Tags } from './Tags';
+
+describe('Tags', () => {
+ it('should render each tag', () => {
+ render();
+
+ expect(screen.getByText('React18')).toBeVisible();
+ expect(screen.getByText('AWS')).toBeVisible();
+ });
+
+ it('should render empty container when tags are empty', () => {
+ const { container } = render();
+
+ expect(container.firstChild).toBeEmptyDOMElement();
+ });
+});
diff --git a/src/features/about/work/shared/Body.spec.tsx b/src/features/about/work/shared/Body.spec.tsx
new file mode 100644
index 00000000..381019be
--- /dev/null
+++ b/src/features/about/work/shared/Body.spec.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '@testing-library/react';
+
+import { Paragraph, Section, Sentence } from './Body';
+
+describe('Body', () => {
+ it('should render Section with top margin class when top is false', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('li')).toHaveClass('mt-8');
+ expect(screen.getByText('내용')).toBeVisible();
+ });
+
+ it('should omit extra top margin when top is true', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('li')).not.toHaveClass('mt-8');
+ });
+
+ it('should render Paragraph and Sentence', () => {
+ render(
+
+ 추가
+ ,
+ );
+
+ expect(screen.getByRole('list')).toBeVisible();
+ expect(screen.getByRole('listitem')).toHaveTextContent('첫 문장추가');
+ });
+});
diff --git a/src/features/home/Container.spec.tsx b/src/features/home/Container.spec.tsx
index 3852870c..a5f1cc52 100644
--- a/src/features/home/Container.spec.tsx
+++ b/src/features/home/Container.spec.tsx
@@ -38,9 +38,11 @@ describe('HomeContainer', () => {
it('should fire analytics on click', async () => {
render();
- await userEvent.click(
- screen.getByRole('link', { name: '포스트 목록으로 이동' }),
- );
+ const link = screen.getByRole('link', { name: '포스트 목록으로 이동' });
+ link.addEventListener('click', (event) => {
+ event.preventDefault();
+ });
+ await userEvent.click(link);
expect(ga).toHaveBeenCalledWith('homeClick', {
type: 'router-push',
diff --git a/src/features/post/PostNavigationContainer/PrevNext.spec.tsx b/src/features/post/PostNavigationContainer/PrevNext.spec.tsx
index 2ed54191..1f882919 100644
--- a/src/features/post/PostNavigationContainer/PrevNext.spec.tsx
+++ b/src/features/post/PostNavigationContainer/PrevNext.spec.tsx
@@ -100,9 +100,13 @@ describe('PrevNext', () => {
/>,
);
- await userEvent.click(
- screen.getByRole('link', { name: '이전 글: prev-post title' }),
- );
+ const link = screen.getByRole('link', {
+ name: '이전 글: prev-post title',
+ });
+ link.addEventListener('click', (event) => {
+ event.preventDefault();
+ });
+ await userEvent.click(link);
expect(mockSendGAEvent).toHaveBeenCalledWith('event', 'postNavigation', {
type: 'prev',
@@ -119,9 +123,13 @@ describe('PrevNext', () => {
/>,
);
- await userEvent.click(
- screen.getByRole('link', { name: '다음 글: next-post title' }),
- );
+ const link = screen.getByRole('link', {
+ name: '다음 글: next-post title',
+ });
+ link.addEventListener('click', (event) => {
+ event.preventDefault();
+ });
+ await userEvent.click(link);
expect(mockSendGAEvent).toHaveBeenCalledWith('event', 'postNavigation', {
type: 'next',
diff --git a/src/features/post/PostNavigationContainer/RelatedPostItem.spec.tsx b/src/features/post/PostNavigationContainer/RelatedPostItem.spec.tsx
index e4acb073..ab18779c 100644
--- a/src/features/post/PostNavigationContainer/RelatedPostItem.spec.tsx
+++ b/src/features/post/PostNavigationContainer/RelatedPostItem.spec.tsx
@@ -79,9 +79,11 @@ describe('RelatedPostItem', () => {
/>,
);
- await userEvent.click(
- screen.getByRole('link', { name: /Sample Post Title/i }),
- );
+ const link = screen.getByRole('link', { name: /Sample Post Title/i });
+ link.addEventListener('click', (event) => {
+ event.preventDefault();
+ });
+ await userEvent.click(link);
expect(mockSendGAEvent).toHaveBeenCalledWith('event', 'postNavigation', {
type: 'similar',
@@ -99,9 +101,11 @@ describe('RelatedPostItem', () => {
/>,
);
- await userEvent.click(
- screen.getByRole('link', { name: /Sample Post Title/i }),
- );
+ const link = screen.getByRole('link', { name: /Sample Post Title/i });
+ link.addEventListener('click', (event) => {
+ event.preventDefault();
+ });
+ await userEvent.click(link);
expect(mockSendGAEvent).toHaveBeenCalledWith('event', 'postNavigation', {
type: 'discovery',
diff --git a/src/features/post/PostNavigationContainer/utils.spec.ts b/src/features/post/PostNavigationContainer/utils.spec.ts
index bf3c4e3f..28ff21c3 100644
--- a/src/features/post/PostNavigationContainer/utils.spec.ts
+++ b/src/features/post/PostNavigationContainer/utils.spec.ts
@@ -463,4 +463,321 @@ describe('getRelatedPosts', () => {
expect(getRelatedPosts(current, [current])).toEqual([]);
});
+
+ it('should prefer truly distant posts over topic-overlapping neighbors for discovery', () => {
+ const current = createPost('current', {
+ date: '2024-02-01',
+ tags: ['graphql', 'frontend'],
+ category: 'JavaScript',
+ });
+ const posts = [
+ current,
+ createPost('similar-a', {
+ date: '2024-03-01',
+ tags: ['graphql', 'frontend'],
+ category: 'JavaScript',
+ }),
+ createPost('similar-b', {
+ date: '2024-01-01',
+ tags: ['graphql', 'frontend'],
+ category: 'JavaScript',
+ }),
+ createPost('topic-neighbor', {
+ date: '2024-05-01',
+ tags: ['graphql', 'career', 'developer'],
+ category: 'Activity',
+ }),
+ createPost('true-discovery', {
+ date: '2024-06-01',
+ tags: ['book-review', 'developer'],
+ category: 'Book',
+ }),
+ ];
+
+ const related = getRelatedPosts(current, posts);
+ const discovery = related.filter(({ kind }) => kind === 'discovery');
+
+ // topic overlap은 distant 풀에서 제외되므로 첫 discovery는 true-discovery
+ expect(discovery[0].post.slug).toBe('true-discovery');
+ });
+
+ it('should prefer lower current topic score when no distant discovery pool exists', () => {
+ const current = createPost('current', {
+ date: '2024-02-01',
+ tags: ['shared-topic', 'frontend'],
+ category: 'JavaScript',
+ });
+ const posts = [
+ current,
+ createPost('similar-a', {
+ date: '2024-03-01',
+ tags: ['shared-topic', 'frontend'],
+ category: 'JavaScript',
+ }),
+ createPost('similar-b', {
+ date: '2024-01-01',
+ tags: ['shared-topic', 'frontend'],
+ category: 'JavaScript',
+ }),
+ createPost('more-overlap', {
+ date: '2024-05-01',
+ tags: ['shared-topic', 'react', 'frontend'],
+ category: 'JavaScript',
+ }),
+ createPost('less-overlap', {
+ date: '2024-06-01',
+ tags: ['react', 'frontend'],
+ category: 'JavaScript',
+ }),
+ ];
+
+ const related = getRelatedPosts(current, posts);
+ const discovery = related.filter(({ kind }) => kind === 'discovery');
+
+ expect(discovery[0].post.slug).toBe('less-overlap');
+ });
+
+ it('should prefer different category from current when topic scores are equal', () => {
+ const current = createPost('current', {
+ date: '2024-02-01',
+ tags: ['shared-topic', 'frontend'],
+ category: 'JavaScript',
+ });
+ const posts = [
+ current,
+ createPost('similar-a', {
+ date: '2024-03-01',
+ tags: ['shared-topic', 'frontend'],
+ category: 'JavaScript',
+ }),
+ createPost('similar-b', {
+ date: '2024-01-01',
+ tags: ['shared-topic', 'frontend'],
+ category: 'JavaScript',
+ }),
+ // distant 필터를 비우기 위해 둘 다 topic overlap 유지
+ createPost('same-category', {
+ date: '2024-05-01',
+ tags: ['shared-topic', 'react'],
+ category: 'JavaScript',
+ }),
+ createPost('other-category', {
+ date: '2024-06-01',
+ tags: ['shared-topic', 'react'],
+ category: 'Rust',
+ }),
+ ];
+
+ const related = getRelatedPosts(current, posts);
+ const discovery = related.filter(({ kind }) => kind === 'discovery');
+
+ expect(discovery[0].post.slug).toBe('other-category');
+ });
+
+ it('should prefer lower hub score vs current when topic and category are equal', () => {
+ const current = createPost('current', {
+ date: '2024-02-01',
+ tags: ['shared-topic', 'react', 'frontend'],
+ category: 'JavaScript',
+ });
+ const posts = [
+ current,
+ createPost('similar-a', {
+ date: '2024-03-01',
+ tags: ['shared-topic', 'react', 'frontend'],
+ category: 'JavaScript',
+ }),
+ createPost('similar-b', {
+ date: '2024-01-01',
+ tags: ['shared-topic', 'react', 'frontend'],
+ category: 'JavaScript',
+ }),
+ createPost('high-hub', {
+ date: '2024-05-01',
+ tags: ['react', 'frontend', 'javascript'],
+ category: 'JavaScript',
+ }),
+ createPost('low-hub', {
+ date: '2024-06-01',
+ tags: ['react'],
+ category: 'JavaScript',
+ }),
+ ];
+
+ const related = getRelatedPosts(current, posts);
+ const discovery = related.filter(({ kind }) => kind === 'discovery');
+
+ expect(discovery[0].post.slug).toBe('low-hub');
+ });
+
+ it('should diversify second discovery away from the first discovery topic', () => {
+ const current = createPost('current', {
+ date: '2024-02-01',
+ tags: ['react', 'frontend', 'javascript'],
+ category: 'JavaScript',
+ });
+ const posts = [
+ current,
+ createPost('similar-a', {
+ date: '2024-03-01',
+ tags: ['react', 'frontend', 'javascript'],
+ }),
+ createPost('similar-b', {
+ date: '2024-01-01',
+ tags: ['react', 'frontend', 'javascript'],
+ }),
+ createPost('discovery-career', {
+ date: '2024-05-01',
+ tags: ['career', 'developer', 'remote-work'],
+ category: 'Activity',
+ }),
+ createPost('near-career', {
+ date: '2024-06-01',
+ tags: ['career', 'developer'],
+ category: 'Book',
+ }),
+ createPost('far-book', {
+ date: '2024-07-01',
+ tags: ['book-review', 'developer'],
+ category: 'Book',
+ }),
+ ];
+
+ const related = getRelatedPosts(current, posts);
+ const discovery = related
+ .filter(({ kind }) => kind === 'discovery')
+ .map(({ post }) => post);
+
+ expect(discovery).toHaveLength(2);
+ expect(countSharedTopicTags(discovery[0], discovery[1])).toBe(0);
+ expect(discovery[0].category).not.toBe(discovery[1].category);
+ });
+
+ it('should prefer lower hub overlap with the first discovery when other scores are equal', () => {
+ const current = createPost('current', {
+ date: '2024-02-01',
+ tags: ['react', 'frontend', 'javascript'],
+ category: 'JavaScript',
+ });
+ const posts = [
+ current,
+ createPost('similar-a', {
+ date: '2024-03-01',
+ tags: ['react', 'frontend', 'javascript'],
+ }),
+ createPost('similar-b', {
+ date: '2024-01-01',
+ tags: ['react', 'frontend', 'javascript'],
+ }),
+ createPost('discovery-career', {
+ date: '2024-05-01',
+ tags: ['career', 'developer'],
+ category: 'Activity',
+ }),
+ createPost('high-hub-vs-career', {
+ date: '2024-06-01',
+ tags: ['developer', 'career', 'retrospect'],
+ category: 'Book',
+ }),
+ createPost('low-hub-vs-career', {
+ date: '2024-07-01',
+ tags: ['developer'],
+ category: 'Book',
+ }),
+ ];
+
+ const related = getRelatedPosts(current, posts);
+ const discovery = related.filter(({ kind }) => kind === 'discovery');
+
+ expect(discovery).toHaveLength(2);
+ if (discovery[0].post.slug === 'discovery-career') {
+ expect(discovery[1].post.slug).toBe('low-hub-vs-career');
+ } else {
+ // 첫 픽이 달라져도 두 번째는 서로 다른 카테고리여야 함
+ expect(discovery[0].post.category).not.toBe(discovery[1].post.category);
+ }
+ });
+
+ it('should sort second discovery by lower topic overlap with the first discovery', () => {
+ const current = createPost('current', {
+ date: '2024-02-01',
+ tags: ['react', 'frontend', 'developer'],
+ category: 'JavaScript',
+ });
+ const posts = [
+ current,
+ createPost('similar-a', {
+ date: '2024-03-01',
+ tags: ['react', 'frontend', 'developer'],
+ }),
+ createPost('similar-b', {
+ date: '2024-01-01',
+ tags: ['react', 'frontend', 'developer'],
+ }),
+ // current hub overlap이 없어 첫 discovery로 확정
+ createPost('anchor-graphql', {
+ date: '2024-05-01',
+ tags: ['graphql', 'apollo', 'career'],
+ category: 'Activity',
+ }),
+ // anchor와 topic overlap이 있어 distant 필터를 비우고 점수 비교를 유도
+ createPost('high-overlap-anchor', {
+ date: '2024-06-01',
+ tags: ['graphql', 'apollo', 'developer'],
+ category: 'Book',
+ }),
+ createPost('low-overlap-anchor', {
+ date: '2024-07-01',
+ tags: ['graphql', 'developer'],
+ category: 'Book',
+ }),
+ ];
+
+ const related = getRelatedPosts(current, posts);
+ const discovery = related.filter(({ kind }) => kind === 'discovery');
+
+ expect(discovery).toHaveLength(2);
+ expect(discovery[0].post.slug).toBe('anchor-graphql');
+ expect(discovery[1].post.slug).toBe('low-overlap-anchor');
+ });
+
+ it('should prefer lower hub overlap with first discovery when topic and category are equal', () => {
+ const current = createPost('current', {
+ date: '2024-02-01',
+ tags: ['react', 'frontend', 'developer'],
+ category: 'JavaScript',
+ });
+ const posts = [
+ current,
+ createPost('similar-a', {
+ date: '2024-03-01',
+ tags: ['react', 'frontend', 'developer'],
+ }),
+ createPost('similar-b', {
+ date: '2024-01-01',
+ tags: ['react', 'frontend', 'developer'],
+ }),
+ createPost('anchor-graphql', {
+ date: '2024-05-01',
+ tags: ['graphql', 'career'],
+ category: 'Activity',
+ }),
+ createPost('high-hub-vs-anchor', {
+ date: '2024-06-01',
+ tags: ['graphql', 'career', 'developer'],
+ category: 'Book',
+ }),
+ createPost('low-hub-vs-anchor', {
+ date: '2024-07-01',
+ tags: ['graphql', 'developer'],
+ category: 'Book',
+ }),
+ ];
+
+ const related = getRelatedPosts(current, posts);
+ const discovery = related.filter(({ kind }) => kind === 'discovery');
+
+ expect(discovery[0].post.slug).toBe('anchor-graphql');
+ expect(discovery[1].post.slug).toBe('low-hub-vs-anchor');
+ });
});
diff --git a/src/features/post/components/IssuePost.spec.tsx b/src/features/post/components/IssuePost.spec.tsx
new file mode 100644
index 00000000..75ef9a66
--- /dev/null
+++ b/src/features/post/components/IssuePost.spec.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from '@testing-library/react';
+
+import { IssuePost } from './IssuePost';
+
+describe('IssuePost', () => {
+ it('should render CTA link with prefilled GitHub issue query', () => {
+ render();
+
+ const link = screen.getByRole('link', {
+ name: '📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍',
+ });
+
+ expect(link).toHaveAttribute(
+ 'href',
+ expect.stringContaining('https://github.com/1ilsang/dev/issues/new?'),
+ );
+ expect(link.getAttribute('href')).toContain(
+ 'title=[🧊] %ED%85%8C%EC%8A%A4%ED%8A%B8%20%ED%8F%AC%EC%8A%A4%ED%8A%B8',
+ );
+ expect(link.getAttribute('href')).toContain('assignees=1ilsang');
+ expect(link.getAttribute('href')).toContain('labels=🧊 comment');
+ expect(link).toHaveAttribute('target', '_blank');
+ });
+});
diff --git a/src/features/post/components/PrintTracker.spec.tsx b/src/features/post/components/PrintTracker.spec.tsx
new file mode 100644
index 00000000..ab05112c
--- /dev/null
+++ b/src/features/post/components/PrintTracker.spec.tsx
@@ -0,0 +1,37 @@
+import { render } from '@testing-library/react';
+import { sendGAEvent } from '@next/third-parties/google';
+
+import { PrintTracker } from './PrintTracker';
+
+jest.mock('@next/third-parties/google', () => ({
+ sendGAEvent: jest.fn(),
+}));
+
+jest.mock('next/navigation', () => ({
+ usePathname: () => '/posts/sample-post',
+}));
+
+describe('PrintTracker', () => {
+ const mockSendGAEvent = sendGAEvent as jest.MockedFunction<
+ typeof sendGAEvent
+ >;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should track print event with post slug on beforeprint', () => {
+ const { unmount } = render();
+
+ window.dispatchEvent(new Event('beforeprint'));
+
+ expect(mockSendGAEvent).toHaveBeenCalledWith('event', 'printPost', {
+ type: 'print',
+ value: 'sample-post',
+ });
+
+ unmount();
+ window.dispatchEvent(new Event('beforeprint'));
+ expect(mockSendGAEvent).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/features/post/information/InformationContainer.spec.tsx b/src/features/post/information/InformationContainer.spec.tsx
new file mode 100644
index 00000000..0d307d7d
--- /dev/null
+++ b/src/features/post/information/InformationContainer.spec.tsx
@@ -0,0 +1,53 @@
+import { render, screen } from '@testing-library/react';
+
+import type { PostType } from '~/posts/models';
+import { InformationContainer } from './InformationContainer';
+
+const createPost = (
+ overrides: Partial & { readingTime?: number } = {},
+): PostType =>
+ ({
+ MDX: () => null,
+ toc: [],
+ url: '/posts/sample',
+ slug: 'sample',
+ fullSlug: 'js/sample',
+ category: 'JavaScript',
+ readingTime: overrides.readingTime ?? 5,
+ frontmatter: {
+ title: 'Sample',
+ description: 'desc',
+ tags: ['react', 'nextjs'],
+ coverImage: '/cover.webp',
+ date: '2024-01-15T00:00:00.000Z',
+ ...overrides,
+ },
+ }) as PostType;
+
+describe('InformationContainer', () => {
+ it('should render profile, tags, published date, and reading time', () => {
+ render();
+
+ expect(screen.getByText('1ilsang')).toBeVisible();
+ expect(screen.getByRole('link', { name: '#react' })).toHaveAttribute(
+ 'href',
+ '/tags/react',
+ );
+ expect(screen.getByRole('link', { name: '#nextjs' })).toHaveAttribute(
+ 'href',
+ '/tags/nextjs',
+ );
+ expect(screen.getByText('Published')).toBeVisible();
+ expect(screen.getByText('5 min read')).toBeVisible();
+ });
+
+ it('should render updated date when updatedAt exists', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Updated')).toBeVisible();
+ });
+});
diff --git a/src/features/post/information/ProfileSection.spec.tsx b/src/features/post/information/ProfileSection.spec.tsx
new file mode 100644
index 00000000..53c9eeb2
--- /dev/null
+++ b/src/features/post/information/ProfileSection.spec.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/react';
+
+import { MyProfile } from '~/about/headline/data/profile';
+import { ProfileSection } from './ProfileSection';
+
+describe('ProfileSection', () => {
+ it('should render avatar, about link, and profile description', () => {
+ render();
+
+ expect(
+ screen.getByRole('link', { name: 'About 페이지로 이동' }),
+ ).toBeVisible();
+ expect(
+ screen.getByRole('link', { name: MyProfile.personal.label }),
+ ).toHaveAttribute('href', '/about');
+ expect(screen.getByText(MyProfile.personal.description)).toBeVisible();
+ });
+});
diff --git a/src/features/post/information/PublishedDate.spec.tsx b/src/features/post/information/PublishedDate.spec.tsx
new file mode 100644
index 00000000..b0eb78f6
--- /dev/null
+++ b/src/features/post/information/PublishedDate.spec.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from '@testing-library/react';
+
+import { PublishedDate } from './PublishedDate';
+
+describe('PublishedDate', () => {
+ it('should render published date only', () => {
+ render();
+
+ expect(screen.getByText('Published')).toBeVisible();
+ expect(screen.queryByText('Updated')).not.toBeInTheDocument();
+ expect(screen.queryByText('·')).not.toBeInTheDocument();
+ });
+
+ it('should render published and updated dates with separator', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Published')).toBeVisible();
+ expect(screen.getByText('Updated')).toBeVisible();
+ expect(screen.getByText('·')).toBeVisible();
+ expect(screen.getAllByRole('time')).toHaveLength(2);
+ });
+});
diff --git a/src/features/post/sponsor/Container.spec.tsx b/src/features/post/sponsor/Container.spec.tsx
new file mode 100644
index 00000000..ffa29a00
--- /dev/null
+++ b/src/features/post/sponsor/Container.spec.tsx
@@ -0,0 +1,58 @@
+import { render, screen } from '@testing-library/react';
+import { sendGAEvent } from '@next/third-parties/google';
+
+import { SponsorContainer } from './Container';
+
+jest.mock('@next/third-parties/google', () => ({
+ sendGAEvent: jest.fn(),
+}));
+
+describe('SponsorContainer', () => {
+ const mockSendGAEvent = sendGAEvent as jest.MockedFunction<
+ typeof sendGAEvent
+ >;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render GitHub sponsors iframe', () => {
+ render();
+
+ const iframe = screen.getByTitle('Sponsor 1ilsang');
+ expect(iframe).toHaveAttribute(
+ 'src',
+ 'https://github.com/sponsors/1ilsang/button',
+ );
+ });
+
+ it('should track sponsor click when iframe is focused on blur', () => {
+ render();
+ const iframe = screen.getByTitle('Sponsor 1ilsang');
+
+ Object.defineProperty(document, 'activeElement', {
+ configurable: true,
+ get: () => iframe,
+ });
+
+ window.dispatchEvent(new Event('blur'));
+
+ expect(mockSendGAEvent).toHaveBeenCalledWith('event', 'sponsorClick', {
+ type: 'sponsor',
+ value: 'github-sponsors',
+ });
+ });
+
+ it('should not track when blur happens without iframe focus', () => {
+ render();
+
+ Object.defineProperty(document, 'activeElement', {
+ configurable: true,
+ get: () => document.body,
+ });
+
+ window.dispatchEvent(new Event('blur'));
+
+ expect(mockSendGAEvent).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/features/post/toc/Container.spec.tsx b/src/features/post/toc/Container.spec.tsx
index af9a2cea..1004993b 100644
--- a/src/features/post/toc/Container.spec.tsx
+++ b/src/features/post/toc/Container.spec.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useRouter } from 'next/navigation';
import { TocContainer } from './Container';
@@ -47,7 +47,9 @@ describe('event', () => {
await userEvent
.setup({ advanceTimers: jest.advanceTimersByTime })
.click(screen.getByText(MOCK_TOC[2].value));
- jest.advanceTimersByTime(200);
+ act(() => {
+ jest.advanceTimersByTime(200);
+ });
expect(push).toHaveBeenCalledWith(
`#${MOCK_TOC[2].id}`,
// handleTocClick 함수에서 scroll: false 옵션을 주었기 때문
diff --git a/src/features/post/toc/Item.spec.tsx b/src/features/post/toc/Item.spec.tsx
new file mode 100644
index 00000000..968763c8
--- /dev/null
+++ b/src/features/post/toc/Item.spec.tsx
@@ -0,0 +1,74 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { TOC_DEPTH, type TOC } from '~/posts/models';
+import { TocItem } from './Item';
+
+const h2Item: TOC = { depth: TOC_DEPTH.H2, value: 'Section', id: 'section' };
+const h3Item: TOC = {
+ depth: TOC_DEPTH.H3,
+ value: 'Subsection',
+ id: 'subsection',
+};
+
+describe('TocItem', () => {
+ it('should render h2 item link', () => {
+ const handleIndexClick = jest.fn();
+ render(
+ ,
+ );
+
+ const link = screen.getByRole('link', { name: 'Section' });
+ expect(link).toHaveAttribute('href', '#section');
+ expect(link).toHaveClass('hover:text-sub-blue');
+ });
+
+ it('should apply target active and last sub list styles for h3', () => {
+ render(
+ ,
+ );
+
+ const item = screen.getByRole('listitem');
+ expect(item).toHaveClass('mb-1.5', 'animate-toc-index');
+ expect(screen.getByRole('link', { name: 'Subsection' })).toHaveClass(
+ 'text-orange-300',
+ );
+ });
+
+ it('should call handleIndexClick on link click', async () => {
+ const handleIndexClick = jest.fn((event) => {
+ event.preventDefault();
+ });
+
+ render(
+ ,
+ );
+
+ await userEvent.click(screen.getByRole('link', { name: 'Section' }));
+ expect(handleIndexClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/features/shared/components/HashTag/HashTag.spec.tsx b/src/features/shared/components/HashTag/HashTag.spec.tsx
index f3ee4603..1eea30f9 100644
--- a/src/features/shared/components/HashTag/HashTag.spec.tsx
+++ b/src/features/shared/components/HashTag/HashTag.spec.tsx
@@ -1,7 +1,22 @@
import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { sendGAEvent } from '@next/third-parties/google';
+
import { HashTag } from './HashTag';
+jest.mock('@next/third-parties/google', () => ({
+ sendGAEvent: jest.fn(),
+}));
+
describe('HashTag', () => {
+ const mockSendGAEvent = sendGAEvent as jest.MockedFunction<
+ typeof sendGAEvent
+ >;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
it('should render with # prefix', () => {
render();
expect(screen.getByText('#react')).toBeInTheDocument();
@@ -26,4 +41,19 @@ describe('HashTag', () => {
render();
expect(screen.getByRole('link')).toHaveClass('extra');
});
+
+ it('should track tag click', async () => {
+ render();
+
+ const link = screen.getByRole('link');
+ link.addEventListener('click', (event) => {
+ event.preventDefault();
+ });
+ await userEvent.click(link);
+
+ expect(mockSendGAEvent).toHaveBeenCalledWith('event', 'tagClick', {
+ type: 'tag',
+ value: 'react',
+ });
+ });
});
diff --git a/src/features/shared/components/mdx/ExternalAnchor.spec.tsx b/src/features/shared/components/mdx/ExternalAnchor.spec.tsx
new file mode 100644
index 00000000..21d50ad5
--- /dev/null
+++ b/src/features/shared/components/mdx/ExternalAnchor.spec.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/react';
+
+import { ExternalAnchor } from './ExternalAnchor';
+
+describe('ExternalAnchor', () => {
+ it('should render external link with accessibility attributes', () => {
+ render();
+
+ const link = screen.getByRole('link', {
+ name: '외부 링크: https://example.com',
+ });
+ expect(link).toHaveAttribute('href', 'https://example.com');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ expect(link).toHaveAttribute('title', 'https://example.com');
+ expect(screen.getByText('⎋')).toHaveAttribute('aria-hidden', 'true');
+ });
+});
diff --git a/src/features/shared/components/nav/Progress.spec.tsx b/src/features/shared/components/nav/Progress.spec.tsx
index 57c0ed58..9e63770f 100644
--- a/src/features/shared/components/nav/Progress.spec.tsx
+++ b/src/features/shared/components/nav/Progress.spec.tsx
@@ -13,21 +13,28 @@ jest.mock('../modal/atoms', () => ({
imageAltAtom: {},
}));
+jest.mock('./useProgress', () => ({
+ __esModule: true,
+ INIT_MAX: 1,
+ default: jest.fn(),
+}));
+
+jest.mock('./useBindZoomableImages', () => ({
+ useBindZoomableImages: jest.fn(),
+}));
+
+jest.mock('./usePostAnalytics', () => ({
+ usePostAnalytics: jest.fn(),
+}));
+
+import useProgress, { INIT_MAX } from './useProgress';
import { NavProgress } from './Progress';
-describe('NavProgress', () => {
- const originalConsoleError = console.error;
+const mockUseProgress = useProgress as jest.MockedFunction;
+describe('NavProgress', () => {
beforeEach(() => {
- console.error = (...args: unknown[]) => {
- if (typeof args[0] === 'string' && args[0].includes('not wrapped in act'))
- return;
- originalConsoleError(...args);
- };
- });
-
- afterEach(() => {
- console.error = originalConsoleError;
+ mockUseProgress.mockReturnValue({ progress: 0.3, max: INIT_MAX });
});
it('should render progress element with aria-label', () => {
@@ -40,4 +47,19 @@ describe('NavProgress', () => {
render();
expect(document.getElementById('nav-progress')).toBeInTheDocument();
});
+
+ it('should use loading progress style when max is INIT_MAX', () => {
+ render();
+ expect(screen.getByRole('progressbar')).toHaveClass(
+ '[&::-webkit-progress-value]:bg-base-og',
+ );
+ });
+
+ it('should use ready progress style when max is not INIT_MAX', () => {
+ mockUseProgress.mockReturnValue({ progress: 40, max: 100 });
+ render();
+ expect(screen.getByRole('progressbar')).toHaveClass(
+ '[&::-webkit-progress-value]:progress-wheel',
+ );
+ });
});
diff --git a/src/features/tags/tagList/Container.spec.tsx b/src/features/tags/tagList/Container.spec.tsx
new file mode 100644
index 00000000..aa519890
--- /dev/null
+++ b/src/features/tags/tagList/Container.spec.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from '@testing-library/react';
+
+import { TagListContainer } from './Container';
+
+describe('TagListContainer', () => {
+ it('should render sr-only heading and hashtags', () => {
+ render();
+
+ expect(screen.getByRole('heading', { name: 'Tags' })).toHaveClass(
+ 'sr-only',
+ );
+ expect(screen.getByRole('link', { name: '#react' })).toHaveAttribute(
+ 'href',
+ '/tags/react',
+ );
+ expect(screen.getByRole('link', { name: '#testing' })).toHaveAttribute(
+ 'href',
+ '/tags/testing',
+ );
+ });
+
+ it('should render only heading when tags are empty', () => {
+ render();
+
+ expect(screen.getByRole('heading', { name: 'Tags' })).toBeInTheDocument();
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+ });
+});