Refactor frontend workspaces and centralize API contracts
Organize domain APIs and types under common/apis, shared components under named folders, and private workspace logic alongside each page. Use Bearer sessions, explicit response envelopes, session-aware routing and streams, and configurable login captcha presentation for the deployed backend contract. Preserve news, economic calendar, crawler, market, chat, Skills, settings, and demo trading workflows with responsive layouts and explicit configuration saves. Validate with frontend lint, type checking, 22 test groups, production builds, and deployed desktop/mobile page checks.
This commit is contained in:
1 parent
77b33e5e77
commit
bc480fb806
102 files changed
+4716
-1707
No files matched your search
@@ -0,0 +1,207 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { setImmediate } from "node:timers/promises"
|
||||
import { moduleLoader, require } from "./module-loader.ts"
|
||||
|
||||
const axios = require("axios").default
|
||||
const pinia = require("pinia")
|
||||
const vue = require("vue")
|
||||
const { createRouter, createMemoryHistory } = require("vue-router")
|
||||
|
||||
const ok = (data: unknown) => ({ status: 200, body: { code: 0, data, message: "" } })
|
||||
const failure = (status: number) => ({ status, body: { code: status, data: null, message: `failure ${status}` } })
|
||||
|
||||
function environment(respond: (config: any) => any) {
|
||||
const messages: string[] = []
|
||||
const values = new Map<string, string>()
|
||||
const storage = { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value), removeItem: (key: string) => values.delete(key) }
|
||||
pinia.setActivePinia(pinia.createPinia())
|
||||
const routes = [
|
||||
{ path: "/login", name: "login" },
|
||||
{ path: "/news", name: "news", meta: { admin: true } },
|
||||
{ path: "/calendar", name: "calendar", meta: { admin: true } },
|
||||
{ path: "/403", name: "forbidden" },
|
||||
{ path: "/connection-error", name: "connection-error", meta: { public: true } }
|
||||
].map(route => ({ ...route, component: { render: () => null } }))
|
||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||
const load = moduleLoader({
|
||||
"@/router": { router },
|
||||
"element-plus": { ElMessage: { error: (message: string) => messages.push(message) } },
|
||||
"axios": { __esModule: true, ...axios, default: { ...axios, create: (config: any) => axios.create({
|
||||
...config,
|
||||
adapter: async (config: any) => {
|
||||
const { status, body } = await respond(config)
|
||||
const response = { status, data: body, statusText: "", headers: {}, config }
|
||||
if (status >= 400) throw new axios.AxiosError(`HTTP ${status}`, "ERR_BAD_RESPONSE", config, undefined, response)
|
||||
return response
|
||||
}
|
||||
}) } }
|
||||
}, { localStorage: storage, ...vue })
|
||||
const { request, ApiError } = load("@/http/axios")
|
||||
const user = load("@/pinia/stores/user").useUserStore()
|
||||
const guards = load("@/router/guards")
|
||||
return { request, ApiError, user, guards, router, messages, load, storage }
|
||||
}
|
||||
|
||||
test("JSON 解包、明确拒绝错误、403 保留身份且只提示一次", async () => {
|
||||
let result = ok([1])
|
||||
const env = environment(() => result)
|
||||
env.user.setToken("current")
|
||||
assert.deepEqual(await env.request({ url: "/news", method: "get" }), [1])
|
||||
result = failure(403)
|
||||
await assert.rejects(env.request({ url: "/news", method: "get" }), (error: any) => error.code === 403)
|
||||
assert.equal(env.user.token, "current")
|
||||
assert.equal(env.messages.length, 1)
|
||||
result = { status: 200, body: { code: 400, data: null, message: "business failure" } }
|
||||
await assert.rejects(env.request({ url: "/news", method: "get" }), /business failure/)
|
||||
result = { status: 200, body: { items: [] } } as any
|
||||
await assert.rejects(env.request({ url: "/news", method: "get" }), /响应格式无效/)
|
||||
})
|
||||
|
||||
test("旧身份恢复响应不能写入新会话;同 Token 重新设置也隔离", async () => {
|
||||
const pending = Promise.withResolvers<any>()
|
||||
const env = environment(() => pending.promise)
|
||||
env.user.setToken("same-token")
|
||||
const restore = env.user.fetchMe()
|
||||
const rejected = assert.rejects(restore, (error: any) => axios.isCancel(error))
|
||||
await setImmediate()
|
||||
env.user.setToken("same-token")
|
||||
pending.resolve(ok({ username: "old", is_admin: true }))
|
||||
await rejected
|
||||
assert.equal(env.user.identity, undefined)
|
||||
assert.equal(env.user.token, "same-token")
|
||||
assert.equal(env.messages.length, 0)
|
||||
})
|
||||
|
||||
test("旧 401 与旧注销结果都不能注销新登录", async () => {
|
||||
for (const request of ["read", "logout"]) {
|
||||
const pending = Promise.withResolvers<any>()
|
||||
const env = environment(() => pending.promise)
|
||||
env.user.setToken("old")
|
||||
const operation = request === "logout" ? env.user.logout() : env.request({ url: "/news", method: "get" })
|
||||
const rejected = assert.rejects(operation, (error: any) => axios.isCancel(error))
|
||||
await setImmediate()
|
||||
env.user.setToken("new")
|
||||
pending.resolve(request === "logout" ? ok(null) : failure(401))
|
||||
await rejected
|
||||
assert.equal(env.user.token, "new")
|
||||
assert.equal(env.messages.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test("注销失败保留会话;成功才清除持久 Token", async () => {
|
||||
let result: ReturnType<typeof ok> = failure(503)
|
||||
const env = environment(() => result)
|
||||
env.user.setToken("current")
|
||||
await assert.rejects(env.user.logout())
|
||||
assert.equal(env.user.token, "current")
|
||||
result = ok(null)
|
||||
await env.user.logout()
|
||||
assert.equal(env.user.token, "")
|
||||
assert.equal(env.storage.getItem("alphapulse.auth.token"), null)
|
||||
})
|
||||
|
||||
test("匿名深链、登录回跳与已登录刷新使用真实身份", async () => {
|
||||
let count = 0
|
||||
const env = environment(() => {
|
||||
count++
|
||||
return ok({ username: "admin", is_admin: true })
|
||||
})
|
||||
env.guards.installGuards(env.router)
|
||||
await env.router.push("/calendar?date=2026-10-25")
|
||||
assert.equal(env.router.currentRoute.value.name, "login")
|
||||
assert.equal(env.router.currentRoute.value.query.redirect, "/calendar?date=2026-10-25")
|
||||
env.user.setToken("current")
|
||||
await env.router.push("/news")
|
||||
await env.router.push("/login?redirect=/calendar%3Fdate%3D2026-10-25")
|
||||
assert.equal(env.router.currentRoute.value.fullPath, "/calendar?date=2026-10-25")
|
||||
assert.equal(count, 1)
|
||||
await env.router.push("/news")
|
||||
assert.equal(count, 1)
|
||||
for (const target of ["//evil.test", "https://evil.test", "/\\evil.test", "/login?redirect=/login"])
|
||||
assert.equal(env.guards.safeRedirect(target), "/news")
|
||||
})
|
||||
|
||||
test("身份恢复 401 保留深链回跳;403 不清身份,网络错误允许重试", async () => {
|
||||
for (const mode of ["expired", "forbidden", "network"]) {
|
||||
const env = environment(() => mode === "expired" ? failure(401) : mode === "network" ? failure(503) : ok({ username: "former-admin", is_admin: false }))
|
||||
env.user.setToken("current")
|
||||
env.guards.installGuards(env.router)
|
||||
await env.router.push("/calendar?date=today")
|
||||
const route = env.router.currentRoute.value
|
||||
assert.equal(route.name, mode === "expired" ? "login" : mode === "forbidden" ? "forbidden" : "connection-error")
|
||||
assert.equal(env.user.token, mode === "expired" ? "" : "current")
|
||||
if (mode === "expired") assert.equal(route.query.redirect, "/calendar?date=today")
|
||||
if (mode === "forbidden") assert.equal(env.user.identity.username, "former-admin")
|
||||
}
|
||||
})
|
||||
|
||||
test("共享来源缓存只有一个在途请求,切换会话清空且丢弃旧响应", async () => {
|
||||
const pending = Promise.withResolvers<any>()
|
||||
let count = 0
|
||||
const env = environment(() => {
|
||||
count++
|
||||
return pending.promise
|
||||
})
|
||||
env.user.setToken("old")
|
||||
const sources = env.load("@/pinia/stores/sources").useSourcesStore()
|
||||
const first = sources.load()
|
||||
assert.equal(first, sources.load())
|
||||
const rejected = assert.rejects(first, (error: any) => axios.isCancel(error))
|
||||
await setImmediate()
|
||||
env.user.setToken("new")
|
||||
pending.resolve(ok({ sources: [{ code: "private", name: "old" }] }))
|
||||
await rejected
|
||||
assert.equal(count, 1)
|
||||
assert.deepEqual(Object.keys(sources.names), [])
|
||||
})
|
||||
|
||||
test("确认框结束后不能以新会话发布旧页面的 Skill", async () => {
|
||||
const confirm = Promise.withResolvers<void>()
|
||||
const user = vue.reactive({ token: "old", sessionVersion: 1 })
|
||||
let published = 0
|
||||
const node = { id: 1, kind: "skill", name: "test" }
|
||||
const load = moduleLoader({
|
||||
"vue": { ...vue, onBeforeUnmount() {} },
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@@/apis/skills": {
|
||||
listSkillTreeApi: async () => [node],
|
||||
listSkillVersionsApi: async () => [{ id: 1, version_no: 1, content: "text" }],
|
||||
publishSkillVersionApi: async () => {
|
||||
published++
|
||||
}
|
||||
},
|
||||
"@@/components/SkillTree/index.vue": {},
|
||||
"@@/components/SkillVersionDrawer/index.vue": {},
|
||||
"@@/utils/markdown": { renderMarkdown: (text: string) => text }
|
||||
}, {
|
||||
...vue,
|
||||
onMounted() {},
|
||||
onBeforeUnmount() {},
|
||||
onBeforeRouteLeave() {},
|
||||
ElMessage: { success() {} },
|
||||
ElMessageBox: { confirm: () => confirm.promise }
|
||||
})
|
||||
const page = load("@/pages/skills/index.vue").default.setup({}, { expose() {} })
|
||||
await page.loadTree()
|
||||
await page.select(node)
|
||||
const operation = page.publishLatest()
|
||||
await setImmediate()
|
||||
user.sessionVersion++
|
||||
confirm.resolve()
|
||||
await assert.rejects(operation, (error: any) => axios.isCancel(error))
|
||||
assert.equal(published, 0)
|
||||
})
|
||||
|
||||
test("OpenAPI 仅显式原样返回;201/202 保留业务数据与 null", async () => {
|
||||
let result: { status: number, body: unknown } = { status: 200, body: { openapi: "3.1.0", info: { title: "test", version: "1" }, paths: {} } }
|
||||
const env = environment(() => result)
|
||||
env.user.setToken("current")
|
||||
const spec = await env.load("@@/apis/docs").getOpenApiApi()
|
||||
assert.equal(spec.openapi, "3.1.0")
|
||||
await assert.rejects(env.request({ url: "/news", method: "get" }), /响应格式无效/)
|
||||
for (const status of [201, 202]) {
|
||||
result = { status, body: { code: 0, data: null, message: "" } }
|
||||
assert.equal(await env.request({ url: "/write", method: "post" }), null)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { moduleLoader, require } from "./module-loader.ts"
|
||||
|
||||
const vue = require("vue")
|
||||
|
||||
test("验证码刷新保留最新响应;登录锁覆盖导航,失败刷新挑战", async () => {
|
||||
const first = Promise.withResolvers<any>()
|
||||
const second = Promise.withResolvers<any>()
|
||||
const signed = Promise.withResolvers<any>()
|
||||
const navigated = Promise.withResolvers<any>()
|
||||
let challenges = 0
|
||||
let logins = 0
|
||||
let fail = false
|
||||
const user = { token: "", setToken(value: string) {
|
||||
this.token = value
|
||||
} }
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/router/guards": { safeRedirect: () => "/calendar" },
|
||||
"@@/apis/auth": {
|
||||
getCaptchaApi: () => ++challenges === 1 ? first.promise : challenges === 2 ? second.promise : Promise.resolve({ enabled: true, captcha_id: "retry", image: "png", expires_in: 120 }),
|
||||
loginApi: () => {
|
||||
logins++
|
||||
return fail ? Promise.reject(new Error("验证码失败")) : signed.promise
|
||||
}
|
||||
}
|
||||
}, {
|
||||
...vue,
|
||||
useRoute: () => ({ query: { redirect: "/calendar" } }),
|
||||
useRouter: () => ({ replace: () => navigated.promise }),
|
||||
onMounted() {},
|
||||
onBeforeUnmount() {},
|
||||
ElMessage: { warning() {} }
|
||||
})
|
||||
const page = load("@/pages/login/index.vue").default.setup({}, { expose() {} })
|
||||
page.formRef.value = { validate: async () => true }
|
||||
const a = page.refreshCaptcha()
|
||||
const b = page.refreshCaptcha()
|
||||
second.resolve({ enabled: true, captcha_id: "new", image: "new-png", expires_in: 120 })
|
||||
await b
|
||||
first.resolve({ enabled: true, captcha_id: "old", image: "old-png", expires_in: 120 })
|
||||
await a
|
||||
assert.equal(page.form.captcha_id, "new")
|
||||
assert.equal(page.image.value, "new-png")
|
||||
Object.assign(page.form, { username: "admin", password: "password", code: "ABCDE" })
|
||||
const sending = page.submit()
|
||||
await page.submit()
|
||||
await Promise.resolve()
|
||||
assert.equal(logins, 1)
|
||||
signed.resolve({ token: "new-token" })
|
||||
await Promise.resolve()
|
||||
assert.equal(page.loading.value, true)
|
||||
navigated.resolve(undefined)
|
||||
await sending
|
||||
assert.equal(user.token, "new-token")
|
||||
assert.equal(page.loading.value, false)
|
||||
fail = true
|
||||
await assert.rejects(page.submit())
|
||||
assert.equal(page.form.captcha_id, "retry")
|
||||
assert.equal(page.form.code, "")
|
||||
})
|
||||
|
||||
test("验证码关闭时只提交账号密码,失败无需刷新挑战", async () => {
|
||||
let challenges = 0
|
||||
let fail = false
|
||||
const bodies: unknown[] = []
|
||||
const user = { token: "", setToken(value: string) {
|
||||
this.token = value
|
||||
} }
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/router/guards": { safeRedirect: () => "/news" },
|
||||
"@@/apis/auth": {
|
||||
getCaptchaApi: async () => {
|
||||
challenges++
|
||||
return { enabled: false }
|
||||
},
|
||||
loginApi: async (body: unknown) => {
|
||||
bodies.push(body)
|
||||
if (fail) throw new Error("用户名或密码错误")
|
||||
return { token: "password-login-token" }
|
||||
}
|
||||
}
|
||||
}, {
|
||||
...vue,
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ replace: async () => {} }),
|
||||
onMounted() {},
|
||||
onBeforeUnmount() {},
|
||||
ElMessage: { warning() {
|
||||
assert.fail("不应要求验证码")
|
||||
} }
|
||||
})
|
||||
const page = load("@/pages/login/index.vue").default.setup({}, { expose() {} })
|
||||
page.formRef.value = { validate: async () => true }
|
||||
await page.refreshCaptcha()
|
||||
assert.equal(page.captchaEnabled.value, false)
|
||||
Object.assign(page.form, { username: "admin", password: "password" })
|
||||
await page.submit()
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(bodies)), [{ username: "admin", password: "password" }])
|
||||
assert.equal(user.token, "password-login-token")
|
||||
fail = true
|
||||
await assert.rejects(page.submit())
|
||||
assert.equal(challenges, 1)
|
||||
assert.equal(page.loading.value, false)
|
||||
})
|
||||
|
||||
test("验证配置读取失败阻止登录,重试成功后解除;表单失败不发送请求", async () => {
|
||||
let fail = true
|
||||
let logins = 0
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => ({ sessionVersion: 0 }) },
|
||||
"@/router/guards": { safeRedirect: () => "/news" },
|
||||
"@@/apis/auth": {
|
||||
getCaptchaApi: async () => {
|
||||
if (fail) throw new Error("配置读取失败")
|
||||
return { enabled: false }
|
||||
},
|
||||
loginApi: () => {
|
||||
logins++
|
||||
}
|
||||
}
|
||||
}, { ...vue, useRoute: () => ({ query: {} }), useRouter: () => ({}), onMounted() {}, onBeforeUnmount() {} })
|
||||
const page = load("@/pages/login/index.vue").default.setup({}, { expose() {} })
|
||||
await assert.rejects(page.refreshCaptcha(), /配置读取失败/)
|
||||
await page.submit()
|
||||
assert.equal(logins, 0)
|
||||
assert.equal(page.captchaReady.value, false)
|
||||
assert.equal(page.captchaError.value, "配置读取失败")
|
||||
fail = false
|
||||
await page.refreshCaptcha()
|
||||
page.formRef.value = { validate: async () => {
|
||||
throw new Error("表单无效")
|
||||
} }
|
||||
await assert.rejects(page.submit(), /表单无效/)
|
||||
assert.equal(logins, 0)
|
||||
assert.equal(page.captchaReady.value, true)
|
||||
assert.equal(page.loading.value, false)
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { createRequire, Module } from "node:module"
|
||||
import { dirname, resolve } from "node:path"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import ts from "typescript"
|
||||
import { compileScript, parse } from "vue/compiler-sfc"
|
||||
|
||||
export const require = createRequire(import.meta.url)
|
||||
const source = new URL("../src/", import.meta.url).pathname
|
||||
|
||||
/** 使用现有 TS/Vue 编译器执行真实模块;只替换 I/O 和浏览器生命周期。 */
|
||||
export function moduleLoader(mocks: Record<string, unknown>, globals: Record<string, unknown> = {}) {
|
||||
const cache = new Map<string, Module>()
|
||||
function load(name: string, parent = source): any {
|
||||
if (name in mocks) return mocks[name]
|
||||
if (!name.startsWith("@/") && !name.startsWith("@@/") && !name.startsWith(".")) return require(name)
|
||||
let filename = name.startsWith("@@/")
|
||||
? resolve(source, "common", name.slice(3))
|
||||
: name.startsWith("@/") ? resolve(source, name.slice(2)) : resolve(parent, name)
|
||||
if (!/\.(?:vue|ts)$/.test(filename)) filename = existsSync(`${filename}.ts`) ? `${filename}.ts` : resolve(filename, "index.ts")
|
||||
if (cache.has(filename)) return cache.get(filename)!.exports
|
||||
const mod = new Module(filename)
|
||||
cache.set(filename, mod)
|
||||
let script = readFileSync(filename, "utf8")
|
||||
if (filename.endsWith(".vue")) script = compileScript(parse(script).descriptor, { id: filename }).content
|
||||
const code = ts.transpileModule(script, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
runInNewContext(code, {
|
||||
module: mod,
|
||||
exports: mod.exports,
|
||||
require: (value: string) => load(value, dirname(filename)),
|
||||
console,
|
||||
Error,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
AbortController,
|
||||
TextDecoder,
|
||||
URL,
|
||||
...globals
|
||||
}, { filename })
|
||||
return mod.exports
|
||||
}
|
||||
return load
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { createRequire, Module } from "node:module"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import ts from "typescript"
|
||||
import * as vue from "vue"
|
||||
import { compileScript, parse } from "vue/compiler-sfc"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
import { moduleLoader } from "./module-loader.ts"
|
||||
|
||||
interface Version {
|
||||
id: number
|
||||
@@ -15,12 +9,9 @@ interface Version {
|
||||
}
|
||||
|
||||
function setupComponent(component: string, modules: Record<string, unknown>, confirm: () => Promise<unknown> = async () => {}) {
|
||||
const filename = new URL(`../src/pages/${component}`, import.meta.url)
|
||||
const { descriptor } = parse(readFileSync(filename, "utf8"))
|
||||
const script = compileScript(descriptor, { id: component }).content
|
||||
const code = ts.transpileModule(script, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const globals = {
|
||||
ref: vue.ref,
|
||||
shallowRef: vue.shallowRef,
|
||||
reactive: vue.reactive,
|
||||
computed: vue.computed,
|
||||
watch: vue.watch,
|
||||
@@ -31,18 +22,16 @@ function setupComponent(component: string, modules: Record<string, unknown>, con
|
||||
ElMessage: { success() {}, warning() {} },
|
||||
ElMessageBox: { confirm }
|
||||
}
|
||||
const mod = new Module(filename.pathname)
|
||||
const load = (path: string) => path in modules ? modules[path] : require(path)
|
||||
runInNewContext(code, { require: load, module: mod, exports: mod.exports, ...globals }, { filename: component })
|
||||
return mod.exports.default.setup({}, { expose() {} })
|
||||
const load = moduleLoader({ "@/http/axios": { logRequestError() {} }, "@@/composables/useSessionScope": { useSessionScope: () => ({ isCurrent: () => true, assertCurrent() {} }) }, ...modules }, globals)
|
||||
return load(`@/pages/${component}`).default.setup({}, { expose() {} })
|
||||
}
|
||||
|
||||
function skills(api: Record<string, unknown>, confirm?: () => Promise<unknown>) {
|
||||
return setupComponent("skills/index.vue", {
|
||||
"@/apis/skills": api,
|
||||
"@/components/SkillTree.vue": {},
|
||||
"@/components/SkillVersionDrawer.vue": {},
|
||||
"@/utils/markdown": { renderMarkdown: (text: string) => text }
|
||||
"@@/apis/skills": api,
|
||||
"@@/components/SkillTree/index.vue": {},
|
||||
"@@/components/SkillVersionDrawer/index.vue": {},
|
||||
"@@/utils/markdown": { renderMarkdown: (text: string) => text }
|
||||
}, confirm)
|
||||
}
|
||||
|
||||
@@ -52,9 +41,9 @@ async function checkSkillSwitch() {
|
||||
const drafts: { id: number, content: string }[] = []
|
||||
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
|
||||
const page = skills({
|
||||
listSkillTree: async () => nodes,
|
||||
listSkillVersions: (id: number) => id === 1 ? a.promise : b.promise,
|
||||
saveSkillDraft: async (id: number, content: string) => {
|
||||
listSkillTreeApi: async () => nodes,
|
||||
listSkillVersionsApi: (id: number) => id === 1 ? a.promise : b.promise,
|
||||
saveSkillDraftApi: async (id: number, content: string) => {
|
||||
drafts.push({ id, content })
|
||||
return { id: 20, version_no: 2, content }
|
||||
}
|
||||
@@ -79,8 +68,8 @@ async function checkSkillReload() {
|
||||
const old = Promise.withResolvers<Version[]>()
|
||||
let calls = 0
|
||||
const page = skills({
|
||||
listSkillTree: async () => [{ id: 1, kind: "skill" }],
|
||||
listSkillVersions: () => ++calls === 1 ? old.promise : Promise.resolve([{ id: 2, content: "最新" }])
|
||||
listSkillTreeApi: async () => [{ id: 1, kind: "skill" }],
|
||||
listSkillVersionsApi: () => ++calls === 1 ? old.promise : Promise.resolve([{ id: 2, content: "最新" }])
|
||||
})
|
||||
await page.loadTree()
|
||||
page.selectedId.value = 1
|
||||
@@ -96,10 +85,10 @@ async function checkPublishSwitch() {
|
||||
const published: [number, number][] = []
|
||||
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
|
||||
const page = skills({
|
||||
listSkillTree: async () => nodes,
|
||||
listSkillVersions: async (id: number) => [{ id, content: `内容 ${id}` }],
|
||||
saveSkillDraft: () => saved.promise,
|
||||
publishSkillVersion: async (id: number, version: number) => published.push([id, version])
|
||||
listSkillTreeApi: async () => nodes,
|
||||
listSkillVersionsApi: async (id: number) => [{ id, content: `内容 ${id}` }],
|
||||
saveSkillDraftApi: () => saved.promise,
|
||||
publishSkillVersionApi: async (id: number, version: number) => published.push([id, version])
|
||||
})
|
||||
await page.loadTree()
|
||||
await page.select(nodes[0])
|
||||
@@ -115,15 +104,17 @@ async function checkPublishSwitch() {
|
||||
async function checkTickerSwitch() {
|
||||
const btc = Promise.withResolvers<Record<string, string>>()
|
||||
const eth = Promise.withResolvers<Record<string, string>>()
|
||||
const page = setupComponent("market/index.vue", {
|
||||
"@/apis/market": { getTicker: (_: string, symbol: string) => symbol === "BTCUSDT" ? btc.promise : eth.promise },
|
||||
"@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) },
|
||||
const load = moduleLoader({
|
||||
"@/http/axios": { logRequestError() {} },
|
||||
"@@/apis/market": { getTickerApi: (_: string, symbol: string) => symbol === "BTCUSDT" ? btc.promise : eth.promise },
|
||||
"@@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) },
|
||||
"./chart": { DEFAULT_FAVORITE_INTERVALS: [], INTERVAL_LABELS: {}, MAIN_INDICATORS: [], SUB_INDICATORS: [] },
|
||||
"./useMarketSocket": { useMarketSocket: () => ({ connected: vue.ref(true), subscribe() {} }) },
|
||||
"./components/AddSymbolDialog.vue": {},
|
||||
"./components/SymbolManager.vue": {},
|
||||
"klinecharts": {}
|
||||
})
|
||||
}, { ...vue, onMounted() {}, onBeforeUnmount() {}, setInterval() {} })
|
||||
const page = load("@/pages/market/composables/useMarketChart").useMarketChart()
|
||||
const first = page.loadTicker()
|
||||
page.selection.value = { market: "um", symbol: "ETHUSDT" }
|
||||
await vue.nextTick()
|
||||
@@ -141,9 +132,9 @@ async function checkPublishConfirmationSwitch() {
|
||||
const published: [number, number][] = []
|
||||
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
|
||||
const page = skills({
|
||||
listSkillTree: async () => nodes,
|
||||
listSkillVersions: async (id: number) => [{ id, content: `内容 ${id}`, version_no: 1 }],
|
||||
publishSkillVersion: async (id: number, version: number) => published.push([id, version])
|
||||
listSkillTreeApi: async () => nodes,
|
||||
listSkillVersionsApi: async (id: number) => [{ id, content: `内容 ${id}`, version_no: 1 }],
|
||||
publishSkillVersionApi: async (id: number, version: number) => published.push([id, version])
|
||||
}, () => confirmation.promise)
|
||||
await page.loadTree()
|
||||
await page.select(nodes[0])
|
||||
@@ -157,21 +148,21 @@ async function checkPublishConfirmationSwitch() {
|
||||
|
||||
function contextEditor(api: Record<string, unknown>) {
|
||||
return setupComponent("chat/components/ContextTab.vue", {
|
||||
"@/apis/chat": api,
|
||||
"@/utils/markdown": { renderMarkdown: (text: string) => text },
|
||||
"@/utils/text": { formatTime: (text: string) => text }
|
||||
"@@/apis/chat": api,
|
||||
"@@/utils/markdown": { renderMarkdown: (text: string) => text },
|
||||
"@@/utils/text": { formatTime: (text: string) => text }
|
||||
})
|
||||
}
|
||||
|
||||
async function checkContextRefresh() {
|
||||
const updated = Promise.withResolvers<{ id: number, content: string }>()
|
||||
const api = {
|
||||
getChatContext: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersions: async () => []
|
||||
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersionsApi: async () => []
|
||||
}
|
||||
const page = contextEditor(api)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
api.getChatContext = () => updated.promise
|
||||
api.getChatContextApi = () => updated.promise
|
||||
const refresh = page.refreshFromChat()
|
||||
page.content.value = "刷新期间的新输入"
|
||||
updated.resolve({ id: 2, content: "Chat 更新" })
|
||||
@@ -185,9 +176,9 @@ async function checkContextSave() {
|
||||
const saved = Promise.withResolvers<{ id: number, content: string }>()
|
||||
let submitted = ""
|
||||
const page = contextEditor({
|
||||
getChatContext: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersions: async () => [],
|
||||
saveChatContext: (content: string) => {
|
||||
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersionsApi: async () => [],
|
||||
saveChatContextApi: (content: string) => {
|
||||
submitted = content
|
||||
return saved.promise
|
||||
}
|
||||
@@ -207,13 +198,13 @@ async function checkContextSave() {
|
||||
async function checkContextReloadAfterSave() {
|
||||
const stale = Promise.withResolvers<{ id: number, content: string }>()
|
||||
const api = {
|
||||
getChatContext: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersions: async () => [],
|
||||
saveChatContext: async (content: string) => ({ id: 2, content })
|
||||
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersionsApi: async () => [],
|
||||
saveChatContextApi: async (content: string) => ({ id: 2, content })
|
||||
}
|
||||
const page = contextEditor(api)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
api.getChatContext = () => stale.promise
|
||||
api.getChatContextApi = () => stale.promise
|
||||
const loading = page.load()
|
||||
page.content.value = "已保存修改"
|
||||
await page.save()
|
||||
@@ -227,9 +218,9 @@ async function checkContextReloadAfterSave() {
|
||||
async function checkContextRefreshDuringSave() {
|
||||
const saved = Promise.withResolvers<{ id: number, content: string }>()
|
||||
const page = contextEditor({
|
||||
getChatContext: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersions: async () => [],
|
||||
saveChatContext: () => saved.promise
|
||||
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersionsApi: async () => [],
|
||||
saveChatContextApi: () => saved.promise
|
||||
})
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
page.content.value = "提交的修改"
|
||||
@@ -245,15 +236,15 @@ async function checkContextFailedSave() {
|
||||
const saved = Promise.withResolvers<{ id: number, content: string }>()
|
||||
const stale = Promise.withResolvers<{ id: number, content: string }>()
|
||||
const api = {
|
||||
getChatContext: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersions: async () => [],
|
||||
saveChatContext: () => saved.promise
|
||||
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersionsApi: async () => [],
|
||||
saveChatContextApi: () => saved.promise
|
||||
}
|
||||
const page = contextEditor(api)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
page.content.value = "保存失败后应保留的草稿"
|
||||
const saving = page.save()
|
||||
api.getChatContext = () => stale.promise
|
||||
api.getChatContextApi = () => stale.promise
|
||||
const loading = page.load()
|
||||
saved.reject(new Error("保存失败"))
|
||||
await assert.rejects(saving, /保存失败/)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { setImmediate } from "node:timers/promises"
|
||||
import * as vue from "vue"
|
||||
import { parseEvents } from "../src/http/sse.ts"
|
||||
|
||||
import { moduleLoader, require } from "./module-loader.ts"
|
||||
|
||||
const axios = require("axios").default
|
||||
|
||||
function body(parts: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
return new ReadableStream({ start(controller) {
|
||||
for (const part of parts) controller.enqueue(encoder.encode(part))
|
||||
controller.close()
|
||||
} })
|
||||
}
|
||||
|
||||
test("SSE 分块、CRLF、注释、多行 data 与 EOF", async () => {
|
||||
const events = []
|
||||
for await (const event of parseEvents(body([": ping\r", "\nevent: delta\r\ndata: 第一", "行\r\ndata: 第二行\r\n\r", "\nevent: done\ndata: {}"])))
|
||||
events.push(event)
|
||||
assert.deepEqual(events, [{ event: "delta", data: "第一行\n第二行" }, { event: "done", data: "{}" }])
|
||||
})
|
||||
|
||||
function environment(fetcher: typeof fetch, websocket?: unknown, post?: () => Promise<unknown>) {
|
||||
const user = vue.reactive({ token: "old", sessionVersion: 1 })
|
||||
const messages: string[] = []
|
||||
const timers: { run: () => void, delay: number }[] = []
|
||||
const unmount: (() => void)[] = []
|
||||
class ApiError extends Error {
|
||||
code: number
|
||||
constructor(code: number, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/http/axios": {
|
||||
ApiError,
|
||||
request: post,
|
||||
readEnvelope: (value: any) => {
|
||||
assert.equal(typeof value.code, "number")
|
||||
return value
|
||||
},
|
||||
rejectAuthentication: (version: number) => {
|
||||
if (version === user.sessionVersion) {
|
||||
user.sessionVersion++
|
||||
user.token = ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"element-plus": { ElMessage: { error: (value: string) => messages.push(value) } }
|
||||
}, {
|
||||
...vue,
|
||||
WebSocket: websocket,
|
||||
location: { protocol: "https:", host: "preview.test" },
|
||||
Error,
|
||||
ReadableStream,
|
||||
TextEncoder,
|
||||
fetch: fetcher,
|
||||
onBeforeUnmount: (callback: () => void) => unmount.push(callback),
|
||||
setTimeout: (run: () => void, delay: number) => {
|
||||
timers.push({ run, delay })
|
||||
return timers.length
|
||||
},
|
||||
clearTimeout() {}
|
||||
})
|
||||
return { load, user, messages, timers, unmount }
|
||||
}
|
||||
|
||||
function response(text: string) {
|
||||
return new Response(body([text]), { headers: { "Content-Type": "text/event-stream" } })
|
||||
}
|
||||
|
||||
test("旧流响应不触发回调;Chat POST 失败明确拒绝且不重发", async () => {
|
||||
const pending = Promise.withResolvers<Response>()
|
||||
const env = environment(async () => pending.promise)
|
||||
let called = 0
|
||||
const operation = env.load("@/http/stream").consumeStream("/api/news/stream", {}, 1, () => called++, () => called++)
|
||||
const rejected = assert.rejects(operation, (error: any) => axios.isCancel(error))
|
||||
env.user.sessionVersion++
|
||||
pending.resolve(response("event: news\ndata: {}\n\n"))
|
||||
await rejected
|
||||
assert.equal(called, 0)
|
||||
|
||||
let requests = 0
|
||||
const events: string[] = []
|
||||
const chatEnv = environment(async (_, init) => {
|
||||
requests++
|
||||
assert.equal(init?.method, "POST")
|
||||
assert.equal((init?.headers as Record<string, string>).Authorization, "Bearer old")
|
||||
return response("event: error\ndata: {\"message\":\"模型失败\"}\n\n")
|
||||
})
|
||||
const chat = chatEnv.load("@/pages/chat/composables/useChatStream").useChatStream()
|
||||
await assert.rejects(chat.send({ text: "test" }, (event: { name: string }) => events.push(event.name)), /模型失败/)
|
||||
assert.deepEqual(events, ["error"])
|
||||
assert.equal(requests, 1)
|
||||
assert.equal(chatEnv.timers.length, 0)
|
||||
assert.equal(chat.streaming.value, false)
|
||||
})
|
||||
|
||||
test("只读订阅有限退避重连,403 停止,当前身份保留", async () => {
|
||||
let calls = 0
|
||||
const env = environment(async () => {
|
||||
calls++
|
||||
if (calls < 3) throw new Error("network unavailable")
|
||||
return new Response(JSON.stringify({ code: 403, data: null, message: "权限不足" }), { status: 403 })
|
||||
})
|
||||
const source = env.load("@/http/useEventSource").useEventSource(env.load("@@/apis/news").subscribeNewsApi, () => {})
|
||||
await setImmediate()
|
||||
assert.equal(env.timers[0].delay, 1000)
|
||||
env.timers[0].run()
|
||||
await setImmediate()
|
||||
assert.equal(env.timers[1].delay, 2000)
|
||||
env.timers[1].run()
|
||||
await setImmediate()
|
||||
assert.equal(env.timers.length, 2)
|
||||
assert.equal(env.user.token, "old")
|
||||
assert.equal(source.connected.value, false)
|
||||
assert.deepEqual(env.messages, ["network unavailable", "权限不足"])
|
||||
})
|
||||
|
||||
test("服务端 auth_error 清理当前身份;换会话立即 abort 旧 Chat", async () => {
|
||||
const env = environment(async () => response("event: auth_error\ndata: {\"code\":401,\"message\":\"已注销\"}\n\n"))
|
||||
env.load("@/http/useEventSource").useEventSource(env.load("@@/apis/news").subscribeNewsApi, () => assert.fail("不能继续推送"))
|
||||
await setImmediate()
|
||||
assert.equal(env.user.token, "")
|
||||
assert.equal(env.timers.length, 0)
|
||||
let signal: AbortSignal | undefined
|
||||
const pending = Promise.withResolvers<Response>()
|
||||
const chatEnv = environment(async (_, init) => {
|
||||
signal = init?.signal as AbortSignal
|
||||
return pending.promise
|
||||
})
|
||||
const chat = chatEnv.load("@/pages/chat/composables/useChatStream").useChatStream()
|
||||
const sending = chat.send({}, () => assert.fail("旧会话不能回调"))
|
||||
const rejected = assert.rejects(sending, (error: any) => axios.isCancel(error))
|
||||
chatEnv.user.sessionVersion++
|
||||
assert.equal(signal?.aborted, true)
|
||||
pending.resolve(response("event: done\ndata: {}\n\n"))
|
||||
await rejected
|
||||
})
|
||||
|
||||
test("行情重连申请新票据,丢弃旧连接消息,切换会话关闭连接", async () => {
|
||||
const sockets: FakeSocket[] = []
|
||||
class FakeSocket {
|
||||
static OPEN = 1
|
||||
readyState = 0
|
||||
sent: unknown[] = []
|
||||
onopen = () => {}
|
||||
onmessage = (_: any) => {}
|
||||
onclose = (_: any) => {}
|
||||
constructor(url: string, protocols: string[]) {
|
||||
assert.equal(url, "wss://preview.test/api/market/ws")
|
||||
assert.equal(protocols[0], "alphapulse.market.v1")
|
||||
assert.equal(protocols[1], `ticket.ticket-${sockets.length + 1}`)
|
||||
sockets.push(this)
|
||||
}
|
||||
|
||||
send(value: string) {
|
||||
this.sent.push(JSON.parse(value))
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3
|
||||
}
|
||||
}
|
||||
let tickets = 0
|
||||
const env = environment(async () => assert.fail("不使用 fetch 连接 WS"), FakeSocket, async () => ({ ticket: `ticket-${++tickets}`, expires_in: 30 }))
|
||||
const prices: unknown[] = []
|
||||
const stream = env.load("@/pages/market/composables/useMarketSocket").useMarketSocket(() => {}, (price: unknown) => prices.push(price))
|
||||
stream.subscribe({ market: "um", symbol: "BTCUSDT", interval: "15m" })
|
||||
await setImmediate()
|
||||
sockets[0].readyState = 1
|
||||
sockets[0].onopen()
|
||||
assert.equal(stream.connected.value, true)
|
||||
assert.equal((sockets[0].sent[0] as any).symbol, "BTCUSDT")
|
||||
sockets[0].onclose({ code: 1011 })
|
||||
env.timers[0].run()
|
||||
await setImmediate()
|
||||
assert.equal(tickets, 2)
|
||||
sockets[0].onmessage({ data: JSON.stringify({ type: "ticker", market: "um", symbol: "BTCUSDT", data: 1 }) })
|
||||
sockets[1].onmessage({ data: JSON.stringify({ type: "ticker", market: "um", symbol: "BTCUSDT", data: 2 }) })
|
||||
assert.deepEqual(prices, [2])
|
||||
env.user.sessionVersion++
|
||||
assert.equal(sockets[1].readyState, 3)
|
||||
assert.equal(stream.connected.value, false)
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { setImmediate } from "node:timers/promises"
|
||||
import * as vue from "vue"
|
||||
import { moduleLoader } from "./module-loader.ts"
|
||||
|
||||
const globals = { ...vue, onMounted() {}, onBeforeUnmount() {}, onBeforeRouteLeave() {}, ElMessage: { success() {}, warning() {} } }
|
||||
|
||||
const tradingStatus = (limit = 120) => ({ exchange: "bybit", configured: true, provider_id: null, reasoning_effort: null, snapshot: null, today: { analyses: 10, limit, input_tokens: 0, output_tokens: 0 } })
|
||||
|
||||
test("交易配置草稿抵抗轮询、仅保存改动字段;失败保留草稿并拒绝重复提交", async () => {
|
||||
const pending = Promise.withResolvers<unknown>()
|
||||
const bodies: unknown[] = []
|
||||
let fail = false
|
||||
const props = vue.reactive({ status: tradingStatus(), providers: [] })
|
||||
const load = moduleLoader({ "@@/apis/trading": { patchConfigApi: (body: unknown) => {
|
||||
bodies.push(body)
|
||||
return fail ? Promise.reject(new Error("保存失败")) : pending.promise
|
||||
} } }, globals)
|
||||
const emitted: unknown[] = []
|
||||
const page = load("@/pages/trading/components/TradingConfig.vue").default.setup(props, { expose() {}, emit: (...args: unknown[]) => emitted.push(args) })
|
||||
page.formRef.value = { validate: async () => true, clearValidate() {} }
|
||||
page.form.daily_analysis_limit = 200
|
||||
props.status = tradingStatus(150)
|
||||
await vue.nextTick()
|
||||
assert.equal(page.form.daily_analysis_limit, 200)
|
||||
const saving = page.save()
|
||||
await page.save()
|
||||
await setImmediate()
|
||||
assert.equal(bodies.length, 1)
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(bodies[0])), { daily_analysis_limit: 200 })
|
||||
pending.resolve(tradingStatus(200))
|
||||
await saving
|
||||
assert.equal(page.dirty.value, false)
|
||||
assert.equal(emitted.length, 1)
|
||||
fail = true
|
||||
page.form.provider_id = 2
|
||||
await assert.rejects(page.save(), /保存失败/)
|
||||
assert.equal(page.form.provider_id, 2)
|
||||
assert.equal(page.dirty.value, true)
|
||||
assert.equal(page.saving.value, false)
|
||||
page.reset()
|
||||
assert.equal(page.form.daily_analysis_limit, 150)
|
||||
})
|
||||
|
||||
test("模型表单按请求字段编辑;校验失败不请求,保存失败保留输入和弹窗", async () => {
|
||||
let calls = 0
|
||||
const load = moduleLoader({
|
||||
"@@/apis/settings": { patchProviderApi: async () => {
|
||||
calls++
|
||||
throw new Error("保存失败")
|
||||
} },
|
||||
"@@/composables/useSessionScope": { useSessionScope: () => ({ assertCurrent() {} }) }
|
||||
}, globals)
|
||||
const page = load("@/pages/settings/components/ProviderSettings.vue").default.setup({}, { expose() {} })
|
||||
page.openEdit({ id: 1, name: "test", protocol: "openai", base_url: "https://example.test", api_key_hint: "abcd", model: "model", max_tokens: 8192, context_window: 200000, reasoning_effort: null, is_default: false, updated_at: "now" })
|
||||
assert.equal(page.form.value.api_key, "")
|
||||
assert.equal("api_key_hint" in page.form.value, false)
|
||||
assert.equal("updated_at" in page.form.value, false)
|
||||
page.formRef.value = { validate: async () => {
|
||||
throw new Error("校验失败")
|
||||
} }
|
||||
await assert.rejects(page.save(), /校验失败/)
|
||||
assert.equal(calls, 0)
|
||||
page.formRef.value = { validate: async () => true }
|
||||
page.form.value.name = "new-name"
|
||||
await assert.rejects(page.save(), /保存失败/)
|
||||
assert.equal(page.dialogVisible.value, true)
|
||||
assert.equal(page.form.value.name, "new-name")
|
||||
assert.equal(page.saving.value, false)
|
||||
})
|
||||
|
||||
test("新闻搜索/页容量重置只有一次请求;失败区别于空数据且旧响应不覆盖新查询", async () => {
|
||||
const queries: { page: number }[] = []
|
||||
const pending: ReturnType<typeof Promise.withResolvers>[] = []
|
||||
const load = moduleLoader({
|
||||
"@@/apis/news": { listNewsApi: (query: { page: number }) => {
|
||||
queries.push(query)
|
||||
const item = Promise.withResolvers()
|
||||
pending.push(item)
|
||||
return item.promise
|
||||
} },
|
||||
"@@/composables/useSourceNames": { useSourceNames: () => vue.ref({}) },
|
||||
"@@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) },
|
||||
"@/http/useEventSource": { useEventSource: () => ({ connected: vue.ref(false) }) }
|
||||
}, globals)
|
||||
const page = load("@/pages/news/composables/useNewsFeed").useNewsFeed()
|
||||
const first = page.load()
|
||||
const next = page.load()
|
||||
pending[1].resolve({ items: [{ id: 2 }], total: 1 })
|
||||
await next
|
||||
pending[0].resolve({ items: [{ id: 1 }], total: 1 })
|
||||
await first
|
||||
assert.equal(page.items.value[0].id, 2)
|
||||
page.page.value = 3
|
||||
await vue.nextTick()
|
||||
pending[2].resolve({ items: [], total: 0 })
|
||||
await setImmediate()
|
||||
page.search()
|
||||
await vue.nextTick()
|
||||
assert.equal(queries.length, 4)
|
||||
assert.equal(queries[3].page, 1)
|
||||
pending[3].resolve({ items: [], total: 0 })
|
||||
await setImmediate()
|
||||
page.page.value = 3
|
||||
await vue.nextTick()
|
||||
pending[4].resolve({ items: [], total: 0 })
|
||||
await setImmediate()
|
||||
page.pageSize.value = 50
|
||||
await vue.nextTick()
|
||||
assert.equal(queries.length, 6)
|
||||
assert.equal(queries[5].page, 1)
|
||||
pending[5].resolve({ items: [], total: 0 })
|
||||
await setImmediate()
|
||||
const failing = page.load()
|
||||
pending[6].reject(new Error("读取失败"))
|
||||
await assert.rejects(failing, /读取失败/)
|
||||
assert.equal(page.failed.value, true)
|
||||
})
|
||||
|
||||
test("交易确认绑定发起币种;交易所或会话切换后拒绝旧操作", async () => {
|
||||
let confirm = Promise.withResolvers<void>()
|
||||
let active = true
|
||||
let exchange = "bybit"
|
||||
const writes: unknown[] = []
|
||||
const data = {
|
||||
status: vue.ref(tradingStatus()),
|
||||
symbols: vue.ref([]),
|
||||
selected: vue.ref("BTCUSDT"),
|
||||
events: vue.ref([]),
|
||||
eventsExhausted: vue.ref(true),
|
||||
latestAnalysis: vue.ref(null),
|
||||
livePlan: vue.ref(null),
|
||||
planOrders: vue.ref([]),
|
||||
current: vue.ref(null),
|
||||
loading: vue.ref(false),
|
||||
failed: vue.ref(false),
|
||||
refresh: async () => {},
|
||||
refreshEvents: async () => {},
|
||||
loadMoreEvents: async () => {}
|
||||
}
|
||||
const children = Object.fromEntries(["AddSymbolDialog", "AnalysisDrawer", "NewsSignalDrawer", "PlanCard", "PositionHistoryDrawer", "TradingConfig"].map(name => [`./components/${name}.vue`, {}]))
|
||||
const load = moduleLoader({
|
||||
...children,
|
||||
"./composables/useTradingData": { useTradingData: () => data },
|
||||
"@@/apis/settings": {},
|
||||
"@@/apis/trading": {
|
||||
getStatusApi: async () => ({ ...tradingStatus(), exchange }),
|
||||
manualActionApi: async (...args: unknown[]) => writes.push(args)
|
||||
},
|
||||
"@@/composables/useSessionScope": { useSessionScope: () => ({ assertCurrent() {
|
||||
if (!active) throw new Error("会话已切换")
|
||||
} }) }
|
||||
}, { ...globals, ElMessageBox: { confirm: () => confirm.promise } })
|
||||
const page = load("@/pages/trading/index.vue").default.setup({}, { expose() {} })
|
||||
const first = page.clearOrders()
|
||||
data.selected.value = "ETHUSDT"
|
||||
confirm.resolve()
|
||||
await first
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(writes)), [["BTCUSDT", "cancel"]])
|
||||
confirm = Promise.withResolvers<void>()
|
||||
const second = page.clearOrders()
|
||||
exchange = "binance"
|
||||
confirm.resolve()
|
||||
await assert.rejects(second, /交易所已切换/)
|
||||
assert.equal(writes.length, 1)
|
||||
exchange = "bybit"
|
||||
confirm = Promise.withResolvers<void>()
|
||||
const third = page.closePosition("BTCUSDT")
|
||||
active = false
|
||||
confirm.resolve()
|
||||
await assert.rejects(third, /会话已切换/)
|
||||
assert.equal(writes.length, 1)
|
||||
})
|
||||
Reference in new issue
Block a user