DB, Collections, Search
This commit is contained in:
113
src/lib/auth.ts
Normal file
113
src/lib/auth.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import NextAuth from "next-auth"
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
import bcrypt from "bcrypt"
|
||||
import { prisma } from "./prisma"
|
||||
import { UserRole } from "@prisma/client"
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
adapter: PrismaAdapter(prisma),
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email as string }
|
||||
})
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(
|
||||
credentials.password as string,
|
||||
user.password
|
||||
)
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
signOut: "/",
|
||||
error: "/login",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id
|
||||
token.role = (user as any).role
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user && token.id) {
|
||||
(session.user as any).id = token.id as string
|
||||
(session.user as any).role = token.role as string
|
||||
}
|
||||
return session
|
||||
}
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if the current user is an admin
|
||||
*/
|
||||
export async function isAdmin(): Promise<boolean> {
|
||||
const session = await auth()
|
||||
return (session?.user as any)?.role === UserRole.ADMIN
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is an admin or moderator
|
||||
*/
|
||||
export async function isAdminOrModerator(): Promise<boolean> {
|
||||
const session = await auth()
|
||||
return (
|
||||
(session?.user as any)?.role === UserRole.ADMIN ||
|
||||
(session?.user as any)?.role === UserRole.MODERATOR
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin role, throw error if not authorized
|
||||
*/
|
||||
export async function requireAdmin() {
|
||||
const admin = await isAdmin()
|
||||
if (!admin) {
|
||||
throw new Error("Unauthorized: Admin access required")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin or moderator role, throw error if not authorized
|
||||
*/
|
||||
export async function requireAdminOrModerator() {
|
||||
const authorized = await isAdminOrModerator()
|
||||
if (!authorized) {
|
||||
throw new Error("Unauthorized: Admin or Moderator access required")
|
||||
}
|
||||
}
|
||||
250
src/lib/import/csv-parser.test.ts
Normal file
250
src/lib/import/csv-parser.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { parseCSV, generateCSVTemplate } from "./csv-parser"
|
||||
|
||||
describe("parseCSV", () => {
|
||||
it("should parse valid CSV with all fields", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning,hsk_level,radical,frequency,pos,classifiers
|
||||
爱好,愛好,ài hào,"to like; hobby","new-1,old-3",爫,4902,"n,v",个`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.failed).toBe(0)
|
||||
expect(data).toHaveLength(1)
|
||||
expect(data[0].simplified).toBe("爱好")
|
||||
expect(data[0].radical).toBe("爫")
|
||||
expect(data[0].frequency).toBe(4902)
|
||||
expect(data[0].hskLevels).toEqual(["new-1", "old-3"])
|
||||
expect(data[0].partsOfSpeech).toEqual(["n", "v"])
|
||||
expect(data[0].forms).toHaveLength(1)
|
||||
expect(data[0].forms[0].traditional).toBe("愛好")
|
||||
expect(data[0].forms[0].classifiers).toEqual(["个"])
|
||||
})
|
||||
|
||||
it("should parse CSV with only required fields", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(1)
|
||||
expect(data[0].simplified).toBe("好")
|
||||
expect(data[0].radical).toBeUndefined()
|
||||
expect(data[0].frequency).toBeUndefined()
|
||||
expect(data[0].hskLevels).toEqual([])
|
||||
expect(data[0].partsOfSpeech).toEqual([])
|
||||
})
|
||||
|
||||
it("should parse multiple rows", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good
|
||||
爱,愛,ài,love
|
||||
你,你,nǐ,you`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(3)
|
||||
expect(data).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("should handle quoted values with commas", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,"good, fine, nice"`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].meanings[0].meaning).toBe("good, fine, nice")
|
||||
})
|
||||
|
||||
it("should handle quoted values with semicolons (multiple meanings)", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,"good; fine; nice"`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].meanings).toHaveLength(3)
|
||||
expect(data[0].forms[0].meanings[0].meaning).toBe("good")
|
||||
expect(data[0].forms[0].meanings[1].meaning).toBe("fine")
|
||||
expect(data[0].forms[0].meanings[2].meaning).toBe("nice")
|
||||
})
|
||||
|
||||
it("should handle escaped quotes in values", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,"He said ""good"""`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].meanings[0].meaning).toBe('He said "good"')
|
||||
})
|
||||
|
||||
it("should skip empty lines", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good
|
||||
|
||||
爱,愛,ài,love
|
||||
|
||||
你,你,nǐ,you`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(3)
|
||||
})
|
||||
|
||||
it("should parse comma-separated HSK levels", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning,hsk_level
|
||||
好,好,hǎo,good,"new-1,old-2,old-3"`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].hskLevels).toEqual(["new-1", "old-2", "old-3"])
|
||||
})
|
||||
|
||||
it("should parse comma-separated parts of speech", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning,hsk_level,radical,frequency,pos
|
||||
好,好,hǎo,good,,,,"adj,v,n"`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].partsOfSpeech).toEqual(["adj", "v", "n"])
|
||||
})
|
||||
|
||||
it("should parse comma-separated classifiers", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning,hsk_level,radical,frequency,pos,classifiers
|
||||
好,好,hǎo,good,,,,,"个,只,条"`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].classifiers).toEqual(["个", "只", "条"])
|
||||
})
|
||||
|
||||
it("should parse frequency as number", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning,hsk_level,radical,frequency
|
||||
好,好,hǎo,good,,,1234`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].frequency).toBe(1234)
|
||||
})
|
||||
|
||||
it("should return error for empty CSV", () => {
|
||||
const csv = ""
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
expect(result.errors[0].error).toContain("Invalid CSV headers")
|
||||
})
|
||||
|
||||
it("should return error for invalid headers", () => {
|
||||
const csv = `wrong,headers
|
||||
好,好`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
expect(result.errors[0].error).toContain("Invalid CSV headers")
|
||||
})
|
||||
|
||||
it("should return error for missing required fields", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,,good`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should continue parsing after errors", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good
|
||||
爱,愛,,love
|
||||
你,你,nǐ,you`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.imported).toBe(2)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(data).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should set first form as default", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].isDefault).toBe(true)
|
||||
})
|
||||
|
||||
it("should create pinyin transcription", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].transcriptions).toHaveLength(1)
|
||||
expect(data[0].forms[0].transcriptions[0].type).toBe("pinyin")
|
||||
expect(data[0].forms[0].transcriptions[0].value).toBe("hǎo")
|
||||
})
|
||||
|
||||
it("should set language code to English", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].meanings[0].languageCode).toBe("en")
|
||||
})
|
||||
|
||||
it("should assign order indices to meanings", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,"good; fine; nice"`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].meanings[0].orderIndex).toBe(0)
|
||||
expect(data[0].forms[0].meanings[1].orderIndex).toBe(1)
|
||||
expect(data[0].forms[0].meanings[2].orderIndex).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateCSVTemplate", () => {
|
||||
it("should generate valid CSV template", () => {
|
||||
const template = generateCSVTemplate()
|
||||
|
||||
expect(template).toContain("simplified,traditional,pinyin,meaning")
|
||||
expect(template).toContain("爱好,愛好,ài hào")
|
||||
|
||||
const lines = template.split("\n")
|
||||
expect(lines).toHaveLength(2) // Header + example
|
||||
})
|
||||
|
||||
it("should have parseable template", () => {
|
||||
const template = generateCSVTemplate()
|
||||
|
||||
const { result } = parseCSV(template)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(1)
|
||||
})
|
||||
})
|
||||
249
src/lib/import/csv-parser.ts
Normal file
249
src/lib/import/csv-parser.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { z } from "zod"
|
||||
import type {
|
||||
CSVRow,
|
||||
ParsedHanzi,
|
||||
ImportResult,
|
||||
ImportError,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Zod schema for CSV row validation
|
||||
*/
|
||||
const CSVRowSchema = z.object({
|
||||
simplified: z.string().min(1),
|
||||
traditional: z.string().min(1),
|
||||
pinyin: z.string().min(1),
|
||||
meaning: z.string().min(1),
|
||||
hsk_level: z.string().optional(),
|
||||
radical: z.string().optional(),
|
||||
frequency: z.string().optional(),
|
||||
pos: z.string().optional(),
|
||||
classifiers: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Parse CSV format
|
||||
* Expected format:
|
||||
* simplified,traditional,pinyin,meaning,hsk_level,radical,frequency,pos,classifiers
|
||||
*/
|
||||
export function parseCSV(csvString: string): {
|
||||
result: ImportResult
|
||||
data: ParsedHanzi[]
|
||||
} {
|
||||
const errors: ImportError[] = []
|
||||
const parsed: ParsedHanzi[] = []
|
||||
const lines = csvString.trim().split("\n")
|
||||
|
||||
if (lines.length === 0) {
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
imported: 0,
|
||||
failed: 0,
|
||||
errors: [{ error: "Empty CSV file" }],
|
||||
},
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Parse header
|
||||
const headerLine = lines[0]
|
||||
const headers = parseCSVLine(headerLine)
|
||||
|
||||
if (!validateHeaders(headers)) {
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
imported: 0,
|
||||
failed: 0,
|
||||
errors: [{
|
||||
error: `Invalid CSV headers. Expected at least: simplified,traditional,pinyin,meaning. Got: ${headers.join(",")}`,
|
||||
}],
|
||||
},
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Parse data rows
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (!line) continue // Skip empty lines
|
||||
|
||||
try {
|
||||
const values = parseCSVLine(line)
|
||||
const row = parseCSVRow(headers, values)
|
||||
const validationResult = CSVRowSchema.safeParse(row)
|
||||
|
||||
if (!validationResult.success) {
|
||||
throw new Error(
|
||||
validationResult.error.errors
|
||||
.map(e => `${e.path.join(".")}: ${e.message}`)
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
const parsedEntry = transformCSVRow(validationResult.data)
|
||||
parsed.push(parsedEntry)
|
||||
} catch (error) {
|
||||
const simplified = line.split(",")[0] || "unknown"
|
||||
errors.push({
|
||||
line: i + 1,
|
||||
character: simplified,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
success: errors.length === 0,
|
||||
imported: parsed.length,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
},
|
||||
data: parsed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CSV line handling quoted values
|
||||
*/
|
||||
function parseCSVLine(line: string): string[] {
|
||||
const values: string[] = []
|
||||
let current = ""
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i]
|
||||
const nextChar = line[i + 1]
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && nextChar === '"') {
|
||||
// Escaped quote
|
||||
current += '"'
|
||||
i++
|
||||
} else {
|
||||
// Toggle quote state
|
||||
inQuotes = !inQuotes
|
||||
}
|
||||
} else if (char === "," && !inQuotes) {
|
||||
// End of field
|
||||
values.push(current.trim())
|
||||
current = ""
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
}
|
||||
|
||||
// Add last field
|
||||
values.push(current.trim())
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CSV headers
|
||||
*/
|
||||
function validateHeaders(headers: string[]): boolean {
|
||||
const required = ["simplified", "traditional", "pinyin", "meaning"]
|
||||
return required.every(h => headers.includes(h))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert CSV values array to row object
|
||||
*/
|
||||
function parseCSVRow(headers: string[], values: string[]): CSVRow {
|
||||
const row: any = {}
|
||||
headers.forEach((header, index) => {
|
||||
const value = values[index]?.trim()
|
||||
if (value) {
|
||||
row[header] = value
|
||||
}
|
||||
})
|
||||
return row as CSVRow
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform CSV row to ParsedHanzi format
|
||||
*/
|
||||
function transformCSVRow(row: CSVRow): ParsedHanzi {
|
||||
// Parse HSK levels (comma-separated)
|
||||
const hskLevels = row.hsk_level
|
||||
? row.hsk_level.split(",").map(l => l.trim())
|
||||
: []
|
||||
|
||||
// Parse parts of speech (comma-separated)
|
||||
const partsOfSpeech = row.pos
|
||||
? row.pos.split(",").map(p => p.trim())
|
||||
: []
|
||||
|
||||
// Parse frequency
|
||||
const frequency = row.frequency
|
||||
? parseInt(row.frequency, 10)
|
||||
: undefined
|
||||
|
||||
// Parse classifiers (comma-separated)
|
||||
const classifiers = row.classifiers
|
||||
? row.classifiers.split(",").map(c => c.trim())
|
||||
: []
|
||||
|
||||
// Parse meanings (semicolon-separated)
|
||||
const meanings = row.meaning.split(";").map((m, index) => ({
|
||||
languageCode: "en",
|
||||
meaning: m.trim(),
|
||||
orderIndex: index,
|
||||
}))
|
||||
|
||||
return {
|
||||
simplified: row.simplified,
|
||||
radical: row.radical,
|
||||
frequency,
|
||||
hskLevels,
|
||||
partsOfSpeech,
|
||||
forms: [
|
||||
{
|
||||
traditional: row.traditional,
|
||||
isDefault: true,
|
||||
transcriptions: [
|
||||
{
|
||||
type: "pinyin",
|
||||
value: row.pinyin,
|
||||
},
|
||||
],
|
||||
meanings,
|
||||
classifiers,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSV template
|
||||
*/
|
||||
export function generateCSVTemplate(): string {
|
||||
const headers = [
|
||||
"simplified",
|
||||
"traditional",
|
||||
"pinyin",
|
||||
"meaning",
|
||||
"hsk_level",
|
||||
"radical",
|
||||
"frequency",
|
||||
"pos",
|
||||
"classifiers",
|
||||
]
|
||||
|
||||
const example = [
|
||||
"爱好",
|
||||
"愛好",
|
||||
"ài hào",
|
||||
"to like; hobby",
|
||||
"new-1,old-3",
|
||||
"爫",
|
||||
"4902",
|
||||
"n,v",
|
||||
"个",
|
||||
]
|
||||
|
||||
return [headers.join(","), example.join(",")].join("\n")
|
||||
}
|
||||
300
src/lib/import/hsk-json-parser.test.ts
Normal file
300
src/lib/import/hsk-json-parser.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { parseHSKJson, validateHSKJsonEntry } from "./hsk-json-parser"
|
||||
|
||||
describe("parseHSKJson", () => {
|
||||
it("should parse valid single JSON entry", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "爱好",
|
||||
radical: "爫",
|
||||
level: ["new-1", "old-3"],
|
||||
frequency: 4902,
|
||||
pos: ["n", "v"],
|
||||
forms: [
|
||||
{
|
||||
traditional: "愛好",
|
||||
transcriptions: {
|
||||
pinyin: "ài hào",
|
||||
numeric: "ai4 hao4",
|
||||
},
|
||||
meanings: ["to like; hobby"],
|
||||
classifiers: ["个"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.failed).toBe(0)
|
||||
expect(result.errors).toHaveLength(0)
|
||||
expect(data).toHaveLength(1)
|
||||
expect(data[0].simplified).toBe("爱好")
|
||||
expect(data[0].radical).toBe("爫")
|
||||
expect(data[0].frequency).toBe(4902)
|
||||
expect(data[0].hskLevels).toEqual(["new-1", "old-3"])
|
||||
expect(data[0].partsOfSpeech).toEqual(["n", "v"])
|
||||
expect(data[0].forms).toHaveLength(1)
|
||||
expect(data[0].forms[0].traditional).toBe("愛好")
|
||||
expect(data[0].forms[0].isDefault).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse valid JSON array", () => {
|
||||
const json = JSON.stringify([
|
||||
{
|
||||
simplified: "爱",
|
||||
forms: [
|
||||
{
|
||||
traditional: "愛",
|
||||
transcriptions: { pinyin: "ài" },
|
||||
meanings: ["to love"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(2)
|
||||
expect(data).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should handle missing optional fields", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].radical).toBeUndefined()
|
||||
expect(data[0].frequency).toBeUndefined()
|
||||
expect(data[0].hskLevels).toEqual([])
|
||||
expect(data[0].partsOfSpeech).toEqual([])
|
||||
})
|
||||
|
||||
it("should split semicolon-separated meanings", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good; fine; nice"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].meanings).toHaveLength(3)
|
||||
expect(data[0].forms[0].meanings[0].meaning).toBe("good")
|
||||
expect(data[0].forms[0].meanings[1].meaning).toBe("fine")
|
||||
expect(data[0].forms[0].meanings[2].meaning).toBe("nice")
|
||||
})
|
||||
|
||||
it("should handle multiple forms with second form not being default", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "爱",
|
||||
forms: [
|
||||
{
|
||||
traditional: "愛",
|
||||
transcriptions: { pinyin: "ài" },
|
||||
meanings: ["to love"],
|
||||
},
|
||||
{
|
||||
traditional: "爱",
|
||||
transcriptions: { pinyin: "ài" },
|
||||
meanings: ["to love (simplified)"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms).toHaveLength(2)
|
||||
expect(data[0].forms[0].isDefault).toBe(true)
|
||||
expect(data[0].forms[1].isDefault).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle multiple transcription types", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: {
|
||||
pinyin: "hǎo",
|
||||
numeric: "hao3",
|
||||
wadegiles: "hao3",
|
||||
},
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].transcriptions).toHaveLength(3)
|
||||
expect(data[0].forms[0].transcriptions.map(t => t.type)).toContain("pinyin")
|
||||
expect(data[0].forms[0].transcriptions.map(t => t.type)).toContain("numeric")
|
||||
expect(data[0].forms[0].transcriptions.map(t => t.type)).toContain("wadegiles")
|
||||
})
|
||||
|
||||
it("should return error for invalid JSON", () => {
|
||||
const json = "{ invalid json }"
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.imported).toBe(0)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
expect(result.errors[0].error).toContain("Invalid JSON")
|
||||
expect(data).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should return error for missing required fields", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "好",
|
||||
// Missing forms
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
expect(data).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should return error for empty simplified field", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should return error for empty meanings array", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should continue parsing after errors", () => {
|
||||
const json = JSON.stringify([
|
||||
{
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
simplified: "", // Invalid
|
||||
forms: [
|
||||
{
|
||||
traditional: "x",
|
||||
transcriptions: { pinyin: "x" },
|
||||
meanings: ["x"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
simplified: "爱",
|
||||
forms: [
|
||||
{
|
||||
traditional: "愛",
|
||||
transcriptions: { pinyin: "ài" },
|
||||
meanings: ["love"],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.imported).toBe(2)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(data).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateHSKJsonEntry", () => {
|
||||
it("should validate correct entry", () => {
|
||||
const entry = {
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = validateHSKJsonEntry(entry)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should return errors for invalid entry", () => {
|
||||
const entry = {
|
||||
simplified: "",
|
||||
forms: [],
|
||||
}
|
||||
|
||||
const result = validateHSKJsonEntry(entry)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
161
src/lib/import/hsk-json-parser.ts
Normal file
161
src/lib/import/hsk-json-parser.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { z } from "zod"
|
||||
import type {
|
||||
HSKJsonEntry,
|
||||
HSKJsonForm,
|
||||
ParsedHanzi,
|
||||
ParsedHanziForm,
|
||||
ImportResult,
|
||||
ImportError,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Zod schema for HSK JSON validation
|
||||
*/
|
||||
const HSKJsonFormSchema = z.object({
|
||||
traditional: z.string().min(1),
|
||||
transcriptions: z.object({
|
||||
pinyin: z.string().min(1),
|
||||
numeric: z.string().optional(),
|
||||
wadegiles: z.string().optional(),
|
||||
}).catchall(z.string().optional()),
|
||||
meanings: z.array(z.string().min(1)).min(1),
|
||||
classifiers: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
const HSKJsonEntrySchema = z.object({
|
||||
simplified: z.string().min(1),
|
||||
radical: z.string().optional(),
|
||||
level: z.array(z.string()).optional(),
|
||||
frequency: z.number().int().positive().optional(),
|
||||
pos: z.array(z.string()).optional(),
|
||||
forms: z.array(HSKJsonFormSchema).min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* Parse HSK JSON format
|
||||
* Source: https://github.com/drkameleon/complete-hsk-vocabulary
|
||||
*/
|
||||
export function parseHSKJson(jsonString: string): {
|
||||
result: ImportResult
|
||||
data: ParsedHanzi[]
|
||||
} {
|
||||
const errors: ImportError[] = []
|
||||
const parsed: ParsedHanzi[] = []
|
||||
let entries: unknown[]
|
||||
|
||||
// Parse JSON
|
||||
try {
|
||||
const data = JSON.parse(jsonString)
|
||||
entries = Array.isArray(data) ? data : [data]
|
||||
} catch (error) {
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
imported: 0,
|
||||
failed: 0,
|
||||
errors: [{ error: `Invalid JSON: ${error instanceof Error ? error.message : "Unknown error"}` }],
|
||||
},
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and transform each entry
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
try {
|
||||
const entry = HSKJsonEntrySchema.parse(entries[i])
|
||||
const parsedEntry = transformHSKJsonEntry(entry)
|
||||
parsed.push(parsedEntry)
|
||||
} catch (error) {
|
||||
const simplified = (entries[i] as any)?.simplified || "unknown"
|
||||
const errorMessage = error instanceof z.ZodError
|
||||
? error.errors.map(e => `${e.path.join(".")}: ${e.message}`).join(", ")
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error"
|
||||
|
||||
errors.push({
|
||||
line: i + 1,
|
||||
character: simplified,
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
success: errors.length === 0,
|
||||
imported: parsed.length,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
},
|
||||
data: parsed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform HSK JSON entry to ParsedHanzi format
|
||||
*/
|
||||
function transformHSKJsonEntry(entry: HSKJsonEntry): ParsedHanzi {
|
||||
return {
|
||||
simplified: entry.simplified,
|
||||
radical: entry.radical,
|
||||
frequency: entry.frequency,
|
||||
hskLevels: entry.level || [],
|
||||
partsOfSpeech: entry.pos || [],
|
||||
forms: entry.forms.map((form, index) => transformHSKJsonForm(form, index === 0)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform HSK JSON form to ParsedHanziForm format
|
||||
*/
|
||||
function transformHSKJsonForm(form: HSKJsonForm, isDefault: boolean): ParsedHanziForm {
|
||||
// Extract transcriptions
|
||||
const transcriptions = Object.entries(form.transcriptions)
|
||||
.filter(([_, value]) => value !== undefined)
|
||||
.map(([type, value]) => ({
|
||||
type,
|
||||
value: value!,
|
||||
}))
|
||||
|
||||
// Parse meanings (can be semicolon-separated or array)
|
||||
const meanings = form.meanings.flatMap((meaningStr, index) =>
|
||||
meaningStr.split(";").map((m, subIndex) => ({
|
||||
languageCode: "en", // Default to English
|
||||
meaning: m.trim(),
|
||||
orderIndex: index * 100 + subIndex,
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
traditional: form.traditional,
|
||||
isDefault,
|
||||
transcriptions,
|
||||
meanings,
|
||||
classifiers: form.classifiers || [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single HSK JSON entry
|
||||
*/
|
||||
export function validateHSKJsonEntry(entry: unknown): {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
} {
|
||||
try {
|
||||
HSKJsonEntrySchema.parse(entry)
|
||||
return { valid: true, errors: [] }
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: error.errors.map(e => `${e.path.join(".")}: ${e.message}`),
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
errors: [error instanceof Error ? error.message : "Unknown error"],
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/lib/import/types.ts
Normal file
77
src/lib/import/types.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Types for HSK JSON and CSV import formats
|
||||
*/
|
||||
|
||||
export interface HSKJsonForm {
|
||||
traditional: string
|
||||
transcriptions: {
|
||||
pinyin: string
|
||||
numeric?: string
|
||||
wadegiles?: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
meanings: string[]
|
||||
classifiers?: string[]
|
||||
}
|
||||
|
||||
export interface HSKJsonEntry {
|
||||
simplified: string
|
||||
radical?: string
|
||||
level?: string[]
|
||||
frequency?: number
|
||||
pos?: string[]
|
||||
forms: HSKJsonForm[]
|
||||
}
|
||||
|
||||
export interface CSVRow {
|
||||
simplified: string
|
||||
traditional: string
|
||||
pinyin: string
|
||||
meaning: string
|
||||
hsk_level?: string
|
||||
radical?: string
|
||||
frequency?: string
|
||||
pos?: string
|
||||
classifiers?: string
|
||||
}
|
||||
|
||||
export interface ParsedHanzi {
|
||||
simplified: string
|
||||
radical?: string
|
||||
frequency?: number
|
||||
forms: ParsedHanziForm[]
|
||||
hskLevels: string[]
|
||||
partsOfSpeech: string[]
|
||||
}
|
||||
|
||||
export interface ParsedHanziForm {
|
||||
traditional: string
|
||||
isDefault: boolean
|
||||
transcriptions: ParsedTranscription[]
|
||||
meanings: ParsedMeaning[]
|
||||
classifiers: string[]
|
||||
}
|
||||
|
||||
export interface ParsedTranscription {
|
||||
type: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ParsedMeaning {
|
||||
languageCode: string
|
||||
meaning: string
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
success: boolean
|
||||
imported: number
|
||||
failed: number
|
||||
errors: ImportError[]
|
||||
}
|
||||
|
||||
export interface ImportError {
|
||||
line?: number
|
||||
character?: string
|
||||
error: string
|
||||
}
|
||||
773
src/lib/learning/sm2.test.ts
Normal file
773
src/lib/learning/sm2.test.ts
Normal file
@@ -0,0 +1,773 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
INITIAL_PROGRESS,
|
||||
calculateCorrectAnswer,
|
||||
calculateIncorrectAnswer,
|
||||
Difficulty,
|
||||
selectCardsForSession,
|
||||
generateWrongAnswers,
|
||||
shuffleOptions,
|
||||
type CardProgress,
|
||||
type SelectableCard,
|
||||
type HanziOption,
|
||||
} from "./sm2"
|
||||
|
||||
/**
|
||||
* Unit tests for SM-2 Algorithm
|
||||
*
|
||||
* Tests the spaced repetition algorithm implementation
|
||||
* following the specification exactly.
|
||||
*/
|
||||
|
||||
describe("SM-2 Algorithm", () => {
|
||||
describe("INITIAL_PROGRESS", () => {
|
||||
it("should have correct initial values", () => {
|
||||
expect(INITIAL_PROGRESS.easeFactor).toBe(2.5)
|
||||
expect(INITIAL_PROGRESS.interval).toBe(1)
|
||||
expect(INITIAL_PROGRESS.consecutiveCorrect).toBe(0)
|
||||
expect(INITIAL_PROGRESS.incorrectCount).toBe(0)
|
||||
expect(INITIAL_PROGRESS.lastReviewDate).toBeNull()
|
||||
expect(INITIAL_PROGRESS.nextReviewDate).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateCorrectAnswer", () => {
|
||||
it("should set interval to 1 for first correct answer", () => {
|
||||
const progress: CardProgress = {
|
||||
...INITIAL_PROGRESS,
|
||||
}
|
||||
|
||||
const result = calculateCorrectAnswer(progress, new Date("2025-01-01"))
|
||||
|
||||
expect(result.interval).toBe(1)
|
||||
expect(result.consecutiveCorrect).toBe(1)
|
||||
expect(result.easeFactor).toBe(2.6) // 2.5 + 0.1
|
||||
expect(result.nextReviewDate).toEqual(new Date("2025-01-02"))
|
||||
})
|
||||
|
||||
it("should set interval to 6 for second correct answer", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.6,
|
||||
interval: 1,
|
||||
consecutiveCorrect: 1,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-02"),
|
||||
}
|
||||
|
||||
const result = calculateCorrectAnswer(progress, new Date("2025-01-02"))
|
||||
|
||||
expect(result.interval).toBe(6)
|
||||
expect(result.consecutiveCorrect).toBe(2)
|
||||
expect(result.easeFactor).toBe(2.7) // 2.6 + 0.1
|
||||
expect(result.nextReviewDate).toEqual(new Date("2025-01-08"))
|
||||
})
|
||||
|
||||
it("should multiply interval by easeFactor for third+ correct answer", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.7,
|
||||
interval: 6,
|
||||
consecutiveCorrect: 2,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-02"),
|
||||
nextReviewDate: new Date("2025-01-08"),
|
||||
}
|
||||
|
||||
const result = calculateCorrectAnswer(progress, new Date("2025-01-08"))
|
||||
|
||||
// 6 * 2.7 = 16.2, rounded = 16
|
||||
expect(result.interval).toBe(16)
|
||||
expect(result.consecutiveCorrect).toBe(3)
|
||||
expect(result.easeFactor).toBeCloseTo(2.8) // 2.7 + 0.1
|
||||
expect(result.nextReviewDate).toEqual(new Date("2025-01-24"))
|
||||
})
|
||||
|
||||
it("should continue increasing ease factor with each correct answer", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 3.0,
|
||||
interval: 50,
|
||||
consecutiveCorrect: 5,
|
||||
incorrectCount: 2,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-02-20"),
|
||||
}
|
||||
|
||||
const result = calculateCorrectAnswer(progress, new Date("2025-02-20"))
|
||||
|
||||
// 50 * 3.0 = 150
|
||||
expect(result.interval).toBe(150)
|
||||
expect(result.consecutiveCorrect).toBe(6)
|
||||
expect(result.easeFactor).toBe(3.1) // 3.0 + 0.1
|
||||
expect(result.incorrectCount).toBe(2) // Should not change
|
||||
})
|
||||
|
||||
it("should use current date by default", () => {
|
||||
const progress: CardProgress = {
|
||||
...INITIAL_PROGRESS,
|
||||
}
|
||||
|
||||
const before = new Date()
|
||||
const result = calculateCorrectAnswer(progress)
|
||||
const after = new Date()
|
||||
|
||||
// Next review should be approximately 1 day from now
|
||||
const expectedMin = new Date(before)
|
||||
expectedMin.setDate(expectedMin.getDate() + 1)
|
||||
const expectedMax = new Date(after)
|
||||
expectedMax.setDate(expectedMax.getDate() + 1)
|
||||
|
||||
expect(result.nextReviewDate.getTime()).toBeGreaterThanOrEqual(
|
||||
expectedMin.getTime()
|
||||
)
|
||||
expect(result.nextReviewDate.getTime()).toBeLessThanOrEqual(
|
||||
expectedMax.getTime()
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle large intervals correctly", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.5,
|
||||
interval: 365,
|
||||
consecutiveCorrect: 10,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2026-01-01"),
|
||||
}
|
||||
|
||||
const result = calculateCorrectAnswer(progress, new Date("2026-01-01"))
|
||||
|
||||
// 365 * 2.5 = 912.5, rounded = 913
|
||||
expect(result.interval).toBe(913)
|
||||
expect(result.consecutiveCorrect).toBe(11)
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateIncorrectAnswer", () => {
|
||||
it("should reset interval to 1", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.7,
|
||||
interval: 16,
|
||||
consecutiveCorrect: 3,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-17"),
|
||||
}
|
||||
|
||||
const result = calculateIncorrectAnswer(progress, new Date("2025-01-17"))
|
||||
|
||||
expect(result.interval).toBe(1)
|
||||
expect(result.nextReviewDate).toEqual(new Date("2025-01-18"))
|
||||
})
|
||||
|
||||
it("should reset consecutiveCorrect to 0", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.7,
|
||||
interval: 16,
|
||||
consecutiveCorrect: 5,
|
||||
incorrectCount: 1,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-17"),
|
||||
}
|
||||
|
||||
const result = calculateIncorrectAnswer(progress, new Date("2025-01-17"))
|
||||
|
||||
expect(result.consecutiveCorrect).toBe(0)
|
||||
})
|
||||
|
||||
it("should decrease easeFactor by 0.2", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.5,
|
||||
interval: 6,
|
||||
consecutiveCorrect: 2,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-07"),
|
||||
}
|
||||
|
||||
const result = calculateIncorrectAnswer(progress, new Date("2025-01-07"))
|
||||
|
||||
expect(result.easeFactor).toBe(2.3) // 2.5 - 0.2
|
||||
})
|
||||
|
||||
it("should not decrease easeFactor below 1.3", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 1.4,
|
||||
interval: 1,
|
||||
consecutiveCorrect: 0,
|
||||
incorrectCount: 5,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-02"),
|
||||
}
|
||||
|
||||
const result = calculateIncorrectAnswer(progress, new Date("2025-01-02"))
|
||||
|
||||
// 1.4 - 0.2 = 1.2, but minimum is 1.3
|
||||
expect(result.easeFactor).toBe(1.3)
|
||||
})
|
||||
|
||||
it("should increment incorrectCount", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.5,
|
||||
interval: 6,
|
||||
consecutiveCorrect: 2,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-07"),
|
||||
}
|
||||
|
||||
const result = calculateIncorrectAnswer(progress, new Date("2025-01-07"))
|
||||
|
||||
expect(result.incorrectCount).toBe(1)
|
||||
})
|
||||
|
||||
it("should use current date by default", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.5,
|
||||
interval: 6,
|
||||
consecutiveCorrect: 2,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-07"),
|
||||
}
|
||||
|
||||
const before = new Date()
|
||||
const result = calculateIncorrectAnswer(progress)
|
||||
const after = new Date()
|
||||
|
||||
// Next review should be approximately 1 day from now
|
||||
const expectedMin = new Date(before)
|
||||
expectedMin.setDate(expectedMin.getDate() + 1)
|
||||
const expectedMax = new Date(after)
|
||||
expectedMax.setDate(expectedMax.getDate() + 1)
|
||||
|
||||
expect(result.nextReviewDate.getTime()).toBeGreaterThanOrEqual(
|
||||
expectedMin.getTime()
|
||||
)
|
||||
expect(result.nextReviewDate.getTime()).toBeLessThanOrEqual(
|
||||
expectedMax.getTime()
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle multiple consecutive incorrect answers", () => {
|
||||
let progress: CardProgress = {
|
||||
easeFactor: 2.5,
|
||||
interval: 16,
|
||||
consecutiveCorrect: 3,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-17"),
|
||||
}
|
||||
|
||||
// First incorrect
|
||||
let result = calculateIncorrectAnswer(progress, new Date("2025-01-17"))
|
||||
expect(result.easeFactor).toBe(2.3)
|
||||
expect(result.incorrectCount).toBe(1)
|
||||
|
||||
// Second incorrect
|
||||
progress = {
|
||||
easeFactor: result.easeFactor,
|
||||
interval: result.interval,
|
||||
consecutiveCorrect: result.consecutiveCorrect,
|
||||
incorrectCount: result.incorrectCount,
|
||||
lastReviewDate: new Date("2025-01-17"),
|
||||
nextReviewDate: result.nextReviewDate,
|
||||
}
|
||||
result = calculateIncorrectAnswer(progress, new Date("2025-01-18"))
|
||||
expect(result.easeFactor).toBeCloseTo(2.1) // 2.3 - 0.2
|
||||
expect(result.incorrectCount).toBe(2)
|
||||
|
||||
// Third incorrect
|
||||
progress = {
|
||||
easeFactor: result.easeFactor,
|
||||
interval: result.interval,
|
||||
consecutiveCorrect: result.consecutiveCorrect,
|
||||
incorrectCount: result.incorrectCount,
|
||||
lastReviewDate: new Date("2025-01-18"),
|
||||
nextReviewDate: result.nextReviewDate,
|
||||
}
|
||||
result = calculateIncorrectAnswer(progress, new Date("2025-01-19"))
|
||||
expect(result.easeFactor).toBeCloseTo(1.9) // 2.1 - 0.2
|
||||
expect(result.incorrectCount).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("selectCardsForSession", () => {
|
||||
const now = new Date("2025-01-15T10:00:00Z")
|
||||
|
||||
it("should select due cards only", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"), // Due
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-16T10:00:00Z"), // Not due
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
nextReviewDate: new Date("2025-01-13T10:00:00Z"), // Due
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected.length).toBe(2)
|
||||
expect(selected.map((c) => c.id)).toContain("1")
|
||||
expect(selected.map((c) => c.id)).toContain("3")
|
||||
expect(selected.map((c) => c.id)).not.toContain("2")
|
||||
})
|
||||
|
||||
it("should exclude suspended cards", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.SUSPENDED,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected.length).toBe(1)
|
||||
expect(selected[0].id).toBe("1")
|
||||
})
|
||||
|
||||
it("should prioritize HARD over NORMAL over EASY", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "easy",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.EASY,
|
||||
},
|
||||
{
|
||||
id: "hard",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.HARD,
|
||||
},
|
||||
{
|
||||
id: "normal",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected[0].id).toBe("hard")
|
||||
expect(selected[1].id).toBe("normal")
|
||||
expect(selected[2].id).toBe("easy")
|
||||
})
|
||||
|
||||
it("should sort by nextReviewDate ascending", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
nextReviewDate: new Date("2025-01-13T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected[0].id).toBe("2") // Oldest
|
||||
expect(selected[1].id).toBe("3")
|
||||
expect(selected[2].id).toBe("1") // Newest
|
||||
})
|
||||
|
||||
it("should prioritize higher incorrectCount", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 1,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 3,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 2,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected[0].id).toBe("2") // incorrectCount: 3
|
||||
expect(selected[1].id).toBe("3") // incorrectCount: 2
|
||||
expect(selected[2].id).toBe("1") // incorrectCount: 1
|
||||
})
|
||||
|
||||
it("should prioritize lower consecutiveCorrect", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 3,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 2,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected[0].id).toBe("2") // consecutiveCorrect: 1
|
||||
expect(selected[1].id).toBe("3") // consecutiveCorrect: 2
|
||||
expect(selected[2].id).toBe("1") // consecutiveCorrect: 3
|
||||
})
|
||||
|
||||
it("should include new cards (null nextReviewDate)", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: null, // New card
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 0,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected.length).toBe(2)
|
||||
expect(selected[0].id).toBe("1") // New cards first
|
||||
expect(selected[1].id).toBe("2")
|
||||
})
|
||||
|
||||
it("should limit to cardsPerSession", () => {
|
||||
const cards: SelectableCard[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `${i}`,
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
}))
|
||||
|
||||
const selected = selectCardsForSession(cards, 5, now)
|
||||
|
||||
expect(selected.length).toBe(5)
|
||||
})
|
||||
|
||||
it("should apply all sorting criteria in correct order", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "easy-old-high-low",
|
||||
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
||||
incorrectCount: 5,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.EASY,
|
||||
},
|
||||
{
|
||||
id: "hard-new-low-high",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 1,
|
||||
consecutiveCorrect: 5,
|
||||
manualDifficulty: Difficulty.HARD,
|
||||
},
|
||||
{
|
||||
id: "hard-old-low-low",
|
||||
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
||||
incorrectCount: 1,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.HARD,
|
||||
},
|
||||
{
|
||||
id: "normal-old-high-low",
|
||||
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
||||
incorrectCount: 5,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
// Expected order:
|
||||
// 1. HARD difficulty has priority
|
||||
// 2. Among HARD: older date (2025-01-12) before newer (2025-01-14)
|
||||
// 3. Then NORMAL difficulty
|
||||
// 4. Then EASY difficulty
|
||||
expect(selected[0].id).toBe("hard-old-low-low")
|
||||
expect(selected[1].id).toBe("hard-new-low-high")
|
||||
expect(selected[2].id).toBe("normal-old-high-low")
|
||||
expect(selected[3].id).toBe("easy-old-high-low")
|
||||
})
|
||||
|
||||
it("should handle empty card list", () => {
|
||||
const selected = selectCardsForSession([], 10, now)
|
||||
expect(selected.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle all cards being suspended", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.SUSPENDED,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.SUSPENDED,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
expect(selected.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle all cards not being due", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-16T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-17T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
expect(selected.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateWrongAnswers", () => {
|
||||
const correctAnswer: HanziOption = {
|
||||
id: "1",
|
||||
simplified: "好",
|
||||
pinyin: "hǎo",
|
||||
hskLevel: "new-1",
|
||||
}
|
||||
|
||||
it("should generate 3 wrong answers", () => {
|
||||
const sameHskOptions: HanziOption[] = [
|
||||
correctAnswer,
|
||||
{ id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
|
||||
{ id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
|
||||
{ id: "4", simplified: "他", pinyin: "tā", hskLevel: "new-1" },
|
||||
{ id: "5", simplified: "的", pinyin: "de", hskLevel: "new-1" },
|
||||
]
|
||||
|
||||
const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions)
|
||||
|
||||
expect(wrongAnswers.length).toBe(3)
|
||||
expect(wrongAnswers).not.toContain("hǎo") // Should not include correct
|
||||
})
|
||||
|
||||
it("should not include correct answer", () => {
|
||||
const sameHskOptions: HanziOption[] = [
|
||||
correctAnswer,
|
||||
{ id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
|
||||
{ id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
|
||||
{ id: "4", simplified: "他", pinyin: "tā", hskLevel: "new-1" },
|
||||
]
|
||||
|
||||
const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions)
|
||||
|
||||
expect(wrongAnswers).not.toContain("hǎo")
|
||||
})
|
||||
|
||||
it("should not include duplicate pinyin", () => {
|
||||
const sameHskOptions: HanziOption[] = [
|
||||
correctAnswer,
|
||||
{ id: "2", simplified: "好", pinyin: "hǎo", hskLevel: "new-1" }, // Duplicate pinyin
|
||||
{ id: "3", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
|
||||
{ id: "4", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
|
||||
{ id: "5", simplified: "他", pinyin: "tā", hskLevel: "new-1" },
|
||||
{ id: "6", simplified: "的", pinyin: "de", hskLevel: "new-1" },
|
||||
]
|
||||
|
||||
const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions)
|
||||
|
||||
expect(wrongAnswers.length).toBe(3)
|
||||
// Should only have 1 instance of each pinyin
|
||||
const uniquePinyin = new Set(wrongAnswers)
|
||||
expect(uniquePinyin.size).toBe(3)
|
||||
})
|
||||
|
||||
it("should throw error if not enough options", () => {
|
||||
const sameHskOptions: HanziOption[] = [
|
||||
correctAnswer,
|
||||
{ id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
|
||||
{ id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
|
||||
// Only 2 other options, need 3
|
||||
]
|
||||
|
||||
expect(() =>
|
||||
generateWrongAnswers(correctAnswer, sameHskOptions)
|
||||
).toThrow("Not enough wrong answers available")
|
||||
})
|
||||
|
||||
it("should randomize the selection", () => {
|
||||
const sameHskOptions: HanziOption[] = [
|
||||
correctAnswer,
|
||||
{ id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
|
||||
{ id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
|
||||
{ id: "4", simplified: "他", pinyin: "tā", hskLevel: "new-1" },
|
||||
{ id: "5", simplified: "的", pinyin: "de", hskLevel: "new-1" },
|
||||
{ id: "6", simplified: "是", pinyin: "shì", hskLevel: "new-1" },
|
||||
{ id: "7", simplified: "在", pinyin: "zài", hskLevel: "new-1" },
|
||||
{ id: "8", simplified: "有", pinyin: "yǒu", hskLevel: "new-1" },
|
||||
]
|
||||
|
||||
// Run multiple times and check that we get different results
|
||||
const results = new Set<string>()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions)
|
||||
results.add(wrongAnswers.sort().join(","))
|
||||
}
|
||||
|
||||
// Should have at least 2 different combinations (very likely with 7 options)
|
||||
expect(results.size).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("shuffleOptions", () => {
|
||||
it("should return array of same length", () => {
|
||||
const options = ["a", "b", "c", "d"]
|
||||
const shuffled = shuffleOptions(options)
|
||||
|
||||
expect(shuffled.length).toBe(4)
|
||||
})
|
||||
|
||||
it("should contain all original elements", () => {
|
||||
const options = ["a", "b", "c", "d"]
|
||||
const shuffled = shuffleOptions(options)
|
||||
|
||||
expect(shuffled).toContain("a")
|
||||
expect(shuffled).toContain("b")
|
||||
expect(shuffled).toContain("c")
|
||||
expect(shuffled).toContain("d")
|
||||
})
|
||||
|
||||
it("should not mutate original array", () => {
|
||||
const options = ["a", "b", "c", "d"]
|
||||
const original = [...options]
|
||||
shuffleOptions(options)
|
||||
|
||||
expect(options).toEqual(original)
|
||||
})
|
||||
|
||||
it("should produce different orders", () => {
|
||||
const options = ["a", "b", "c", "d", "e", "f"]
|
||||
|
||||
// Run multiple times and check that we get different results
|
||||
const results = new Set<string>()
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const shuffled = shuffleOptions(options)
|
||||
results.add(shuffled.join(","))
|
||||
}
|
||||
|
||||
// Should have at least 2 different orderings (very likely with 6 elements)
|
||||
expect(results.size).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it("should handle single element array", () => {
|
||||
const options = ["a"]
|
||||
const shuffled = shuffleOptions(options)
|
||||
|
||||
expect(shuffled).toEqual(["a"])
|
||||
})
|
||||
|
||||
it("should handle empty array", () => {
|
||||
const options: string[] = []
|
||||
const shuffled = shuffleOptions(options)
|
||||
|
||||
expect(shuffled).toEqual([])
|
||||
})
|
||||
|
||||
it("should work with different types", () => {
|
||||
const options = [1, 2, 3, 4, 5]
|
||||
const shuffled = shuffleOptions(options)
|
||||
|
||||
expect(shuffled.length).toBe(5)
|
||||
expect(shuffled).toContain(1)
|
||||
expect(shuffled).toContain(2)
|
||||
expect(shuffled).toContain(3)
|
||||
expect(shuffled).toContain(4)
|
||||
expect(shuffled).toContain(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
275
src/lib/learning/sm2.ts
Normal file
275
src/lib/learning/sm2.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* SM-2 Algorithm Implementation
|
||||
*
|
||||
* Implements the SuperMemo SM-2 spaced repetition algorithm
|
||||
* as specified in the MemoHanzi specification.
|
||||
*
|
||||
* Reference: https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
|
||||
*/
|
||||
|
||||
/**
|
||||
* Progress data for a single card
|
||||
*/
|
||||
export interface CardProgress {
|
||||
easeFactor: number
|
||||
interval: number // in days
|
||||
consecutiveCorrect: number
|
||||
incorrectCount: number
|
||||
lastReviewDate: Date | null
|
||||
nextReviewDate: Date | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial values for a new card
|
||||
*/
|
||||
export const INITIAL_PROGRESS: CardProgress = {
|
||||
easeFactor: 2.5,
|
||||
interval: 1,
|
||||
consecutiveCorrect: 0,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: null,
|
||||
nextReviewDate: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of calculating the next review
|
||||
*/
|
||||
export interface ReviewResult {
|
||||
easeFactor: number
|
||||
interval: number
|
||||
consecutiveCorrect: number
|
||||
incorrectCount: number
|
||||
nextReviewDate: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next review for a correct answer
|
||||
*
|
||||
* @param progress Current card progress
|
||||
* @param reviewDate Date of the review (defaults to now)
|
||||
* @returns Updated progress values
|
||||
*/
|
||||
export function calculateCorrectAnswer(
|
||||
progress: CardProgress,
|
||||
reviewDate: Date = new Date()
|
||||
): ReviewResult {
|
||||
let newInterval: number
|
||||
let newEaseFactor: number
|
||||
let newConsecutiveCorrect: number
|
||||
|
||||
// Calculate new interval based on consecutive correct count
|
||||
if (progress.consecutiveCorrect === 0) {
|
||||
newInterval = 1
|
||||
} else if (progress.consecutiveCorrect === 1) {
|
||||
newInterval = 6
|
||||
} else {
|
||||
newInterval = Math.round(progress.interval * progress.easeFactor)
|
||||
}
|
||||
|
||||
// Increase ease factor (making future intervals longer)
|
||||
newEaseFactor = progress.easeFactor + 0.1
|
||||
|
||||
// Increment consecutive correct count
|
||||
newConsecutiveCorrect = progress.consecutiveCorrect + 1
|
||||
|
||||
// Calculate next review date
|
||||
const nextReviewDate = new Date(reviewDate)
|
||||
nextReviewDate.setDate(nextReviewDate.getDate() + newInterval)
|
||||
|
||||
return {
|
||||
easeFactor: newEaseFactor,
|
||||
interval: newInterval,
|
||||
consecutiveCorrect: newConsecutiveCorrect,
|
||||
incorrectCount: progress.incorrectCount,
|
||||
nextReviewDate,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next review for an incorrect answer
|
||||
*
|
||||
* @param progress Current card progress
|
||||
* @param reviewDate Date of the review (defaults to now)
|
||||
* @returns Updated progress values
|
||||
*/
|
||||
export function calculateIncorrectAnswer(
|
||||
progress: CardProgress,
|
||||
reviewDate: Date = new Date()
|
||||
): ReviewResult {
|
||||
// Reset interval to 1 day
|
||||
const newInterval = 1
|
||||
|
||||
// Reset consecutive correct count
|
||||
const newConsecutiveCorrect = 0
|
||||
|
||||
// Decrease ease factor (but not below 1.3)
|
||||
const newEaseFactor = Math.max(1.3, progress.easeFactor - 0.2)
|
||||
|
||||
// Increment incorrect count
|
||||
const newIncorrectCount = progress.incorrectCount + 1
|
||||
|
||||
// Calculate next review date (1 day from now)
|
||||
const nextReviewDate = new Date(reviewDate)
|
||||
nextReviewDate.setDate(nextReviewDate.getDate() + newInterval)
|
||||
|
||||
return {
|
||||
easeFactor: newEaseFactor,
|
||||
interval: newInterval,
|
||||
consecutiveCorrect: newConsecutiveCorrect,
|
||||
incorrectCount: newIncorrectCount,
|
||||
nextReviewDate,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Difficulty enum matching the Prisma schema
|
||||
*/
|
||||
export enum Difficulty {
|
||||
EASY = "EASY",
|
||||
NORMAL = "NORMAL",
|
||||
HARD = "HARD",
|
||||
SUSPENDED = "SUSPENDED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Card for selection with progress and metadata
|
||||
*/
|
||||
export interface SelectableCard {
|
||||
id: string
|
||||
nextReviewDate: Date | null
|
||||
incorrectCount: number
|
||||
consecutiveCorrect: number
|
||||
manualDifficulty: Difficulty
|
||||
}
|
||||
|
||||
/**
|
||||
* Select cards for a learning session
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Filter out SUSPENDED cards
|
||||
* 2. Filter cards that are due (nextReviewDate <= now)
|
||||
* 3. Apply priority: HARD cards first, NORMAL, then EASY
|
||||
* 4. Sort by: nextReviewDate ASC, incorrectCount DESC, consecutiveCorrect ASC
|
||||
* 5. Limit to cardsPerSession
|
||||
*
|
||||
* @param cards Available cards
|
||||
* @param cardsPerSession Maximum number of cards to select
|
||||
* @param now Current date (defaults to now)
|
||||
* @returns Selected cards for the session
|
||||
*/
|
||||
export function selectCardsForSession(
|
||||
cards: SelectableCard[],
|
||||
cardsPerSession: number,
|
||||
now: Date = new Date()
|
||||
): SelectableCard[] {
|
||||
// Filter out suspended cards
|
||||
const activeCards = cards.filter(
|
||||
(card) => card.manualDifficulty !== Difficulty.SUSPENDED
|
||||
)
|
||||
|
||||
// Filter cards that are due (nextReviewDate <= now or null for new cards)
|
||||
const dueCards = activeCards.filter(
|
||||
(card) => card.nextReviewDate === null || card.nextReviewDate <= now
|
||||
)
|
||||
|
||||
// Apply difficulty priority and sort
|
||||
const sortedCards = dueCards.sort((a, b) => {
|
||||
// Priority by difficulty: HARD > NORMAL > EASY
|
||||
const difficultyPriority = {
|
||||
[Difficulty.HARD]: 0,
|
||||
[Difficulty.NORMAL]: 1,
|
||||
[Difficulty.EASY]: 2,
|
||||
[Difficulty.SUSPENDED]: 3, // Should not appear due to filter
|
||||
}
|
||||
|
||||
const aPriority = difficultyPriority[a.manualDifficulty]
|
||||
const bPriority = difficultyPriority[b.manualDifficulty]
|
||||
|
||||
if (aPriority !== bPriority) {
|
||||
return aPriority - bPriority
|
||||
}
|
||||
|
||||
// Sort by nextReviewDate (null = new cards, should come first)
|
||||
if (a.nextReviewDate === null && b.nextReviewDate !== null) return -1
|
||||
if (a.nextReviewDate !== null && b.nextReviewDate === null) return 1
|
||||
if (a.nextReviewDate !== null && b.nextReviewDate !== null) {
|
||||
const dateCompare = a.nextReviewDate.getTime() - b.nextReviewDate.getTime()
|
||||
if (dateCompare !== 0) return dateCompare
|
||||
}
|
||||
|
||||
// Sort by incorrectCount DESC (more incorrect = higher priority)
|
||||
if (a.incorrectCount !== b.incorrectCount) {
|
||||
return b.incorrectCount - a.incorrectCount
|
||||
}
|
||||
|
||||
// Sort by consecutiveCorrect ASC (fewer correct = higher priority)
|
||||
return a.consecutiveCorrect - b.consecutiveCorrect
|
||||
})
|
||||
|
||||
// Limit to cardsPerSession
|
||||
return sortedCards.slice(0, cardsPerSession)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hanzi option for wrong answer generation
|
||||
*/
|
||||
export interface HanziOption {
|
||||
id: string
|
||||
simplified: string
|
||||
pinyin: string
|
||||
hskLevel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate wrong answers for a multiple choice question
|
||||
*
|
||||
* Selects 3 random incorrect pinyin from the same HSK level,
|
||||
* ensuring no duplicates.
|
||||
*
|
||||
* @param correctAnswer The correct hanzi
|
||||
* @param sameHskOptions Available hanzi from the same HSK level
|
||||
* @returns Array of 3 wrong pinyin options
|
||||
*/
|
||||
export function generateWrongAnswers(
|
||||
correctAnswer: HanziOption,
|
||||
sameHskOptions: HanziOption[]
|
||||
): string[] {
|
||||
// Filter out the correct answer and any with duplicate pinyin
|
||||
const candidates = sameHskOptions.filter(
|
||||
(option) =>
|
||||
option.id !== correctAnswer.id && option.pinyin !== correctAnswer.pinyin
|
||||
)
|
||||
|
||||
// If not enough candidates, throw error
|
||||
if (candidates.length < 3) {
|
||||
throw new Error(
|
||||
`Not enough wrong answers available. Need 3, found ${candidates.length}`
|
||||
)
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
const shuffled = [...candidates]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||
}
|
||||
|
||||
// Take first 3
|
||||
return shuffled.slice(0, 3).map((option) => option.pinyin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle an array of options (for randomizing answer positions)
|
||||
* Uses Fisher-Yates shuffle algorithm
|
||||
*
|
||||
* @param options Array to shuffle
|
||||
* @returns Shuffled array
|
||||
*/
|
||||
export function shuffleOptions<T>(options: T[]): T[] {
|
||||
const shuffled = [...options]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||
}
|
||||
return shuffled
|
||||
}
|
||||
13
src/lib/prisma.ts
Normal file
13
src/lib/prisma.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
172
src/lib/validations/auth.test.ts
Normal file
172
src/lib/validations/auth.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
loginSchema,
|
||||
registerSchema,
|
||||
updatePasswordSchema,
|
||||
updateProfileSchema,
|
||||
} from './auth'
|
||||
|
||||
describe('Auth Validation Schemas', () => {
|
||||
describe('loginSchema', () => {
|
||||
it('should validate correct login data', () => {
|
||||
const result = loginSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid email', () => {
|
||||
const result = loginSchema.safeParse({
|
||||
email: 'invalid-email',
|
||||
password: 'password123',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject empty email', () => {
|
||||
const result = loginSchema.safeParse({
|
||||
email: '',
|
||||
password: 'password123',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject empty password', () => {
|
||||
const result = loginSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerSchema', () => {
|
||||
it('should validate correct registration data', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
name: 'Test User',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject password shorter than 8 characters', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'short',
|
||||
name: 'Test User',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain('password')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject invalid email format', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'not-an-email',
|
||||
password: 'password123',
|
||||
name: 'Test User',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain('email')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject empty name', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
name: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain('name')
|
||||
}
|
||||
})
|
||||
|
||||
it('should accept minimum valid password (8 characters)', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: '12345678',
|
||||
name: 'Test User',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePasswordSchema', () => {
|
||||
it('should validate correct password update data', () => {
|
||||
const result = updatePasswordSchema.safeParse({
|
||||
currentPassword: 'oldpassword',
|
||||
newPassword: 'newpassword123',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject new password shorter than 8 characters', () => {
|
||||
const result = updatePasswordSchema.safeParse({
|
||||
currentPassword: 'oldpassword',
|
||||
newPassword: 'short',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain('newPassword')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject empty current password', () => {
|
||||
const result = updatePasswordSchema.safeParse({
|
||||
currentPassword: '',
|
||||
newPassword: 'newpassword123',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateProfileSchema', () => {
|
||||
it('should validate correct profile update with name', () => {
|
||||
const result = updateProfileSchema.safeParse({
|
||||
name: 'New Name',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate correct profile update with email', () => {
|
||||
const result = updateProfileSchema.safeParse({
|
||||
email: 'newemail@example.com',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate correct profile update with both fields', () => {
|
||||
const result = updateProfileSchema.safeParse({
|
||||
name: 'New Name',
|
||||
email: 'newemail@example.com',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid email format', () => {
|
||||
const result = updateProfileSchema.safeParse({
|
||||
email: 'invalid-email',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept empty object (no updates)', () => {
|
||||
const result = updateProfileSchema.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept undefined values', () => {
|
||||
const result = updateProfileSchema.safeParse({
|
||||
name: undefined,
|
||||
email: undefined,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
22
src/lib/validations/auth.ts
Normal file
22
src/lib/validations/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
})
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
})
|
||||
|
||||
export const updatePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, 'Current password is required'),
|
||||
newPassword: z.string().min(6, 'New password must be at least 6 characters'),
|
||||
})
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters').optional(),
|
||||
email: z.string().email('Invalid email address').optional(),
|
||||
})
|
||||
159
src/lib/validations/preferences.test.ts
Normal file
159
src/lib/validations/preferences.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { updatePreferencesSchema } from './preferences'
|
||||
|
||||
describe('Preferences Validation Schemas', () => {
|
||||
describe('updatePreferencesSchema', () => {
|
||||
it('should validate correct preferences data', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
characterDisplay: 'SIMPLIFIED',
|
||||
cardsPerSession: 20,
|
||||
dailyGoal: 50,
|
||||
transcriptionType: 'pinyin',
|
||||
removalThreshold: 10,
|
||||
allowManualDifficulty: true,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate TRADITIONAL character display', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
characterDisplay: 'TRADITIONAL',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate BOTH character display', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
characterDisplay: 'BOTH',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid character display', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
characterDisplay: 'INVALID',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject cardsPerSession below 5', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
cardsPerSession: 4,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept cardsPerSession of 5 (minimum)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
cardsPerSession: 5,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject dailyGoal below 10', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
dailyGoal: 9,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept dailyGoal of 10 (minimum)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
dailyGoal: 10,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept cardsPerSession of 100 (maximum)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
cardsPerSession: 100,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept dailyGoal of 500 (maximum)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
dailyGoal: 500,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject cardsPerSession above 100', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
cardsPerSession: 101,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject dailyGoal above 500', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
dailyGoal: 501,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept empty object (no updates)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate partial updates', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
cardsPerSession: 30,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept preferredLanguageId as string', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
preferredLanguageId: 'some-uuid-string',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject removalThreshold below 5', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
removalThreshold: 4,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept removalThreshold of 5 (minimum)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
removalThreshold: 5,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept removalThreshold of 50 (maximum)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
removalThreshold: 50,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject removalThreshold above 50', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
removalThreshold: 51,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate boolean allowManualDifficulty', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
allowManualDifficulty: false,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate different transcription types', () => {
|
||||
const types = ['pinyin', 'zhuyin', 'wade-giles', 'ipa']
|
||||
types.forEach((type) => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
transcriptionType: type,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
11
src/lib/validations/preferences.ts
Normal file
11
src/lib/validations/preferences.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const updatePreferencesSchema = z.object({
|
||||
preferredLanguageId: z.string().optional(),
|
||||
characterDisplay: z.enum(['SIMPLIFIED', 'TRADITIONAL', 'BOTH']).optional(),
|
||||
transcriptionType: z.string().optional(),
|
||||
cardsPerSession: z.number().int().min(5).max(100).optional(),
|
||||
dailyGoal: z.number().int().min(10).max(500).optional(),
|
||||
removalThreshold: z.number().int().min(5).max(50).optional(),
|
||||
allowManualDifficulty: z.boolean().optional(),
|
||||
})
|
||||
Reference in New Issue
Block a user