Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions src/components/ReleaseCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ReleaseCard from './ReleaseCard';
import type { Release } from '../types';

vi.mock('../store/useAppStore', () => ({
useAppStore: vi.fn(() => ({
rpcDownloadConfig: { enabled: true, host: '', port: 6800 },
backendApiSecret: null,
aiConfigs: [],
activeAIConfig: null,
})),
}));

vi.mock('../hooks/useDialog', () => ({
useDialog: () => ({ toast: vi.fn(), confirm: vi.fn() }),
}));

vi.mock('../services/rpcDownloadService', () => ({
// 永不 resolve,避免异步 setState 触发 act() 警告;点击行为本身是同步的
sendToRpcDownload: vi.fn(() => new Promise(() => {})),
}));

vi.mock('./MarkdownRenderer', () => ({
default: () => null,
}));

vi.mock('../services/aiService', () => ({
AIService: vi.fn(),
}));

const makeRelease = (id: number, overrides: Partial<Release> = {}): Release => ({
id,
tag_name: `v${id}`,
name: `Release ${id}`,
body: null,
published_at: '2026-01-01T00:00:00.000Z',
html_url: `https://github.com/owner/repo/releases/tag/v${id}`,
assets: [
{
id: 101,
name: 'app.dmg',
size: 1000,
download_count: 5,
browser_download_url: 'https://example.com/app.dmg',
content_type: 'application/octet-stream',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-02T00:00:00.000Z',
},
],
repository: { id: 1, full_name: 'owner/repo', name: 'repo' },
...overrides,
});

const renderCard = (props: Partial<Parameters<typeof ReleaseCard>[0]> = {}) => {
const defaults: Parameters<typeof ReleaseCard>[0] = {
release: makeRelease(1, { updated_asset_ids: [101] }),
downloadLinks: [
{ name: 'app.dmg', url: 'https://example.com/app.dmg', size: 1000, downloadCount: 5, assetId: 101 },
],
isUnread: true,
isAssetsExpanded: true,
isReleaseNotesExpanded: false,
isFullContent: false,
truncatedBody: '',
matchesActiveFilters: () => true,
selectedFilters: [],
onToggleAssets: () => {},
onToggleReleaseNotes: () => {},
onToggleFullContent: () => {},
onUnsubscribe: () => {},
onMarkAsRead: () => {},
onMarkAssetAsRead: () => {},
language: 'zh',
formatFileSize: (bytes: number) => `${bytes} B`,
};
return render(<ReleaseCard {...defaults} {...props} />);
};

describe('ReleaseCard asset updated indicator', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('shows container-level and per-asset indicators from the same source (updated_asset_ids)', () => {
renderCard({ isUnread: true });
// 容器级与资产级标识都会展示
expect(screen.getAllByText('资产已更新')).toHaveLength(2);
});

it('shows no indicator once updated_asset_ids is cleared (release marked read)', () => {
renderCard({
isUnread: false,
release: makeRelease(1, { updated_asset_ids: [] }),
});
expect(screen.queryByText('资产已更新')).not.toBeInTheDocument();
});

it('regression: asset updated_at newer than published_at alone shows no indicator', () => {
// makeRelease 的资产 updated_at 晚于 published_at,但没有 updated_asset_ids
// (即资产相对上次拉取未变化)时不得出现任何“资产已更新”标识。
renderCard({
isUnread: true,
release: makeRelease(1, { updated_asset_ids: undefined }),
});
expect(screen.queryByText('资产已更新')).not.toBeInTheDocument();
});

it('does not show the indicator for assets without an asset id (source code links)', () => {
renderCard({
isUnread: true,
release: makeRelease(1, { updated_asset_ids: [101] }),
downloadLinks: [
{ name: 'Source code (v1.zip)', url: 'https://example.com/v1.zip', size: 0, downloadCount: 0 },
],
});
// 容器级标识仍展示(updated_asset_ids 非空),但源码行没有 assetId,不展示资产级标识
expect(screen.getAllByText('资产已更新')).toHaveLength(1);
});

it('marks only the clicked asset as read via onMarkAssetAsRead', () => {
const onMarkAssetAsRead = vi.fn();
renderCard({ onMarkAssetAsRead });

const assetRow = screen.getByRole('button', { name: /app\.dmg/ });
fireEvent.click(assetRow);

expect(onMarkAssetAsRead).toHaveBeenCalledTimes(1);
expect(onMarkAssetAsRead).toHaveBeenCalledWith(101);
});

it('does not propagate the asset click to the release-level mark-as-read handler', () => {
const onMarkAsRead = vi.fn();
const onMarkAssetAsRead = vi.fn();
renderCard({ onMarkAsRead, onMarkAssetAsRead });

const assetRow = screen.getByRole('button', { name: /app\.dmg/ });
fireEvent.click(assetRow);

expect(onMarkAsRead).not.toHaveBeenCalled();
expect(onMarkAssetAsRead).toHaveBeenCalledWith(101);
});
});
13 changes: 9 additions & 4 deletions src/components/ReleaseCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface ReleaseCardProps {
onToggleFullContent: (e: React.MouseEvent) => void;
onUnsubscribe: () => void;
onMarkAsRead: () => void;
onMarkAssetAsRead: (assetId: number) => void;
language: 'zh' | 'en';
formatFileSize: (bytes: number) => string;
}
Expand All @@ -61,13 +62,14 @@ const ReleaseCard: React.FC<ReleaseCardProps> = memo(({
onToggleFullContent,
onUnsubscribe,
onMarkAsRead,
onMarkAssetAsRead,
language,
formatFileSize,
}) => {
const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]);

const effectiveTime = effectiveReleaseTime(release);
const showAssetsUpdatedIndicator = shouldShowAssetsUpdatedIndicator(release, isUnread);
const showAssetsUpdatedIndicator = shouldShowAssetsUpdatedIndicator(release);

// RPC download support — use refs to avoid stale closure in async handler
const { rpcDownloadConfig, backendApiSecret, aiConfigs, activeAIConfig } = useAppStore();
Expand Down Expand Up @@ -365,8 +367,7 @@ const ReleaseCard: React.FC<ReleaseCardProps> = memo(({
const isRpcEnabled = rpcDownloadConfig.enabled;
const isDownloading = downloadingRef.current[link.url];
const isDownloaded = downloadedRef.current[link.url];
const isAssetUpdated = isUnread
&& link.assetId !== undefined
const isAssetUpdated = link.assetId !== undefined
&& release.updated_asset_ids?.includes(link.assetId) === true;

if (isRpcEnabled) {
Expand All @@ -375,6 +376,7 @@ const ReleaseCard: React.FC<ReleaseCardProps> = memo(({
key={index}
onClick={(e) => {
e.stopPropagation();
if (link.assetId !== undefined) onMarkAssetAsRead(link.assetId);
handleRpcDownload(link);
}}
disabled={isDownloading || isDownloaded}
Expand Down Expand Up @@ -422,7 +424,10 @@ const ReleaseCard: React.FC<ReleaseCardProps> = memo(({
className={`flex items-center justify-between px-4 py-3 hover:bg-light-surface dark:hover:bg-white/[0.06] transition-colors border-b border-black/[0.04] dark:border-white/[0.04] last:border-b-0 ${
link.isSourceCode ? 'bg-gray-100 dark:bg-white/[0.04]' : ''
}`}
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
if (link.assetId !== undefined) onMarkAssetAsRead(link.assetId);
}}
>
<div className="flex items-center space-x-1.5 min-w-0 flex-1">
{link.isSourceCode ? (
Expand Down
11 changes: 9 additions & 2 deletions src/components/ReleaseTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const ReleaseTimeline: React.FC = () => {
addReleases,
upsertReleases,
markReleaseAsRead,
markAssetAsRead,
markAllReleasesAsRead,
batchUnsubscribeReleases,
removeReleasesByRepoFullName,
Expand Down Expand Up @@ -1241,6 +1242,7 @@ export const ReleaseTimeline: React.FC = () => {
onToggleFullContent={(e) => toggleFullContent(release.id, e)}
onUnsubscribe={() => handleUnsubscribeRelease(release.repository.id)}
onMarkAsRead={() => markReleaseAsRead(release.id)}
onMarkAssetAsRead={markAssetAsRead}
language={language}
formatFileSize={formatFileSize}
/>
Expand All @@ -1254,8 +1256,12 @@ export const ReleaseTimeline: React.FC = () => {
const latestEffectiveTime = latestUpdatedRelease
? effectiveReleaseTime(latestUpdatedRelease)
: null;
const latestAssetsUpdated = latestUpdatedRelease !== null
&& shouldShowAssetsUpdatedIndicator(latestUpdatedRelease, isReleaseUnread(latestUpdatedRelease.id));
// 仓库分组头部的“资产已更新”与资产行共用同一事实来源(updated_asset_ids),
// 只要组内任一 Release 存在未清除的资产级标识就展示,避免“头部有标识、
// 展开后无任何资产行带标识”的不一致。
const latestAssetsUpdated = releases.some(
({ release }) => shouldShowAssetsUpdatedIndicator(release)
);

return (
<div key={repository.id} className="ui-card overflow-hidden">
Expand Down Expand Up @@ -1341,6 +1347,7 @@ export const ReleaseTimeline: React.FC = () => {
onToggleFullContent={(e) => toggleFullContent(release.id, e)}
onUnsubscribe={() => handleUnsubscribeRelease(release.repository.id)}
onMarkAsRead={() => markReleaseAsRead(release.id)}
onMarkAssetAsRead={markAssetAsRead}
language={language}
formatFileSize={formatFileSize}
/>
Expand Down
142 changes: 116 additions & 26 deletions src/store/useAppStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,30 +69,30 @@ describe('useAppStore release source settings', () => {
});
});

describe('useAppStore release add/upsert actions', () => {
const makeRelease = (id: number, overrides: Partial<Release> = {}): Release => ({
id,
tag_name: `v${id}`,
name: `Release ${id}`,
body: null,
published_at: '2026-01-01T00:00:00.000Z',
html_url: `https://github.com/owner/repo/releases/tag/v${id}`,
assets: [
{
id: 100 + id,
name: 'app.dmg',
size: 1000,
download_count: 0,
browser_download_url: 'https://example.com/app.dmg',
content_type: 'application/octet-stream',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
},
],
repository: { id: 1, full_name: 'owner/repo', name: 'repo' },
...overrides,
});
const makeRelease = (id: number, overrides: Partial<Release> = {}): Release => ({
id,
tag_name: `v${id}`,
name: `Release ${id}`,
body: null,
published_at: '2026-01-01T00:00:00.000Z',
html_url: `https://github.com/owner/repo/releases/tag/v${id}`,
assets: [
{
id: 100 + id,
name: 'app.dmg',
size: 1000,
download_count: 0,
browser_download_url: 'https://example.com/app.dmg',
content_type: 'application/octet-stream',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
},
],
repository: { id: 1, full_name: 'owner/repo', name: 'repo' },
...overrides,
});

describe('useAppStore release add/upsert actions', () => {
beforeEach(() => {
useAppStore.setState({
releaseSourceSettings: defaultReleaseSourceSettings,
Expand Down Expand Up @@ -178,12 +178,102 @@ describe('useAppStore release add/upsert actions', () => {
expect(merged.is_read).toBe(false);
expect(merged.updated_asset_ids).toEqual([101, 999]);
expect(isUnread()).toBe(true);
expect(shouldShowAssetsUpdatedIndicator(merged, isUnread())).toBe(true);
expect(shouldShowAssetsUpdatedIndicator(merged)).toBe(true);

// 用户再次点击该条 release → 已读,"资产已更新"消失
// 用户再次点击该条 release → 已读,"资产已更新"随 updated_asset_ids 清空而消失
useAppStore.getState().markReleaseAsRead(1);
expect(isUnread()).toBe(false);
expect(shouldShowAssetsUpdatedIndicator(merged, isUnread())).toBe(false);
const afterRead = useAppStore.getState().releases.find(r => r.id === 1)!;
expect(afterRead.updated_asset_ids).toEqual([]);
expect(shouldShowAssetsUpdatedIndicator(afterRead)).toBe(false);
});

it('regression: asset updated_at newer than published_at alone does not show the indicator', () => {
// GitHub 上资产几乎都在 Release 创建后上传(updated_at > published_at 常态),
// 只有"相对上次拉取发生了变化"(updated_asset_ids 非空)才允许展示标识。
const publishedAt = '2026-01-01T00:00:00.000Z';
useAppStore.getState().addReleases([makeRelease(1, {
published_at: publishedAt,
assets: [{ ...makeRelease(1).assets[0], updated_at: '2026-01-02T00:00:00.000Z' }],
})]);

const fresh = useAppStore.getState().releases.find(r => r.id === 1)!;
expect(fresh.updated_asset_ids).toBeUndefined();
expect(shouldShowAssetsUpdatedIndicator(fresh)).toBe(false);
});
});

describe('useAppStore release asset read actions', () => {
beforeEach(() => {
useAppStore.setState({
releaseSourceSettings: defaultReleaseSourceSettings,
releaseSubscriptions: new Set<number>(),
releases: [],
readReleases: new Set<number>(),
});
});

it('markAssetAsRead removes only the clicked asset id from updated_asset_ids', () => {
useAppStore.getState().addReleases([
makeRelease(1, { updated_asset_ids: [101, 202, 303] }),
makeRelease(2, { updated_asset_ids: [404] }),
]);

useAppStore.getState().markAssetAsRead(202);

const first = useAppStore.getState().releases.find(r => r.id === 1)!;
const second = useAppStore.getState().releases.find(r => r.id === 2)!;
expect(first.updated_asset_ids).toEqual([101, 303]);
expect(second.updated_asset_ids).toEqual([404]);
});

it('markAssetAsRead does not touch the release read state', () => {
useAppStore.getState().addReleases([makeRelease(1, { updated_asset_ids: [101] })]);
useAppStore.getState().markAssetAsRead(101);

expect(useAppStore.getState().readReleases.has(1)).toBe(false);
expect(useAppStore.getState().releases[0].is_read).toBeUndefined();
});

it('markAssetAsRead is a no-op for unknown asset ids', () => {
useAppStore.getState().addReleases([makeRelease(1, { updated_asset_ids: [101] })]);
useAppStore.getState().markAssetAsRead(999);
expect(useAppStore.getState().releases[0].updated_asset_ids).toEqual([101]);
});

it('markReleaseAsRead clears updated_asset_ids only on the clicked release', () => {
useAppStore.getState().addReleases([
makeRelease(1, { updated_asset_ids: [101] }),
makeRelease(2, { updated_asset_ids: [202] }),
]);

useAppStore.getState().markReleaseAsRead(1);

expect(useAppStore.getState().releases[0].updated_asset_ids).toEqual([]);
expect(useAppStore.getState().releases[1].updated_asset_ids).toEqual([202]);
});

it('markAllReleasesAsRead clears updated_asset_ids and sets is_read across releases', () => {
useAppStore.getState().addReleases([
makeRelease(1, { updated_asset_ids: [101, 202] }),
makeRelease(2, { updated_asset_ids: [404] }),
]);

useAppStore.getState().markAllReleasesAsRead();

expect(useAppStore.getState().readReleases.has(1)).toBe(true);
expect(useAppStore.getState().readReleases.has(2)).toBe(true);
expect(useAppStore.getState().releases[0].updated_asset_ids).toEqual([]);
expect(useAppStore.getState().releases[1].updated_asset_ids).toEqual([]);
expect(useAppStore.getState().releases[0].is_read).toBe(true);
expect(useAppStore.getState().releases[1].is_read).toBe(true);
});

it('markAllReleasesAsRead leaves releases without updated_asset_ids untouched', () => {
useAppStore.getState().addReleases([makeRelease(1)]);
useAppStore.getState().markAllReleasesAsRead();
expect(useAppStore.getState().releases[0].updated_asset_ids).toBeUndefined();
expect(useAppStore.getState().releases[0].is_read).toBe(true);
});
});

Expand Down
Loading