feat: real component preview in Create page, RelatedArticles + AuthorCard, test coverage 40/51

This commit is contained in:
sunzhongyi
2026-05-21 10:13:14 +08:00
parent 5f65d741ed
commit e38372e34d
15 changed files with 453 additions and 177 deletions
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Byline } from '../text/Byline';
describe('Byline', () => {
it('renders with small-caps class', () => {
const { container } = render(
<Layout><Section columns={24}><Byline>By Alice</Byline></Section></Layout>
);
const el = container.querySelector('.nui-byline');
expect(el?.classList.contains('nui-small-caps')).toBe(true);
});
it('renders children', () => {
const { getByText } = render(
<Layout><Section columns={24}><Byline>By Alice Smith</Byline></Section></Layout>
);
expect(getByText('By Alice Smith')).toBeTruthy();
});
});
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Caption } from '../text/Caption';
describe('Caption', () => {
it('renders as figcaption', () => {
const { container } = render(
<Layout><Section columns={24}><Caption>text</Caption></Section></Layout>
);
expect(container.querySelector('figcaption')).toBeTruthy();
});
it('renders credit in small-caps', () => {
const { container } = render(
<Layout><Section columns={24}><Caption credit="Photo by X">text</Caption></Section></Layout>
);
const credit = container.querySelector('.nui-small-caps');
expect(credit?.textContent).toBe('Photo by X');
});
});
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Footer } from '../layout/Footer';
describe('Footer', () => {
const wrap = (ui: React.ReactElement) => render(
<Layout><Section columns={24}>{ui}</Section></Layout>
);
it('renders copyright text', () => {
const { getByText } = wrap(<Footer copyright="© 2026" />);
expect(getByText('© 2026')).toBeTruthy();
});
it('renders links', () => {
const { getByText } = wrap(
<Footer links={[{ label: 'About', href: '/about' }]} />
);
const link = getByText('About');
expect(link.getAttribute('href')).toBe('/about');
});
});
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Image } from '../media/Image';
describe('Image', () => {
const wrap = (ui: React.ReactElement) => render(
<Layout><Section columns={24}>{ui}</Section></Layout>
);
it('renders img with src and alt', () => {
const { container } = wrap(<Image src="/test.jpg" alt="test" />);
const img = container.querySelector('img');
expect(img?.getAttribute('src')).toBe('/test.jpg');
expect(img?.getAttribute('alt')).toBe('test');
});
it('defaults to lazy loading', () => {
const { container } = wrap(<Image src="/test.jpg" alt="test" />);
const img = container.querySelector('img');
expect(img?.getAttribute('loading')).toBe('lazy');
});
it('applies aspectRatio', () => {
const { container } = wrap(<Image src="/test.jpg" alt="test" aspectRatio="16/9" />);
const img = container.querySelector('img') as HTMLElement;
expect(img.style.aspectRatio).toBe('16/9');
});
});
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Kicker } from '../text/Kicker';
describe('Kicker', () => {
it('renders with small-caps class', () => {
const { container } = render(
<Layout><Section columns={24}><Kicker>BREAKING</Kicker></Section></Layout>
);
const el = container.querySelector('.nui-kicker');
expect(el).toBeTruthy();
expect(el?.classList.contains('nui-small-caps')).toBe(true);
});
it('renders children text', () => {
const { getByText } = render(
<Layout><Section columns={24}><Kicker>Politics</Kicker></Section></Layout>
);
expect(getByText('Politics')).toBeTruthy();
});
});
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layer } from '../layout/Layer';
describe('Layer', () => {
it('renders with absolute position by default', () => {
const { container } = render(<Layer>content</Layer>);
const div = container.firstElementChild as HTMLElement;
expect(div.style.position).toBe('absolute');
});
it('applies top/left/zIndex', () => {
const { container } = render(<Layer position="fixed" top="10px" left="20px" zIndex={99}>x</Layer>);
const div = container.firstElementChild as HTMLElement;
expect(div.style.position).toBe('fixed');
expect(div.style.top).toBe('10px');
expect(div.style.left).toBe('20px');
expect(div.style.zIndex).toBe('99');
});
});
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { PullQuote } from '../media/PullQuote';
describe('PullQuote', () => {
const wrap = (ui: React.ReactElement) => render(
<Layout><Section columns={24}>{ui}</Section></Layout>
);
it('renders as aside with border-top and border-bottom', () => {
const { container } = wrap(<PullQuote>quote text</PullQuote>);
const aside = container.querySelector('aside') as HTMLElement;
expect(aside).toBeTruthy();
expect(aside.style.borderTop).toContain('1px solid');
expect(aside.style.borderBottom).toContain('1px solid');
});
it('renders author in footer', () => {
const { getByText } = wrap(<PullQuote author="John">quote</PullQuote>);
expect(getByText('— John')).toBeTruthy();
});
it('applies spanAllColumns class', () => {
const { container } = wrap(<PullQuote spanAllColumns>quote</PullQuote>);
const aside = container.querySelector('aside');
expect(aside?.classList.contains('nui-span-all-columns')).toBe(true);
});
});
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Quote } from '../text/Quote';
describe('Quote', () => {
const wrap = (ui: React.ReactElement) => render(
<Layout><Section columns={24}>{ui}</Section></Layout>
);
it('renders block variant as blockquote', () => {
const { container } = wrap(<Quote variant="block">text</Quote>);
expect(container.querySelector('blockquote')).toBeTruthy();
});
it('renders inline variant as em', () => {
const { container } = wrap(<Quote variant="inline">text</Quote>);
expect(container.querySelector('em')).toBeTruthy();
});
it('block variant does not apply visible border-left', () => {
const { container } = wrap(<Quote variant="block">text</Quote>);
const bq = container.querySelector('blockquote') as HTMLElement;
// borderLeft: 'none' is set in component; jsdom normalizes it to empty sub-properties
expect(bq.style.borderLeftStyle).toBeFalsy();
});
});
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Rule } from '../layout/Rule';
describe('Rule', () => {
const wrap = (ui: React.ReactElement) => render(
<Layout><Section columns={24}>{ui}</Section></Layout>
);
it('renders hairline by default', () => {
const { container } = wrap(<Rule />);
const hr = container.querySelector('hr');
expect(hr).toBeTruthy();
expect(hr?.style.borderTop).toContain('1px solid');
});
it('renders thick variant', () => {
const { container } = wrap(<Rule variant="thick" />);
const hr = container.querySelector('hr');
expect(hr?.style.borderTop).toContain('3px solid');
});
it('renders double variant with gradient', () => {
const { container } = wrap(<Rule variant="double" />);
const hr = container.querySelector('hr');
expect(hr?.style.height).toBe('6px');
});
});
+8
View File
@@ -47,3 +47,11 @@ export { Video } from './media/Video';
export type { VideoProps } from './media/Video';
export { PullQuote } from './media/PullQuote';
export type { PullQuoteProps } from './media/PullQuote';
// additional layout
export { RelatedArticles } from './layout/RelatedArticles';
export type { RelatedArticlesProps, RelatedArticle } from './layout/RelatedArticles';
// additional text
export { AuthorCard } from './text/AuthorCard';
export type { AuthorCardProps } from './text/AuthorCard';
@@ -0,0 +1,75 @@
'use client';
import React, { CSSProperties } from 'react';
import { cx } from 'newspaperui-utils';
export interface RelatedArticle {
title: string;
href: string;
category?: string;
}
export interface RelatedArticlesProps {
title?: string;
articles: RelatedArticle[];
className?: string;
style?: CSSProperties;
}
/**
* RelatedArticles — "See Also" / "相关报道" 列表
*
* @example
* <RelatedArticles title="Related" articles={[
* { title: 'Climate bill passes', href: '/article/1', category: 'Politics' }
* ]} />
*/
export const RelatedArticles: React.FC<RelatedArticlesProps> = ({
title = 'Related', articles, className, style,
}) => (
<aside
className={cx('nui-related', className)}
style={{
borderTop: '2px solid var(--nui-rule-decorative)',
paddingTop: 'var(--nui-space-4)',
marginTop: 'var(--nui-space-6)',
...style,
}}
>
<h4 style={{
fontFamily: 'var(--font-family-meta)',
fontSize: '11px',
fontWeight: 700,
fontVariantCaps: 'small-caps',
letterSpacing: '0.08em',
color: 'var(--nui-accent-primary)',
margin: '0 0 var(--nui-space-3) 0',
}}>{title}</h4>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{articles.map((article, i) => (
<li key={i} style={{
borderBottom: '1px solid var(--nui-rule-hairline)',
padding: 'var(--nui-space-2) 0',
}}>
{article.category && (
<span style={{
fontFamily: 'var(--font-family-meta)',
fontSize: '10px',
fontVariantCaps: 'small-caps',
letterSpacing: '0.06em',
color: 'var(--nui-text-muted)',
marginRight: 'var(--nui-space-2)',
}}>{article.category}</span>
)}
<a href={article.href} style={{
fontFamily: 'var(--font-family-headline)',
fontSize: '14px',
fontWeight: 500,
lineHeight: 1.3,
color: 'var(--nui-text-primary)',
textDecoration: 'none',
}}>{article.title}</a>
</li>
))}
</ul>
</aside>
);
@@ -0,0 +1,88 @@
'use client';
import React, { CSSProperties } from 'react';
import { cx } from 'newspaperui-utils';
export interface AuthorCardProps {
name: string;
role?: string;
bio?: string;
avatar?: string;
email?: string;
className?: string;
style?: CSSProperties;
}
/**
* AuthorCard — 作者简介卡片(长篇报道末尾)
*
* @example
* <AuthorCard name="Eleanor Whitcombe" role="Senior Correspondent" bio="..." avatar="/avatar.jpg" />
*/
export const AuthorCard: React.FC<AuthorCardProps> = ({
name, role, bio, avatar, email, className, style,
}) => (
<div
className={cx('nui-author-card', className)}
style={{
borderTop: '1px solid var(--nui-rule-hairline)',
paddingTop: 'var(--nui-space-4)',
marginTop: 'var(--nui-space-6)',
display: 'flex',
gap: 'var(--nui-space-4)',
alignItems: 'flex-start',
...style,
}}
>
{avatar && (
<img
src={avatar}
alt={name}
loading="lazy"
style={{
width: '56px',
height: '56px',
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
}}
/>
)}
<div>
<div style={{
fontFamily: 'var(--font-family-meta)',
fontSize: '13px',
fontWeight: 600,
fontVariantCaps: 'small-caps',
letterSpacing: '0.06em',
color: 'var(--nui-text-primary)',
}}>{name}</div>
{role && (
<div style={{
fontFamily: 'var(--font-family-meta)',
fontSize: '12px',
color: 'var(--nui-text-muted)',
marginTop: '2px',
}}>{role}</div>
)}
{bio && (
<p style={{
fontFamily: 'var(--font-family-body)',
fontSize: '13px',
lineHeight: 1.5,
color: 'var(--nui-text-secondary)',
margin: 'var(--nui-space-2) 0 0 0',
}}>{bio}</p>
)}
{email && (
<a href={`mailto:${email}`} style={{
fontFamily: 'var(--font-family-meta)',
fontSize: '11px',
color: 'var(--nui-accent-primary)',
textDecoration: 'none',
marginTop: 'var(--nui-space-2)',
display: 'inline-block',
}}>{email}</a>
)}
</div>
</div>
);