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
18 changes: 15 additions & 3 deletions e2e/listen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ test.describe('Listen Module', () => {
await expect(page.getByText('行动')).toHaveCount(0);
});

test('listen popup shows sanitized translation, favorite success, and speech comparison', async ({ page }) => {
test('listen popup keeps partial selections scoped and favorite interactions stay in sync', async ({ page }) => {
await setupSelectionTranslationMocks(page);
await page.goto('/listen/book/junior-high');
await expect(page.getByText('Listen Mode')).toBeVisible({ timeout: 15000 });
Expand All @@ -296,21 +296,33 @@ test.describe('Listen Module', () => {

const popup = page.getByRole('dialog', { name: 'Translation popup' });
await expect(popup).toBeVisible({ timeout: 10000 });
await expect(popup.locator('span.text-sm.font-medium.text-slate-900')).toHaveText('action');
await expect(popup.getByText('行动', { exact: true })).toBeVisible();
await expect(popup.locator('p.text-xs.leading-relaxed.text-slate-500')).toContainText(
'The government must take action now to stop the rise in violent crime.',
);
await expect(popup).not.toContainText('=');

await popup.getByRole('button', { name: '♡ 收藏' }).click();
await expect(popup.getByRole('button', { name: '✓ 已收藏' })).toBeVisible();
await expect(popup.getByRole('button', { name: '取消收藏' })).toBeVisible();

await popup.getByLabel('选择收藏夹').selectOption('auto');
await expect(popup.getByRole('button', { name: '移动到此收藏夹' })).toBeVisible();
await popup.getByRole('button', { name: '移动到此收藏夹' }).click();
await expect(popup.getByRole('button', { name: '取消收藏' })).toBeVisible();

await popup.getByRole('button', { name: 'Speak' }).click();
const spokenTexts = await page.evaluate(() => (window as any).__spokenTexts as string[]);
expect(spokenTexts[0]).toBe('The government must take action now to stop the rise in violent crime.');
expect(spokenTexts[0]).toBe('action');
expect(spokenTexts[0]).not.toContain('=');

await popup.getByRole('button', { name: 'Record speech' }).click();
await expect(popup.getByText('action ✓')).toBeVisible({ timeout: 3000 });

await page.goto('/favorites');
await expect(page.getByRole('heading', { level: 1, name: 'Favorites' })).toBeVisible();
await page.getByRole('button', { name: '🤖 智能收藏' }).click();
await expect(page.getByText('action', { exact: true })).toBeVisible();
await expect(page.getByText('行动', { exact: true })).toBeVisible();
});
});
55 changes: 55 additions & 0 deletions src/app/api/translate/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,59 @@ describe('POST /api/translate', () => {
pronunciation: 'træʃ',
});
});

it('extracts structured translation JSON surrounded by model commentary', async () => {
generateTextMock.mockResolvedValue({
text: `Here is the translation:
{
"itemTranslation": "绝对美味",
"exampleSentence": "The food was absolutely delicious.",
"exampleTranslation": "食物非常美味。"
}
Hope this helps!`,
});

const response = await POST(
makeRequest({
text: 'absolutely delicious',
context: 'The food was absolutely delicious.',
targetLang: 'zh-CN',
selectionType: 'phrase',
}),
);

expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
translation: '绝对美味',
itemTranslation: '绝对美味',
exampleSentence: 'The food was absolutely delicious.',
exampleTranslation: '食物非常美味。',
});
});

it('does not expose the raw JSON response when itemTranslation is empty', async () => {
generateTextMock.mockResolvedValue({
text: JSON.stringify({
itemTranslation: '',
exampleSentence: 'The food was absolutely delicious.',
exampleTranslation: '',
related: { relatedPhrases: ['extremely tasty'] },
}),
});

const response = await POST(
makeRequest({
text: 'absolutely delicious',
context: 'The food was absolutely delicious.',
targetLang: 'zh-CN',
selectionType: 'phrase',
}),
);

expect(response.status).toBe(200);
const payload = await response.json();
expect(payload.translation).toBe('');
expect(payload.itemTranslation).toBe('');
expect(payload.translation).not.toContain('itemTranslation');
});
});
33 changes: 16 additions & 17 deletions src/app/api/translate/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { generateText } from 'ai';
import { NextRequest, NextResponse } from 'next/server';
import { resolveApiKey, resolveModel } from '@/lib/ai-model';
import { parseAIJson } from '@/lib/parse-ai-json';
import { enforcePlatformRateLimit } from '@/lib/platform-provider';
import { ProviderResolutionError, resolveProviderForCapability } from '@/lib/provider-resolver';
import { type ProviderConfig, type ProviderId } from '@/lib/providers';
Expand Down Expand Up @@ -146,13 +147,10 @@ export async function POST(req: NextRequest) {
const prompt = context ? `${text ?? ''}\n\nContext: ${context}` : (text ?? '');
const { text: result } = await generateText({ model, system, prompt });

try {
const cleaned = result
.trim()
.replace(/^```json?\s*/, '')
.replace(/\s*```$/, '');
const parsed = JSON.parse(cleaned) as Partial<SelectionTranslateResponse> & { translation?: string };
const itemTranslation = parsed.itemTranslation || parsed.translation || result;
const parsedResult = parseAIJson<Partial<SelectionTranslateResponse> & { translation?: string }>(result);
if (parsedResult.data) {
const parsed = parsedResult.data;
const itemTranslation = parsed.itemTranslation?.trim() || parsed.translation?.trim() || '';
return NextResponse.json({
translation: itemTranslation,
itemTranslation,
Expand All @@ -165,17 +163,18 @@ export async function POST(req: NextRequest) {
fallbackApplied: resolution.fallbackApplied,
fallbackReason: resolution.fallbackReason,
});
} catch {
// Fallback: return raw text as translation
return NextResponse.json({
translation: result,
itemTranslation: result,
providerId: resolution.providerId,
credentialSource: resolution.credentialSource,
fallbackApplied: resolution.fallbackApplied,
fallbackReason: resolution.fallbackReason,
});
}

// Some providers ignore the JSON instruction and return a plain translation.
const plainTranslation = result.trim();
return NextResponse.json({
translation: plainTranslation,
itemTranslation: plainTranslation,
providerId: resolution.providerId,
credentialSource: resolution.credentialSource,
fallbackApplied: resolution.fallbackApplied,
fallbackReason: resolution.fallbackReason,
});
}

// Single text fallback (original behavior)
Expand Down
45 changes: 45 additions & 0 deletions src/components/favorites/favorite-item-row.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';

vi.mock('@/lib/tauri', () => ({
detectIOSNativeHost: () => false,
}));

vi.mock('@/stores/favorite-store', () => ({
useFavoriteStore: (selector: (state: unknown) => unknown) =>
selector({
removeFavorite: vi.fn(),
}),
}));

import { FavoriteItemRow } from './favorite-item-row';

describe('FavoriteItemRow', () => {
it('renders the review status as a filled metadata pill inside the content area', () => {
const markup = renderToStaticMarkup(
<FavoriteItemRow
item={{
id: 'favorite-1',
text: 'We are running short on time',
normalizedText: 'we are running short on time',
translation: '我们时间不太充裕了',
type: 'sentence',
folderId: 'default',
targetLang: 'zh-CN',
autoCollected: false,
createdAt: 1,
updatedAt: 1,
}}
isExpanded={false}
onToggle={vi.fn()}
/>,
);

const statusIndex = markup.indexOf('data-testid="favorite-status-favorite-1"');
const playIndex = markup.indexOf('aria-label="Play favorite');

expect(statusIndex).toBeGreaterThan(-1);
expect(markup).toContain('bg-slate-100');
expect(statusIndex).toBeLessThan(playIndex);
});
});
65 changes: 29 additions & 36 deletions src/components/favorites/favorite-item-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ const TYPE_BADGE = {
} as const;

function getReviewStatus(item: FavoriteItem): { label: string; color: string } {
if (!item.fsrsCard) return { label: 'New', color: 'text-slate-500' };
if (!item.fsrsCard) return { label: 'New', color: 'bg-slate-100 text-slate-500' };
const state = item.fsrsCard.state;
if (state === 0) return { label: 'New', color: 'text-slate-500' };
if (state === 1 || state === 3) return { label: 'Learning', color: 'text-amber-600' };
if (state === 2) return { label: 'Mastered', color: 'text-emerald-600' };
return { label: 'Due', color: 'text-amber-600' };
if (state === 0) return { label: 'New', color: 'bg-slate-100 text-slate-500' };
if (state === 1 || state === 3) return { label: 'Learning', color: 'bg-amber-50 text-amber-600' };
if (state === 2) return { label: 'Mastered', color: 'bg-emerald-50 text-emerald-600' };
return { label: 'Due', color: 'bg-amber-50 text-amber-600' };
}

export function FavoriteItemRow({ item, isExpanded, onToggle }: Props) {
Expand Down Expand Up @@ -97,39 +97,32 @@ export function FavoriteItemRow({ item, isExpanded, onToggle }: Props) {
<p className={cn('truncate text-slate-500', isIOSNativeHost ? 'mt-0.5 text-[13px]' : 'text-xs')}>
{item.translation}
</p>
{isIOSNativeHost ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className={cn(IOS_PILL_CLASS, 'px-2.5 py-1 text-[10px] font-medium')}>{reviewStatus.label}</span>
{item.autoCollected ? (
<span className={cn(IOS_PILL_CLASS, 'px-2.5 py-1 text-[10px] font-medium')}>AI saved</span>
) : null}
</div>
) : null}
<div className={cn('flex flex-wrap items-center gap-2', isIOSNativeHost ? 'mt-2' : 'mt-1')}>
<span
data-testid={`favorite-status-${item.id}`}
className={cn(
'rounded-full font-medium',
isIOSNativeHost ? cn(IOS_PILL_CLASS, 'px-2.5 py-1 text-[10px]') : 'px-2 py-0.5 text-[10px]',
reviewStatus.color,
)}
>
{reviewStatus.label}
</span>
{item.autoCollected ? (
<span
className={cn(
'rounded-full font-medium',
isIOSNativeHost
? cn(IOS_PILL_CLASS, 'px-2.5 py-1 text-[10px]')
: 'bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500',
)}
>
{isIOSNativeHost ? 'AI saved' : 'AI'}
</span>
) : null}
</div>
</div>

{!isIOSNativeHost && item.autoCollected && (
<span
className={cn(
'shrink-0 rounded-full bg-slate-100 text-slate-500',
isIOSNativeHost ? 'px-2 py-1 text-[10px]' : 'px-1.5 py-0.5 text-[10px]',
)}
>
AI
</span>
)}

{!isIOSNativeHost ? (
<span
className={cn(
'shrink-0 rounded-full border border-slate-200 bg-white text-[10px]',
isIOSNativeHost ? 'px-2.5 py-1 font-medium' : 'px-0 py-0',
reviewStatus.color,
)}
>
{reviewStatus.label}
</span>
) : null}

<div
className={cn(
'flex items-center shrink-0 transition-opacity',
Expand Down
33 changes: 33 additions & 0 deletions src/components/favorites/folder-chips.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';

vi.mock('@/lib/tauri', () => ({
detectIOSNativeHost: () => false,
}));

vi.mock('@/stores/favorite-store', () => ({
useFavoriteStore: (selector: (state: unknown) => unknown) =>
selector({
folders: [
{ id: 'default', name: '默认收藏', emoji: '⭐', sortOrder: 0, createdAt: 1 },
{ id: 'empty-folder', name: '单词收藏', emoji: '📚', sortOrder: 1, createdAt: 2 },
],
favorites: [],
activeFolderId: null,
setActiveFolderId: vi.fn(),
}),
}));

vi.mock('./folder-manage-dialog', () => ({
FolderManageDialog: () => null,
}));

import { FolderChips } from './folder-chips';

describe('FolderChips', () => {
it('shows newly created folders even when they do not contain favorites yet', () => {
const markup = renderToStaticMarkup(<FolderChips />);

expect(markup).toContain('📚 单词收藏');
});
});
7 changes: 1 addition & 6 deletions src/components/favorites/folder-chips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,9 @@ import { FolderManageDialog } from './folder-manage-dialog';
export function FolderChips() {
const isIOSNativeHost = detectIOSNativeHost();
const folders = useFavoriteStore((s) => s.folders);
const favorites = useFavoriteStore((s) => s.favorites);
const activeFolderId = useFavoriteStore((s) => s.activeFolderId);
const setActiveFolderId = useFavoriteStore((s) => s.setActiveFolderId);
const [showManage, setShowManage] = useState(false);
const visibleFolders = folders.filter((folder) => {
if (folder.id === activeFolderId) return true;
return favorites.some((favorite) => favorite.folderId === folder.id);
});
const getFolderLabel = (folder: (typeof folders)[number]) => {
if (!isIOSNativeHost) return folder.name;
if (folder.id === 'default') return 'Default';
Expand Down Expand Up @@ -55,7 +50,7 @@ export function FolderChips() {
All
</button>

{visibleFolders.map((f) => (
{folders.map((f) => (
<button
key={f.id}
type="button"
Expand Down
Loading
Loading