From c48b5560778548e2e87e05b1df5b170d7db1a345 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 06:20:25 +0800 Subject: Add stock sell vertical slice with cash recovery and snapshot pricing (3d-26) --- qa/driver/browser-test.js | 103 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) (limited to 'qa/driver') diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index b271fbb..fda7c6f 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -39,6 +39,8 @@ let lastStockQuoteResponse = null; let lastStockDailyResponse = null; let lastStockTradeResponse = null; let lastStockPositionsResponse = null; +let lastStockSellResponse = null; +const stockSellStatuses = []; let lastBondTradeResponse = null; let lastBondPositionsResponse = null; let lastValuationResponse = null; @@ -214,6 +216,14 @@ async function summaryLabelExists(page, label, name) { lastStockTradeResponse = await r.json(); } catch {} } + if (/\/stock-sells$/i.test(new URL(r.url()).pathname) && r.request().method() === "POST") { + stockSellStatuses.push(r.status()); + if (r.status() < 400) { + try { + lastStockSellResponse = await r.json(); + } catch {} + } + } if (/\/stock-positions$/i.test(new URL(r.url()).pathname) && r.request().method() === "GET" && r.status() < 400) { try { lastStockPositionsResponse = await r.json(); @@ -249,7 +259,7 @@ async function summaryLabelExists(page, label, name) { lastSipAdvanceResponse = await r.json(); } catch {} } - if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url()) && !/\/stock-trades/.test(r.url()) && !/\/stock-positions/.test(r.url()) && !/\/bond-trades/.test(r.url()) && !/\/bond-positions/.test(r.url()) && !/\/valuation/.test(r.url()) && !/\/rebalance\/plans\//.test(r.url())) { + if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url()) && !/\/stock-trades/.test(r.url()) && !/\/stock-sells/.test(r.url()) && !/\/stock-positions/.test(r.url()) && !/\/bond-trades/.test(r.url()) && !/\/bond-positions/.test(r.url()) && !/\/valuation/.test(r.url()) && !/\/rebalance\/plans\//.test(r.url())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } }); @@ -309,6 +319,7 @@ async function summaryLabelExists(page, label, name) { await marketRefreshScenario(page); await sipStatusScenario(page); await rebalanceArchiveScenario(page); + await stockSellScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); @@ -1597,6 +1608,96 @@ async function rebalanceArchiveScenario(page) { await page.screenshot({ path: SHOTS + "/28-rebalance-archive.png" }); } +async function stockSellScenario(page) { + // F5 (3d-26): 卖出部分股票 -> 持仓减少、现金回收、估值按剩余持仓快照价刷新。 + await page.waitForSelector(".stock-position-row", { timeout: 10000 }); + + const beforeText = ((await page.textContent(".stock-position-row")) || "").trim(); + check( + "F5a 持仓行显示卖出数量输入与卖出按钮", + (await page.locator(".stock-sell-quantity-input").count()) >= 1 && (await page.locator(".stock-sell-action").count()) >= 1, + beforeText.slice(0, 160) + ); + + lastStockSellResponse = null; + lastValuationResponse = null; + + await page.fill(".stock-sell-quantity-input", "40"); + await page.click(".stock-sell-action"); + + for (let i = 0; i < 200 && lastStockSellResponse === null; i++) { + await page.waitForTimeout(100); + } + + const sell = lastStockSellResponse || {}; + check( + "F5b 卖出 40 股以快照价成交并回收现金", + sell.instrumentCode === "600519" && + sell.quantity === "40.00000000" && + sell.price === "1252.57000000" && + sell.feeAmount === "0.00" && + sell.proceeds === "50102.80", + JSON.stringify(sell).slice(0, 260) + ); + + await page.waitForFunction( + () => { + const rows = Array.from(document.querySelectorAll(".stock-position-row")); + return rows.some((row) => (row.textContent || "").includes("60.00000000")); + }, + null, + { timeout: 10000 } + ); + check("F5c 卖出后持仓数量减少为 60", true); + + for (let i = 0; i < 200 && lastValuationResponse === null; i++) { + await page.waitForTimeout(100); + } + + const valuation = lastValuationResponse || {}; + const positions = Array.isArray(valuation.positions) ? valuation.positions : []; + const stock = positions.find((item) => item.instrumentCode === "600519") || {}; + const cash = parseFloat(valuation.cash || "0"); + const marketValue = parseFloat(valuation.positionsMarketValue || "0"); + + check( + "F5d 卖出后估值按剩余持仓与快照价重算", + stock.quantity === "60.00000000" && + stock.marketValue === "75154.20" && + stock.priceSource === "snapshot" && + valuation.portfolioValue && + Math.abs(parseFloat(valuation.portfolioValue) - (cash + marketValue)) < 0.005, + `valuation=${JSON.stringify(valuation).slice(0, 280)}` + ); + + const messageText = ((await page.textContent(".stock-sell-message")) || "").trim(); + check("F5e 页面显示卖出结果摘要", messageText.includes("已卖出") && messageText.includes("50102.80"), messageText.slice(0, 160)); + + // 超卖被拒绝,持仓不变。 + await page.fill(".stock-sell-quantity-input", "1000"); + await page.click(".stock-sell-action"); + await page.waitForFunction( + () => { + const element = document.querySelector(".stock-sell-message"); + return element && element.textContent.includes("卖出失败") && element.textContent.includes("not enough"); + }, + null, + { timeout: 10000 } + ); + + const oversellStatus = stockSellStatuses[stockSellStatuses.length - 1]; + check("F5f 超卖被 400 拒绝并提示原因", oversellStatus === 400, `status=${oversellStatus}`); + + const afterOversell = ((await page.textContent(".stock-positions")) || ""); + check( + "F5g 超卖后持仓仍为 60", + afterOversell.includes("60.00000000") && !afterOversell.includes("1000.00000000"), + afterOversell.slice(0, 160) + ); + + await page.screenshot({ path: SHOTS + "/30-stock-sell.png" }); +} + const failed = results.filter((r) => !r.ok); console.log(`\n==== ${results.length - failed.length}/${results.length} passed ====`); process.exit(failed.length > 0 ? 1 : 0); -- cgit v1.2.3