服务端 - 删掉没人用的 /account/register,外网不能再批量注册小号 - 模型名只放行豆包和配置的 LLM_MODEL,浏览器里存的旧模型名换成默认模型; 聊天默认模型改读 LLM_MODEL - 流式生成改成 120 秒无数据才超时(另有 15 分钟总上限),思考模型不会被中途掐断; SSE 最后一行没有换行时不再丢内容 - WebSocket:token 无效 401、任务不存在 404,建对话失败时关闭连接; 引导出错时把没有回复的提问移出上下文 - 未公开的教程只有超管能看,未公开的挑战和素材管理员也能看,其他人 404 - Caddy:/media 加 CSP sandbox 和 nosniff,上传的 html/svg 直接打开不能跑脚本; 加 CORS,预览 iframe 里的代码可以读取字体和 fetch 素材 前端 - 新增 ReconnectingSocket:意外断开后退避重连,握手失败先刷新 token; 断线时清掉“生成中”状态,旧连接的事件不再改写新连接 - marked 扩展抽到 utils/markdown.ts:教程用独立实例,不再改全局 marked; 学生内容的渲染保留高亮,不带会渲染原始 HTML 的 preview 扩展 - 挑战页、创作页加载完成前离开时不再建立连接,离开时关闭引导弹窗 - 教程、提交列表、用户管理跟随浏览器前进后退;快速翻页只用最后一次结果, 改筛选条件不再请求两次 - 随机评分弹窗在 AI 回复完之后不再弹出 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
4.6 KiB
TypeScript
117 lines
4.6 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { pyRound, pySum } from "../src/services/pyround"
|
|
import { chatModel, extractCode, parseGuidanceResponse, streamChat } from "../src/services/llm"
|
|
import { config } from "../src/config"
|
|
import { checkPassword, makePassword } from "../src/auth"
|
|
import { formatMicros, iso, uuidHex, uuidOut } from "../src/db"
|
|
import { toCsv } from "../src/services/gradebook"
|
|
|
|
describe("pyRound", () => {
|
|
test("银行家舍入,与 Python round() 一致", () => {
|
|
expect(pyRound(2.5)).toBe(2)
|
|
expect(pyRound(3.5)).toBe(4)
|
|
expect(pyRound(0.125, 2)).toBe(0.12)
|
|
expect(pyRound(0.375, 2)).toBe(0.38)
|
|
// 2.675 的二进制值略小于 2.675
|
|
expect(pyRound(2.675, 2)).toBe(2.67)
|
|
expect(pyRound(1 / 3, 2)).toBe(0.33)
|
|
expect(pyRound(95.5)).toBe(96)
|
|
})
|
|
})
|
|
|
|
describe("pySum", () => {
|
|
test("补偿求和,与 Python 3.12+ 的 sum() 一致", () => {
|
|
expect(pySum([0.1, 0.2, 0.3])).toBe(0.6)
|
|
expect(pySum([1e100, 1, -1e100])).toBe(1)
|
|
expect(pySum([])).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe("extractCode", () => {
|
|
test("单个 html 代码块拆出 style 和 script", () => {
|
|
const text = "说明\n```html\n<style>\na{}\n</style>\n<a>x</a>\n<script>\nf()\n</script>\n```"
|
|
expect(extractCode(text)).toEqual({ html: "<a>x</a>", css: "a{}", js: "f()" })
|
|
})
|
|
test("分开的代码块(旧格式)", () => {
|
|
const text = "```html\n<p/>\n```\n```css\np{}\n```\n```javascript\nx()\n```"
|
|
expect(extractCode(text)).toEqual({ html: "<p/>", css: "p{}", js: "x()" })
|
|
})
|
|
test("没有代码块", () => {
|
|
expect(extractCode("hello")).toEqual({ html: null, css: null, js: null })
|
|
})
|
|
})
|
|
|
|
test("parseGuidanceResponse", () => {
|
|
expect(parseGuidanceResponse("[READY]\n\n好")).toEqual(["好", true])
|
|
expect(parseGuidanceResponse("再想想")).toEqual(["再想想", false])
|
|
})
|
|
|
|
test("兼容 Django 的 pbkdf2 密码哈希", async () => {
|
|
// 由 Django 的 PBKDF2PasswordHasher 生成的 "secret" 的哈希
|
|
const django = "pbkdf2_sha256$1000$abcdefghijklmnopqrstuv$1nE4fvOmiD+ZePp39y6TYTXfkn5HEwJDyz8e6UkQE1w="
|
|
expect(await checkPassword("secret", django)).toBe(true)
|
|
expect(await checkPassword("Secret", django)).toBe(false)
|
|
expect(await checkPassword("secret", await makePassword("secret"))).toBe(true)
|
|
expect(await checkPassword("wrong", await makePassword("secret"))).toBe(false)
|
|
expect(await checkPassword("secret", "bcrypt$xxx")).toBe(false)
|
|
})
|
|
|
|
test("时间与 UUID 格式", () => {
|
|
expect(formatMicros(Date.UTC(2026, 0, 2, 3, 4, 5) * 1000 + 678901)).toBe("2026-01-02 03:04:05.678901")
|
|
expect(iso("2026-01-02 03:04:05.678901")).toBe("2026-01-02T03:04:05.678Z")
|
|
const hex = "0123456789abcdef0123456789abcdef"
|
|
expect(uuidOut(hex)).toBe("01234567-89ab-cdef-0123-456789abcdef")
|
|
expect(uuidHex("01234567-89AB-cdef-0123-456789abcdef")).toBe(hex)
|
|
expect(uuidHex("nope")).toBeNull()
|
|
})
|
|
|
|
test("CSV 转义", () => {
|
|
expect(toCsv([["a", 'b"c', "d,e"], [1, 2.5, ""]])).toBe('a,"b""c","d,e"\r\n1,2.5,\r\n')
|
|
})
|
|
|
|
describe("streamChat", () => {
|
|
test("不认识的模型换成默认模型,最后一行没有换行也能读到", async () => {
|
|
let model = ""
|
|
const server = Bun.serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
model = ((await req.json()) as any).model
|
|
const line = (s: string) => `data: ${JSON.stringify({ choices: [{ delta: { content: s } }] })}`
|
|
// 一行拆在两个分块之间,最后一行不带换行
|
|
const body = `${line("你好")}\n\n${line("世")}\n${line("界")}`
|
|
const enc = new TextEncoder()
|
|
const cut = body.indexOf("世") + 2
|
|
return new Response(
|
|
new ReadableStream({
|
|
async start(ctrl) {
|
|
ctrl.enqueue(enc.encode(body.slice(0, cut)))
|
|
await Bun.sleep(5)
|
|
ctrl.enqueue(enc.encode(body.slice(cut)))
|
|
ctrl.close()
|
|
},
|
|
}),
|
|
{ headers: { "content-type": "text/event-stream" } },
|
|
)
|
|
},
|
|
})
|
|
const saved = { ...config }
|
|
Object.assign(config, { llmApiKey: "k", llmBaseUrl: `http://localhost:${server.port}` })
|
|
try {
|
|
let out = ""
|
|
for await (const s of streamChat([{ role: "user", content: "hi" }], "deepseek-v4-flash-thinking")) out += s
|
|
expect(out).toBe("你好世界")
|
|
expect(model).toBe(config.llmModel)
|
|
} finally {
|
|
Object.assign(config, saved)
|
|
server.stop(true)
|
|
}
|
|
})
|
|
|
|
test("chatModel 只放行配置里的模型", () => {
|
|
expect(chatModel("doubao-seed-2-1-lite-260915")).toBe("doubao-seed-2-1-lite-260915")
|
|
expect(chatModel(config.llmModel)).toBe(config.llmModel)
|
|
expect(chatModel("gpt-5")).toBe(config.llmModel)
|
|
expect(chatModel("")).toBe(config.llmModel)
|
|
})
|
|
})
|