Skip to content
Open
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
72 changes: 72 additions & 0 deletions frontend/src/components/translate/__tests__/WordCard.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import WordCard from '../WordCard.vue'

describe('WordCard', () => {
it('test_renders_word_and_phonetic', () => {
const wrapper = mount(WordCard, {
props: { word: 'hello', phonetic: 'həˈloʊ' },
})
expect(wrapper.find('.card-word').text()).toBe('hello')
expect(wrapper.find('.card-ipa').text()).toBe('həˈloʊ')
})

it('test_renders_loading_state', () => {
const wrapper = mount(WordCard, {
props: { word: 'hello', loading: true },
})
expect(wrapper.find('.card-loading').text()).toBe('加载中...')
})

it('test_renders_explanation', () => {
const wrapper = mount(WordCard, {
props: {
word: 'hello',
explanation: {
meaning: '你好',
meanings: ['打招呼'],
example: 'Hello world',
},
},
})
expect(wrapper.find('.card-meaning').text()).toBe('你好')
expect(wrapper.find('.card-meanings').text()).toContain('打招呼')
expect(wrapper.find('.card-example').text()).toContain('Hello world')
})

it('test_close_button_emits_close', async () => {
const wrapper = mount(WordCard, {
props: { word: 'hello' },
})
await wrapper.find('.card-close').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})

it('test_renders_without_explanation', () => {
const wrapper = mount(WordCard, {
props: { word: 'hello', explanation: null, loading: false },
})
expect(wrapper.find('.card-body').exists()).toBe(false)
})

it('test_renders_multiple_meanings', () => {
const wrapper = mount(WordCard, {
props: {
word: 'run',
explanation: {
meaning: '跑',
meanings: ['奔跑', '运行', '经营'],
example: null,
},
},
})
const items = wrapper.findAll('.meaning-item')
expect(items).toHaveLength(3)
expect(items[0].text()).toContain('1.')
expect(items[0].text()).toContain('奔跑')
expect(items[1].text()).toContain('2.')
expect(items[1].text()).toContain('运行')
expect(items[2].text()).toContain('3.')
expect(items[2].text()).toContain('经营')
})
})
93 changes: 93 additions & 0 deletions frontend/src/components/translate/__tests__/tokenize-edge.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest'
import { tokenize } from '../tokenize.js'

describe('tokenize — edge cases', () => {
it('emoji — "Hello 👋 world" → emoji surrogate halves are non-interactive punct tokens', () => {
// The tokenizer iterates by JS code unit (text[i]), so a 2-code-unit emoji
// like 👋 (U+1F44B) is split into two surrogate halves, each becoming a
// separate non-interactive punct token.
const tokens = tokenize('Hello 👋 world')
// Positions: Hello(word) ' '(space) \uD83D(punct) \uDC4B(punct) ' '(space) world(word)
expect(tokens).toHaveLength(6)
const surrogates = tokens.filter((t) => t.kind === 'punct')
expect(surrogates).toHaveLength(2)
surrogates.forEach((t) => {
expect(t.interactive).toBe(false)
})
})

it('consecutive punctuation — "Hello!!!" → 3 separate punct tokens', () => {
const tokens = tokenize('Hello!!!')
const excls = tokens.filter((t) => t.text === '!')
expect(excls).toHaveLength(3)
excls.forEach((t) => {
expect(t.kind).toBe('punct')
expect(t.interactive).toBe(false)
})
})

it('number-only — "123" → 3 non-interactive punct tokens (digits)', () => {
const tokens = tokenize('123')
expect(tokens).toHaveLength(3)
tokens.forEach((t) => {
expect(t.kind).toBe('punct')
expect(t.interactive).toBe(false)
})
})

it('mixed numbers and words — "abc123def" → "abc" word + "1","2","3" puncts + "def" word', () => {
const tokens = tokenize('abc123def')
expect(tokens).toHaveLength(5)
expect(tokens[0]).toMatchObject({ kind: 'word', text: 'abc', interactive: true })
expect(tokens[1]).toMatchObject({ kind: 'punct', text: '1', interactive: false })
expect(tokens[2]).toMatchObject({ kind: 'punct', text: '2', interactive: false })
expect(tokens[3]).toMatchObject({ kind: 'punct', text: '3', interactive: false })
expect(tokens[4]).toMatchObject({ kind: 'word', text: 'def', interactive: true })
})

it("trailing apostrophe — \"don't'\" → single word token \"don't'\" (greedy match)", () => {
const tokens = tokenize("don't'")
expect(tokens).toHaveLength(1)
expect(tokens[0]).toMatchObject({ kind: 'word', text: "don't'", interactive: true })
})

it('all CJK — "你好世界" → 4 interactive tokens, each single char', () => {
const tokens = tokenize('你好世界')
expect(tokens).toHaveLength(4)
;['你', '好', '世', '界'].forEach((ch, i) => {
expect(tokens[i]).toMatchObject({ kind: 'cjk', text: ch, interactive: true })
})
})

it('CJK with punctuation — "你好!世界。" → 6 tokens (你 好 ! 世 界 。)', () => {
const tokens = tokenize('你好!世界。')
expect(tokens).toHaveLength(6)
expect(tokens[0]).toMatchObject({ kind: 'cjk', text: '你', interactive: true })
expect(tokens[1]).toMatchObject({ kind: 'cjk', text: '好', interactive: true })
expect(tokens[2]).toMatchObject({ kind: 'punct', text: '!', interactive: false })
expect(tokens[3]).toMatchObject({ kind: 'cjk', text: '世', interactive: true })
expect(tokens[4]).toMatchObject({ kind: 'cjk', text: '界', interactive: true })
expect(tokens[5]).toMatchObject({ kind: 'punct', text: '。', interactive: false })
})

it('newlines — "hello\\nworld" → "hello" word + "\\n" space + "world" word', () => {
const tokens = tokenize('hello\nworld')
expect(tokens).toHaveLength(3)
expect(tokens[0]).toMatchObject({ kind: 'word', text: 'hello', interactive: true })
expect(tokens[1]).toMatchObject({ kind: 'space', text: '\n', interactive: false })
expect(tokens[2]).toMatchObject({ kind: 'word', text: 'world', interactive: true })
})

it('tab character — "hello\\tworld" → tab is space token', () => {
const tokens = tokenize('hello\tworld')
expect(tokens).toHaveLength(3)
expect(tokens[1]).toMatchObject({ kind: 'space', text: '\t', interactive: false })
})

it('very long word — "supercalifragilisticexpialidocious" → single word token', () => {
const word = 'supercalifragilisticexpialidocious'
const tokens = tokenize(word)
expect(tokens).toHaveLength(1)
expect(tokens[0]).toMatchObject({ kind: 'word', text: word, interactive: true })
})
})
6 changes: 3 additions & 3 deletions frontend/src/composables/__tests__/usePractice.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ describe('usePractice · 发音练习 API', () => {
const out = await getEawEpisode('episode with spaces')

expect(authFetchMock).toHaveBeenCalledWith(
'/bbc-eaw/episodes/episode%20with%20spaces',
'/articles/episodes/episode%20with%20spaces',
)
expect(out).toEqual(payload)
})
Expand Down Expand Up @@ -195,12 +195,12 @@ describe('usePractice · 发音练习 API', () => {
await expect(getSource(42)).rejects.toThrow('加载失败')
})

it('listEawEpisodes: GET /bbc-eaw/episodes?limit=100 默认 + 返回 JSON / throw on !ok', async () => {
it('listEawEpisodes: GET /articles/episodes?limit=100 默认 + 返回 JSON / throw on !ok', async () => {
const payload = { episodes: [{ slug: 'ep1' }, { slug: 'ep2' }] }
authFetchMock.mockResolvedValueOnce({ ok: true, json: async () => payload })
const { listEawEpisodes } = usePractice()
const out = await listEawEpisodes()
expect(authFetchMock).toHaveBeenCalledWith('/bbc-eaw/episodes?limit=100')
expect(authFetchMock).toHaveBeenCalledWith('/articles/episodes?limit=100')
expect(out).toEqual(payload)

authFetchMock.mockResolvedValueOnce({ ok: false })
Expand Down
166 changes: 166 additions & 0 deletions tests/test_articles_episodes_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Integration tests for /articles/episodes/* routes.

Covers:
- GET /articles/episodes (list, series filter)
- GET /articles/episodes/{slug} (detail, 404)
- POST /articles/episodes/{slug}/start (create card, 404)
- POST /articles/episodes/{slug}/rate (success, bad rating)
- GET /articles/due
"""

import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session as OrmSession

from main import app
from app.models.db import engine, ArticleEpisode, BbcArticleCard, BbcArticleQuestion
from app.routers.auth import get_current_user_id

USER = "test_user_episodes_integ"
SLUG = "test-ep-integ"


# ── Auth override ──────────────────────────────────────────────────────────────

app.dependency_overrides[get_current_user_id] = lambda: USER


@pytest.fixture(scope="module")
def client():
with TestClient(app) as c:
yield c


# ── Cleanup fixture ────────────────────────────────────────────────────────────

@pytest.fixture(autouse=True)
def _reset():
"""Wipe test rows before and after each test."""
def _clean():
with OrmSession(engine) as s:
s.query(BbcArticleCard).filter_by(user_id=USER).delete()
s.query(BbcArticleQuestion).filter_by(slug=SLUG).delete()
s.query(ArticleEpisode).filter_by(slug=SLUG).delete()
s.commit()

_clean()
with OrmSession(engine) as s:
s.add(ArticleEpisode(
series="bbc_eaw",
slug=SLUG,
url="https://example.com/test",
title="Test Episode",
description="Test",
transcript_json='[{"speaker":"A","text":"Hello world."}]',
phrases_json='["hello"]',
transcript_turns=1,
))
s.commit()

yield

_clean()


# ── Tests ──────────────────────────────────────────────────────────────────────

def test_list_episodes_returns_series_field(client):
resp = client.get("/articles/episodes")
assert resp.status_code == 200
data = resp.json()
assert "items" in data
matching = [item for item in data["items"] if item["slug"] == SLUG]
assert len(matching) == 1
assert matching[0]["series"] == "bbc_eaw"


def test_list_episodes_filter_by_series(client):
resp = client.get("/articles/episodes?series=bbc_eaw")
assert resp.status_code == 200
data = resp.json()
assert all(item["series"] == "bbc_eaw" for item in data["items"])
slugs = [item["slug"] for item in data["items"]]
assert SLUG in slugs


def test_list_episodes_filter_by_series_no_match(client):
resp = client.get("/articles/episodes?series=nonexistent_series")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0
assert data["items"] == []


def test_get_episode_returns_series_and_source_type(client):
resp = client.get(f"/articles/episodes/{SLUG}")
assert resp.status_code == 200
data = resp.json()
assert data["slug"] == SLUG
assert data["series"] == "bbc_eaw"
assert data["source_type"] == "article"
assert data["title"] == "Test Episode"
assert isinstance(data["segments"], list)
assert len(data["segments"]) == 1
assert data["segments"][0]["content"] == "Hello world."


def test_get_episode_not_found(client):
resp = client.get("/articles/episodes/nonexistent-slug-xyz")
assert resp.status_code == 404


def test_start_studying_creates_card(client):
resp = client.post(f"/articles/episodes/{SLUG}/start")
assert resp.status_code == 201
data = resp.json()
assert data["slug"] == SLUG
assert data["title"] == "Test Episode"
assert data["due"] is not None
assert isinstance(data["id"], int)


def test_start_studying_not_found(client):
resp = client.post("/articles/episodes/nonexistent-slug-xyz/start")
assert resp.status_code == 404


def test_rate_card(client):
# First create the card
start_resp = client.post(f"/articles/episodes/{SLUG}/start")
assert start_resp.status_code == 201

# Then rate it
resp = client.post(
f"/articles/episodes/{SLUG}/rate",
json={"rating": "good"},
)
assert resp.status_code == 200
data = resp.json()
assert data["slug"] == SLUG
assert data["review_count"] == 1
assert data["last_reviewed_at"] is not None
assert data["due"] is not None


def test_rate_invalid_rating(client):
# Create card first so the route reaches the rating validation
start_resp = client.post(f"/articles/episodes/{SLUG}/start")
assert start_resp.status_code == 201

resp = client.post(
f"/articles/episodes/{SLUG}/rate",
json={"rating": "meh"},
)
assert resp.status_code == 400


def test_due_returns_list(client):
resp = client.get("/articles/due")
assert resp.status_code == 200
data = resp.json()
# /articles/due is served by the articles router (prefix="/articles", route="/due")
# which returns {"cards": [...], "count": N}
assert "cards" in data
assert "count" in data
assert isinstance(data["cards"], list)
assert data["count"] == len(data["cards"])
Loading
Loading