| 1 | import assert from "node:assert/strict"; |
| 2 | import { readFileSync } from "node:fs"; |
| 3 | import test from "node:test"; |
| 4 | |
| 5 | const workspace = readFileSync(new URL("../app/components/investment-workspace.tsx", import.meta.url), "utf8"); |
| 6 | const api = readFileSync(new URL("../app/lib/portfolio-api.ts", import.meta.url), "utf8"); |
| 7 | const styles = readFileSync(new URL("../app/styles.css", import.meta.url), "utf8"); |
| 8 | |
| 9 | const openStart = workspace.indexOf("async function openResearchReadiness"); |
| 10 | const openEnd = workspace.indexOf("async function findResearchData", openStart); |
| 11 | const readPath = workspace.slice(openStart, openEnd); |
| 12 | const ensureStart = openEnd; |
| 13 | const ensureEnd = workspace.indexOf("function openMarketIntelligenceResearch", ensureStart); |
| 14 | const ensurePath = workspace.slice(ensureStart, ensureEnd); |
| 15 | const dialogStart = workspace.indexOf("function ResearchReadinessDialog"); |
| 16 | const dialogEnd = workspace.indexOf("function AuthPasswordInput", dialogStart); |
| 17 | const dialog = workspace.slice(dialogStart, dialogEnd); |
| 18 | |
| 19 | test("Research opens the provider-free readiness popup by global instrument identity", () => { |
| 20 | assert.match(workspace, /Research readiness\s*<\/Button>/); |
| 21 | assert.match(readPath, /setResearchReadinessDialog\(\{ globalInstrumentId, companyName \}\)/); |
| 22 | assert.match(readPath, /researchApi\.getReadiness\(globalInstrumentId\)/); |
| 23 | assert.doesNotMatch(readPath, /ensureReadiness|\.refresh\(|prefetchVisible|ensureDefaultWatchlist|addWatchlistInstrument/); |
| 24 | }); |
| 25 | |
| 26 | test("readiness API separates read-only GET from targeted POST", () => { |
| 27 | assert.match(api, /getReadiness: \(globalInstrumentId: string\) =>\s*request<ResearchReadiness>\(`\/api\/v1\/research\/readiness\/\$\{globalInstrumentId\}`\)/); |
| 28 | assert.match(api, /ensureReadiness: \(globalInstrumentId: string, requirements\?: string\[\]\) =>[\s\S]*?method: "POST"[\s\S]*?JSON\.stringify\(requirements\?\.length \? \{ requirements \} : \{\}\)/); |
| 29 | }); |
| 30 | |
| 31 | test("Find Data sends only selected requirement IDs", () => { |
| 32 | assert.match(ensurePath, /researchApi\.ensureReadiness\(dialog\.globalInstrumentId, requirements\)/); |
| 33 | assert.match(dialog, /onFindData\(\[requirement\.requirementId\]\)/); |
| 34 | assert.match(dialog, /findable\.map\(\(item\) => item\.requirementId\)/); |
| 35 | assert.doesNotMatch(ensurePath, /NSE|SEC|EODHD|Yahoo|provider/); |
| 36 | }); |
| 37 | |
| 38 | test("readiness statuses map to the required visual tones", () => { |
| 39 | assert.match(dialog, /status === "READY_FRESH"\) return "ready"/); |
| 40 | assert.match(dialog, /\["READY_STALE", "PARTIAL", "REFRESHING"\]\.includes\(status\).*return "warning"/s); |
| 41 | assert.match(dialog, /status === "UNSUPPORTED" \|\| status === "NOT_APPLICABLE"\) return "neutral"/); |
| 42 | assert.match(dialog, /return "danger"/); |
| 43 | for (const tone of ["ready", "warning", "danger", "neutral"]) { |
| 44 | assert.match(styles, new RegExp(`\\.readiness-status-${tone}\\b`)); |
| 45 | } |
| 46 | }); |
| 47 | |
| 48 | test("popup shows provenance, freshness, reasons, upload placeholder, and analysis actions", () => { |
| 49 | assert.match(dialog, /requirement\.area\.replaceAll/); |
| 50 | assert.match(dialog, /requirement\.status\.replaceAll/); |
| 51 | assert.match(dialog, /As of/); |
| 52 | assert.match(dialog, /Source: \{requirement\.sourceProvider/); |
| 53 | assert.match(dialog, /href=\{requirement\.sourceUrl\} target="_blank" rel="noreferrer"/); |
| 54 | assert.match(dialog, /requirement\.missingReason/); |
| 55 | assert.match(dialog, /requirement\.conflictReason/); |
| 56 | assert.match(dialog, /"Finding…" : "Find Data"/); |
| 57 | assert.match(dialog, />Upload Evidence<\/Button>/); |
| 58 | assert.match(dialog, /analysisEligibility\?\.fullAnalysisAllowed/); |
| 59 | assert.match(dialog, /analysisEligibility\?\.partialAnalysisAllowed/); |
| 60 | assert.match(dialog, /"Run Partial Analysis"/); |
| 61 | }); |
| 62 | |
| 63 | test("quarterly projection opens a generic authoritative filing link", () => { |
| 64 | assert.match(workspace, /Source: \{filingSourceLabel\(result\.sourceName\)\}/); |
| 65 | assert.match(workspace, /href=\{result\.sourceUrl\} target="_blank" rel="noreferrer">View \{filingSourceLabel\(result\.sourceName\)\} Filing ↗<\/a>/); |
| 66 | assert.match(workspace, /function filingSourceLabel\(sourceName: string\)/); |
| 67 | assert.doesNotMatch(workspace.slice(workspace.indexOf("function filingSourceLabel"), workspace.indexOf("function AuthPasswordInput")), /NSE|SEC|EODHD/); |
| 68 | }); |
| 69 | |
| 70 | test("readiness popup reports completeness and data confidence without a stock score", () => { |
| 71 | assert.match(dialog, /readiness\.overallCompletenessPct/); |
| 72 | assert.match(dialog, /readiness\.criticalCompletenessPct/); |
| 73 | assert.match(dialog, /readiness\.confidence/); |
| 74 | assert.doesNotMatch(dialog, /stock score|investment score|final score/i); |
| 75 | }); |
| 76 | |
| 77 | test("readiness dialog is portaled, focus-contained, and locks background scrolling", () => { |
| 78 | assert.match(workspace, /import \{ createPortal \} from "react-dom"/); |
| 79 | assert.match(dialog, /return createPortal\([\s\S]*?document\.body/); |
| 80 | assert.match(dialog, /document\.body\.style\.overflow = "hidden"/); |
| 81 | assert.match(dialog, /appShell\.inert = true/); |
| 82 | assert.match(dialog, /appShell\.setAttribute\("aria-hidden", "true"\)/); |
| 83 | assert.match(dialog, /event\.key === "Escape"[\s\S]*?onClose\(\)/); |
| 84 | assert.match(dialog, /event\.key !== "Tab"[\s\S]*?first[\s\S]*?last/); |
| 85 | assert.match(dialog, /role="dialog"[\s\S]*?aria-modal="true"/); |
| 86 | assert.match(dialog, /className="readiness-popup-header"/); |
| 87 | assert.match(dialog, /className="readiness-popup-body"/); |
| 88 | }); |
| 89 | |
| 90 | test("readiness dialog has an opaque surface and one viewport-constrained scrolling body", () => { |
| 91 | const backdrop = styles.match(/\.readiness-popup-backdrop \{[\s\S]*?\n\}/)?.[0] ?? ""; |
| 92 | const popup = styles.match(/\.readiness-popup \{[\s\S]*?\n\}/)?.[0] ?? ""; |
| 93 | const body = styles.match(/\.readiness-popup-body \{[\s\S]*?\n\}/)?.[0] ?? ""; |
| 94 | |
| 95 | assert.match(backdrop, /position: fixed/); |
| 96 | assert.match(backdrop, /z-index: 1000/); |
| 97 | assert.match(backdrop, /background: rgb\(8 15 25 \/ 68%\)/); |
| 98 | assert.match(popup, /max-height: min\(920px, calc\(100dvh/); |
| 99 | assert.match(popup, /grid-template-rows: auto minmax\(0, 1fr\)/); |
| 100 | assert.match(popup, /overflow: hidden/); |
| 101 | assert.match(popup, /background: var\(--surface-raised\)/); |
| 102 | assert.doesNotMatch(popup, /overflow(?:-y)?: auto/); |
| 103 | assert.match(body, /overflow-y: auto/); |
| 104 | assert.match(body, /overscroll-behavior: contain/); |
| 105 | }); |