Fix workspace detail races and provider default handling

Keep analysis and Skill detail responses bound to the selected request, reject stale publish confirmations, and clear failed position-history results with retry controls.

Reject removal of the current default provider with HTTP 409 and reflect that contract in the form. Track the OpenAPI page and API module by restricting the docs ignore rule to the repository root.

Validate the staged provider contract with four isolated tests. Reuse the matching candidate's frontend lint, type check, 26 test groups, and production build results.
This commit is contained in:
eezd committed 2026-10-26 14:22:40 +08:00
1 parent 3178a2cc31
commit d1a6325ed7
12 files changed
+405 -23

No files matched your search

+1 -1
View File
@@ -3,4 +3,4 @@ CONTEXT.md
backups/ backups/
.venv/ .venv/
__pycache__/ __pycache__/
docs/ /docs/
+3
View File
@@ -67,6 +67,7 @@ class ProviderPatch(BaseModel):
context_window: Annotated[int | None, Field(ge=4096, le=2000000)] = None context_window: Annotated[int | None, Field(ge=4096, le=2000000)] = None
# 显式传 null 表示不传思考强度 # 显式传 null 表示不传思考强度
reasoning_effort: llm.Effort | None = None reasoning_effort: llm.Effort | None = None
# 只能将另一模型设为默认,不能直接取消当前默认模型。
is_default: bool | None = None is_default: bool | None = None
@@ -120,6 +121,8 @@ async def create_provider(body: ProviderIn, session: Annotated[AsyncSession, Dep
@router.patch("/providers/{provider_id}") @router.patch("/providers/{provider_id}")
async def patch_provider(provider_id: int, body: ProviderPatch, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[ProviderOut]: async def patch_provider(provider_id: int, body: ProviderPatch, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[ProviderOut]:
provider = await get_provider(session, provider_id) provider = await get_provider(session, provider_id)
if body.is_default is False and provider.is_default:
raise HTTPException(status.HTTP_409_CONFLICT, "不能直接取消默认模型,请将其他模型设为默认")
values = body.model_dump(exclude_unset=True, exclude={"api_key", "base_url", "is_default"}) values = body.model_dump(exclude_unset=True, exclude={"api_key", "base_url", "is_default"})
# reasoning_effort 显式传 null 表示清除,其他字段为 null 时忽略 # reasoning_effort 显式传 null 表示清除,其他字段为 null 时忽略
for field, value in values.items(): for field, value in values.items():
+53
View File
@@ -0,0 +1,53 @@
"""模型默认配置的接口契约;数据库和认证均隔离。"""
import asyncio
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
@pytest.mark.parametrize("initial,requested,status", [
(True, False, 409), (False, False, 200), (False, True, 200), (True, True, 200),
])
def test_patch_provider_default_contract(trading, monkeypatch, initial, requested, status):
from alphapulse.api import settings
from alphapulse.api.main import app
from alphapulse.core.auth import require_admin
from alphapulse.core.db import get_session
provider = SimpleNamespace(
id=1, name="test", protocol="openai", base_url="https://example.test",
api_key_hint="test", model="test", max_tokens=8192, context_window=200000,
reasoning_effort=None, is_default=initial, updated_at=datetime.now(UTC),
)
session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock(), execute=AsyncMock())
monkeypatch.setattr(settings, "get_provider", AsyncMock(return_value=provider))
async def isolated_session():
yield session
app.dependency_overrides[require_admin] = lambda: None
app.dependency_overrides[get_session] = isolated_session
async def check():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://test") as client:
response = await client.patch("/api/settings/providers/1", json={"is_default": requested})
assert response.status_code == status
body = response.json()
if status == 409:
assert body == {"code": 409, "data": None, "message": "不能直接取消默认模型,请将其他模型设为默认"}
assert provider.is_default is initial
session.commit.assert_not_awaited()
session.execute.assert_not_awaited()
else:
assert body["code"] == 0 and body["data"]["is_default"] is requested
session.commit.assert_awaited_once()
assert session.execute.await_count == int(requested)
try:
asyncio.run(check())
finally:
app.dependency_overrides.clear()
+13
View File
@@ -0,0 +1,13 @@
import type * as Docs from "./type"
import { request } from "@/http/axios"
/** OpenAPI 按原始规范返回,不使用普通 JSON 外壳。 */
export function getOpenApiApi(signal?: AbortSignal) {
return request<Docs.OpenApiDocument>({
url: "/openapi.json",
method: "get",
rawResponse: true,
notify: false,
signal
})
}
+6
View File
@@ -0,0 +1,6 @@
export interface OpenApiDocument {
openapi: string
info: { title: string, version: string }
paths: Record<string, unknown>
components?: Record<string, unknown>
}
+1
View File
@@ -30,6 +30,7 @@ export interface ProviderForm {
context_window: number context_window: number
/** 为空表示不传,由模型决定 */ /** 为空表示不传,由模型决定 */
reasoning_effort: Effort | null reasoning_effort: Effort | null
/** 修改时不能直接取消当前默认模型;通过将其他模型设为默认切换。 */
is_default: boolean is_default: boolean
} }
@@ -3,6 +3,7 @@ import type { SkillNode, SkillVersion } from "@@/apis/skills/type"
import { listSkillVersionsApi, publishSkillVersionApi } from "@@/apis/skills" import { listSkillVersionsApi, publishSkillVersionApi } from "@@/apis/skills"
import { useSessionScope } from "@@/composables/useSessionScope" import { useSessionScope } from "@@/composables/useSessionScope"
import { diffLines, formatTime } from "@@/utils/text" import { diffLines, formatTime } from "@@/utils/text"
import { CanceledError } from "axios"
const props = defineProps<{ skill: SkillNode | null }>() const props = defineProps<{ skill: SkillNode | null }>()
@@ -16,32 +17,53 @@ const visible = defineModel<boolean>({ required: true })
const versions = ref<SkillVersion[]>([]) const versions = ref<SkillVersion[]>([])
const selectedId = ref<number | null>(null) const selectedId = ref<number | null>(null)
const publishing = ref(false) const publishing = ref(false)
const loading = ref(false)
const failed = ref(false)
let requestSeq = 0
const published = computed(() => versions.value.find(v => v.version_no === props.skill?.published_version_no) ?? null) const published = computed(() => versions.value.find(v => v.version_no === props.skill?.published_version_no) ?? null)
const selected = computed(() => versions.value.find(v => v.id === selectedId.value) ?? null) const selected = computed(() => versions.value.find(v => v.id === selectedId.value) ?? null)
const diff = computed(() => selected.value ? diffLines((published.value?.content ?? "").split("\n"), selected.value.content.split("\n")) : []) const diff = computed(() => selected.value ? diffLines((published.value?.content ?? "").split("\n"), selected.value.content.split("\n")) : [])
async function load() { async function load() {
if (!props.skill) const seq = ++requestSeq
return const id = props.skill?.id
versions.value = await listSkillVersionsApi(props.skill.id) versions.value = []
// 默认选中最新版本,直接看到草稿相对已发布版本的改动 selectedId.value = null
selectedId.value = versions.value[0]?.id ?? null failed.value = false
loading.value = false
if (!visible.value || id === undefined) return
loading.value = true
try {
const result = await listSkillVersionsApi(id)
if (seq !== requestSeq || !visible.value || id !== props.skill?.id) return
versions.value = result
// 默认选中最新版本,直接看到草稿相对已发布版本的改动
selectedId.value = result[0]?.id ?? null
} catch (error) {
if (seq === requestSeq) failed.value = true
throw error
} finally {
if (seq === requestSeq) loading.value = false
}
} }
watch([visible, () => props.skill?.id, () => props.skill?.latest_version_no], ([open]) => { watch([visible, () => props.skill?.id, () => props.skill?.latest_version_no], load)
if (open) onBeforeUnmount(() => ++requestSeq)
load()
})
async function publish(version: SkillVersion) { async function publish(version: SkillVersion) {
const skillId = props.skill!.id
const seq = requestSeq
const current = props.skill?.published_version_no const current = props.skill?.published_version_no
const action = current !== null && current !== undefined && version.version_no < current ? "回退到" : "发布" const action = current !== null && current !== undefined && version.version_no < current ? "回退到" : "发布"
await ElMessageBox.confirm(`${action} v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" }) await ElMessageBox.confirm(`${action} v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" })
sessionScope.assertCurrent() sessionScope.assertCurrent()
if (seq !== requestSeq || !visible.value || skillId !== props.skill?.id)
throw new CanceledError("查看的 Skill 已切换")
publishing.value = true publishing.value = true
try { try {
await publishSkillVersionApi(props.skill!.id, version.id) await publishSkillVersionApi(skillId, version.id)
if (seq !== requestSeq) return
ElMessage.success(`已${action} v${version.version_no}`) ElMessage.success(`已${action} v${version.version_no}`)
emit("published") emit("published")
} finally { } finally {
@@ -52,7 +74,12 @@ async function publish(version: SkillVersion) {
<template> <template>
<el-drawer v-model="visible" :title="`版本历史:${skill?.path ?? ''}`" size="60%"> <el-drawer v-model="visible" :title="`版本历史:${skill?.path ?? ''}`" size="60%">
<div class="h-full flex gap-3"> <el-empty v-if="failed" description="版本历史读取失败">
<el-button type="primary" @click="load">
重试
</el-button>
</el-empty>
<div v-else v-loading="loading" class="h-full flex gap-3">
<el-scrollbar class="w-60 shrink-0"> <el-scrollbar class="w-60 shrink-0">
<div <div
v-for="v in versions" :key="v.id" v-for="v in versions" :key="v.id"
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { getOpenApiApi } from "@@/apis/docs"
import { rejectAuthentication } from "@/http/axios"
import { useUserStore } from "@/pinia/stores/user"
const target = ref<HTMLElement>()
const error = ref("")
const user = useUserStore()
const version = user.sessionVersion
const controller = new AbortController()
let disposed = false
interface SwaggerWindow extends Window {
SwaggerUIBundle?: (options: Record<string, unknown>) => unknown
}
onMounted(async () => {
try {
const spec = await getOpenApiApi(controller.signal)
const source = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"
if (!(window as SwaggerWindow).SwaggerUIBundle) {
await new Promise<void>((resolve, reject) => {
const script = document.createElement("script")
script.src = source
script.onload = () => resolve()
script.onerror = () => reject(new Error("接口文档资源加载失败"))
document.head.append(script)
})
}
if (version !== user.sessionVersion || disposed) {
return
}
const render = (window as SwaggerWindow).SwaggerUIBundle!
render({
spec,
domNode: target.value,
requestInterceptor: (request: { headers: Record<string, string> }) => {
if (version !== user.sessionVersion) throw new Error("会话已切换")
request.headers.Authorization = `Bearer ${user.token}`
return request
},
responseInterceptor: (response: { status: number }) => {
if (response.status === 401) rejectAuthentication(version)
return response
}
})
} catch (reason) {
if (!disposed && version === user.sessionVersion)
error.value = (reason as Error).message
}
})
onBeforeUnmount(() => {
disposed = true
controller.abort()
})
</script>
<template>
<div class="h-full overflow-auto bg-white p-4">
<p v-if="error" role="alert">
{{ error }}
</p>
<div ref="target" />
</div>
</template>
<style>
@import url("https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css");
</style>
@@ -14,6 +14,7 @@ const PROTOCOL_LABELS = { openai: "OpenAI 格式(/v1/chat/completions)", ant
const providers = ref<Provider[]>([]) const providers = ref<Provider[]>([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingId = ref<number | null>(null) const editingId = ref<number | null>(null)
const editingDefault = ref(false)
const saving = ref(false) const saving = ref(false)
const testing = ref<number | null>(null) const testing = ref<number | null>(null)
@@ -58,6 +59,7 @@ async function load() {
function openCreate() { function openCreate() {
editingId.value = null editingId.value = null
editingDefault.value = false
form.value = emptyForm() form.value = emptyForm()
formRef.value?.clearValidate() formRef.value?.clearValidate()
dialogVisible.value = true dialogVisible.value = true
@@ -65,6 +67,7 @@ function openCreate() {
function openEdit(p: Provider) { function openEdit(p: Provider) {
editingId.value = p.id editingId.value = p.id
editingDefault.value = p.is_default
// key 不回显,留空表示不修改 // key 不回显,留空表示不修改
form.value = { form.value = {
name: p.name, name: p.name,
@@ -240,7 +243,8 @@ onMounted(load)
<span class="ml-2 text-xs">Chat 默认值,可在 Chat 页按轮调整;留空则不传,由模型决定。越高越慢、越贵</span> <span class="ml-2 text-xs">Chat 默认值,可在 Chat 页按轮调整;留空则不传,由模型决定。越高越慢、越贵</span>
</el-form-item> </el-form-item>
<el-form-item label="设为默认"> <el-form-item label="设为默认">
<el-switch v-model="form.is_default" /> <el-switch v-model="form.is_default" :disabled="editingDefault" />
<span v-if="editingDefault" class="ml-2 text-xs">请将其他模型设为默认以切换</span>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -8,12 +8,32 @@ const props = defineProps<{ analysisId: number | null }>()
const visible = defineModel<boolean>({ required: true }) const visible = defineModel<boolean>({ required: true })
const detail = ref<AnalysisDetail>() const detail = ref<AnalysisDetail>()
const loading = ref(false)
const failed = ref(false)
let requestSeq = 0
watch(() => [visible.value, props.analysisId] as const, async ([open, id]) => { async function load() {
const seq = ++requestSeq
const id = props.analysisId
detail.value = undefined detail.value = undefined
if (open && id) failed.value = false
detail.value = await getAnalysisApi(id) loading.value = false
}) if (!visible.value || id === null) return
loading.value = true
try {
const result = await getAnalysisApi(id)
if (seq === requestSeq && visible.value && id === props.analysisId)
detail.value = result
} catch (error) {
if (seq === requestSeq) failed.value = true
throw error
} finally {
if (seq === requestSeq) loading.value = false
}
}
watch(() => [visible.value, props.analysisId] as const, load)
onBeforeUnmount(() => ++requestSeq)
const toolResults = computed(() => { const toolResults = computed(() => {
const results = new Map<string, string>() const results = new Map<string, string>()
@@ -165,7 +185,12 @@ function promptMarkdown(text: string) {
</el-collapse> </el-collapse>
</el-card> </el-card>
</div> </div>
<div v-else v-loading="true" class="h-40" /> <el-empty v-else-if="failed" description="分析详情读取失败">
<el-button type="primary" @click="load">
重试
</el-button>
</el-empty>
<div v-else v-loading="loading" class="h-40" />
</el-drawer> </el-drawer>
</template> </template>
@@ -25,12 +25,15 @@ const page = ref(1)
const result = ref<PositionHistoryPage>() const result = ref<PositionHistoryPage>()
const symbols = ref<string[]>([]) const symbols = ref<string[]>([])
const loading = ref(false) const loading = ref(false)
const failed = ref(false)
// 快速切换筛选时只采用最后一次请求的结果 // 快速切换筛选时只采用最后一次请求的结果
let requestSeq = 0 let requestSeq = 0
async function load() { async function load() {
const seq = ++requestSeq const seq = ++requestSeq
loading.value = true loading.value = true
failed.value = false
result.value = undefined
try { try {
const data = await listPositionHistoryApi({ const data = await listPositionHistoryApi({
symbol: filters.symbol, symbol: filters.symbol,
@@ -44,6 +47,9 @@ async function load() {
}) })
if (seq === requestSeq) if (seq === requestSeq)
result.value = data result.value = data
} catch (error) {
if (seq === requestSeq) failed.value = true
throw error
} finally { } finally {
if (seq === requestSeq) if (seq === requestSeq)
loading.value = false loading.value = false
@@ -52,15 +58,18 @@ async function load() {
function search() { function search() {
page.value = 1 page.value = 1
load() return load()
} }
watch(visible, async (open) => { watch(visible, async (open) => {
if (!open) if (!open) {
++requestSeq
loading.value = false
return return
search() }
symbols.value = await listPositionHistorySymbolsApi() await Promise.all([search(), listPositionHistorySymbolsApi().then(list => symbols.value = list)])
}) })
onBeforeUnmount(() => ++requestSeq)
const winRate = computed(() => { const winRate = computed(() => {
const s = result.value?.summary const s = result.value?.summary
@@ -121,6 +130,12 @@ function pnlClass(value: string) {
</el-radio-group> </el-radio-group>
</div> </div>
<el-alert v-if="failed" type="error" :closable="false" title="历史仓位查询失败">
<el-button link @click="load">
重试
</el-button>
</el-alert>
<div v-if="result" class="flex flex-wrap gap-x-8 gap-y-1 border border-slate-200 rounded-lg border-solid bg-slate-50 px-4 py-3 text-xs text-slate-500"> <div v-if="result" class="flex flex-wrap gap-x-8 gap-y-1 border border-slate-200 rounded-lg border-solid bg-slate-50 px-4 py-3 text-xs text-slate-500">
<span>笔数 <b class="num ml-1 text-sm text-slate-900">{{ result.summary.count }}</b></span> <span>笔数 <b class="num ml-1 text-sm text-slate-900">{{ result.summary.count }}</b></span>
<span>盈利 <b class="num ml-1 text-sm text-slate-900">{{ result.summary.wins }}</b></span> <span>盈利 <b class="num ml-1 text-sm text-slate-900">{{ result.summary.wins }}</b></span>
@@ -129,7 +144,7 @@ function pnlClass(value: string) {
<span>手续费 <b class="num ml-1 text-sm text-slate-900">{{ Number(result.summary.commission).toFixed(2) }} USDT</b></span> <span>手续费 <b class="num ml-1 text-sm text-slate-900">{{ Number(result.summary.commission).toFixed(2) }} USDT</b></span>
</div> </div>
<el-table v-loading="loading" :data="result?.items ?? []" cell-class-name="num" empty-text="没有符合条件的仓位"> <el-table v-loading="loading" :data="result?.items ?? []" cell-class-name="num" :empty-text="failed ? '查询失败' : '没有符合条件的仓位'">
<el-table-column label="币种" min-width="84"> <el-table-column label="币种" min-width="84">
<template #default="{ row }"> <template #default="{ row }">
<span class="text-slate-900 font-semibold">{{ row.symbol }}</span> <span class="text-slate-900 font-semibold">{{ row.symbol }}</span>
+166
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict" import assert from "node:assert/strict"
import test from "node:test" import test from "node:test"
import { setImmediate } from "node:timers/promises" import { setImmediate } from "node:timers/promises"
import axios from "axios"
import * as vue from "vue" import * as vue from "vue"
import { moduleLoader } from "./module-loader.ts" import { moduleLoader } from "./module-loader.ts"
@@ -8,6 +9,171 @@ const globals = { ...vue, onMounted() {}, onBeforeUnmount() {}, onBeforeRouteLea
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 } }) 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: ReturnType<typeof Promise.withResolvers>[] = []
let unmount = () => {}
const props = vue.reactive({ analysisId: 1 })
const load = moduleLoader({
"vue": { ...vue, useModel: () => vue.ref(false) },
"@@/apis/trading": { getAnalysisApi: () => {
const request = Promise.withResolvers()
pending.push(request)
return request.promise
} }
}, { ...globals, onBeforeUnmount: (callback: () => void) => unmount = callback })
const page = load("@/pages/trading/components/AnalysisDrawer.vue").default.setup(props, { expose() {} })
page.visible.value = true
await vue.nextTick()
props.analysisId = 2
await vue.nextTick()
pending[1].resolve({ id: 2 })
await setImmediate()
pending[0].resolve({ id: 1 })
await setImmediate()
assert.equal(page.detail.value.id, 2)
const old = page.load()
const rejected = assert.rejects(old, /旧请求失败/)
const current = page.load()
pending[3].resolve({ id: 2 })
await current
pending[2].reject(new Error("旧请求失败"))
await rejected
assert.equal(page.failed.value, false)
assert.equal(page.detail.value.id, 2)
const closed = page.load()
page.visible.value = false
await vue.nextTick()
pending[4].resolve({ id: 2 })
await closed
assert.equal(page.detail.value, undefined)
assert.equal(page.loading.value, false)
page.visible.value = true
await vue.nextTick()
unmount()
pending[5].resolve({ id: 2 })
await setImmediate()
assert.equal(page.detail.value, undefined)
})
test("分析详情失败停止加载,明确失败并允许重试", async () => {
let fail = true
const load = moduleLoader({
"vue": { ...vue, useModel: () => vue.ref(true) },
"@@/apis/trading": { getAnalysisApi: async () => {
if (fail) throw new Error("读取失败")
return { id: 1 }
} }
}, globals)
const page = load("@/pages/trading/components/AnalysisDrawer.vue").default.setup({ analysisId: 1 }, { expose() {} })
await assert.rejects(page.load(), /读取失败/)
assert.equal(page.loading.value, false)
assert.equal(page.failed.value, true)
fail = false
await page.load()
assert.equal(page.failed.value, false)
assert.equal(page.detail.value.id, 1)
})
test("Skill 版本绑定当前对象;确认期间切换后不发布旧版本", async () => {
const pending: ReturnType<typeof Promise.withResolvers>[] = []
const confirmation = Promise.withResolvers<void>()
const writes: unknown[] = []
const props = vue.reactive({ skill: { id: 1, published_version_no: null, latest_version_no: 1 } })
const load = moduleLoader({
"vue": { ...vue, useModel: () => vue.ref(false) },
"@@/apis/skills": {
listSkillVersionsApi: () => {
const request = Promise.withResolvers()
pending.push(request)
return request.promise
},
publishSkillVersionApi: async (...args: unknown[]) => writes.push(args)
},
"@@/utils/text": { diffLines: () => [], formatTime: (value: string) => value },
"@@/composables/useSessionScope": { useSessionScope: () => ({ assertCurrent() {} }) }
}, { ...globals, ElMessageBox: { confirm: () => confirmation.promise } })
const page = load("@@/components/SkillVersionDrawer/index.vue").default.setup(props, { expose() {}, emit() {} })
page.visible.value = true
await vue.nextTick()
props.skill = { id: 2, published_version_no: null, latest_version_no: 1 }
await vue.nextTick()
const version = { id: 22, version_no: 1, content: "B" }
pending[1].resolve([version])
await setImmediate()
pending[0].resolve([{ id: 11, version_no: 1, content: "A" }])
await setImmediate()
assert.equal(page.versions.value[0].id, 22)
const publishing = page.publish(version)
const rejected = assert.rejects(publishing, error => axios.isCancel(error))
props.skill = { id: 3, published_version_no: null, latest_version_no: 1 }
await vue.nextTick()
assert.equal(page.versions.value.length, 0)
assert.equal(page.selectedId.value, null)
confirmation.resolve()
await rejected
assert.equal(writes.length, 0)
page.visible.value = false
await vue.nextTick()
pending[2].resolve([{ id: 33, version_no: 1, content: "C" }])
await setImmediate()
assert.equal(page.versions.value.length, 0)
page.visible.value = true
await vue.nextTick()
pending[3].resolve([{ id: 33, version_no: 1, content: "C" }])
await setImmediate()
const failing = page.load()
pending[4].reject(new Error("版本读取失败"))
await assert.rejects(failing, /版本读取失败/)
assert.equal(page.failed.value, true)
assert.equal(page.loading.value, false)
assert.equal(page.versions.value.length, 0)
const retry = page.load()
pending[5].resolve([{ id: 33, version_no: 1, content: "C" }])
await retry
assert.equal(page.failed.value, false)
await page.publish(page.versions.value[0])
assert.deepEqual(writes, [[3, 33]])
})
test("历史仓位失败不展示旧筛选结果;旧失败不影响新查询,重试可恢复", async () => {
const pending: ReturnType<typeof Promise.withResolvers>[] = []
const load = moduleLoader({
"vue": { ...vue, useModel: () => vue.ref(false) },
"@@/apis/trading": { listPositionHistoryApi: () => {
const request = Promise.withResolvers()
pending.push(request)
return request.promise
} }
}, globals)
const page = load("@/pages/trading/components/PositionHistoryDrawer.vue").default.setup({}, { expose() {} })
const first = page.load()
pending[0].resolve({ items: [{ symbol: "BTCUSDT" }], total: 1 })
await first
page.filters.symbol = "ETHUSDT"
const failing = page.load()
assert.equal(page.result.value, undefined)
pending[1].reject(new Error("读取失败"))
await assert.rejects(failing, /读取失败/)
assert.equal(page.failed.value, true)
assert.equal(page.loading.value, false)
assert.equal(page.result.value, undefined)
const old = page.load()
const rejected = assert.rejects(old, /旧请求失败/)
const current = page.load()
pending[3].resolve({ items: [{ symbol: "ETHUSDT" }], total: 1 })
await current
pending[2].reject(new Error("旧请求失败"))
await rejected
assert.equal(page.failed.value, false)
assert.equal(page.result.value.items[0].symbol, "ETHUSDT")
})
test("交易配置草稿抵抗轮询、仅保存改动字段;失败保留草稿并拒绝重复提交", async () => { test("交易配置草稿抵抗轮询、仅保存改动字段;失败保留草稿并拒绝重复提交", async () => {
const pending = Promise.withResolvers<unknown>() const pending = Promise.withResolvers<unknown>()
const bodies: unknown[] = [] const bodies: unknown[] = []