main
mjs 170 lines 10.2 KB
Raw
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
8 test("search endpoint is provider-neutral and backend-owned, no NSE/Yahoo text search from the browser", () => {
9 assert.match(api, /searchInstruments:[\s\S]*?\/api\/v1\/research\/instruments\/search/);
10 assert.doesNotMatch(api, /fetch\("https:\/\/yahoo|fetch\("https:\/\/www\.yahoo|window\.open.*yahoo/i);
11 assert.doesNotMatch(workspace, /\.fetch\("https:\/\/(finance|query1).*yahoo/);
12 });
13
14 test("search contract returns a canonical instrument match with a global identity", () => {
15 assert.match(
16 api,
17 /export type ResearchInstrumentMatch = \{[\s\S]*?globalInstrumentId: string;[\s\S]*?companyName: string;[\s\S]*?symbol: string;[\s\S]*?region: SectorPerformance\["region"\];[\s\S]*?\}/
18 );
19 assert.match(
20 api,
21 /searchInstruments: \(region: SectorPerformance\["region"\], query: string, limit = 20\) =>[\s\S]*?new URLSearchParams\(\{ region, q: query, limit: String\(limit\) \}\)/
22 );
23 });
24
25 test("research context supports a global search kind keyed on a globalInstrumentId", () => {
26 assert.match(workspace, /kind: "SEARCH"/);
27 assert.match(workspace, /setResearchContext\(\{ kind: "SEARCH", region, globalInstrumentId: stock\.globalInstrumentId \}\)/);
28 assert.match(workspace, /function handleSearchSelect\(match: ResearchInstrumentMatch\)/);
29 assert.match(workspace, /setSelectedResearchInstrumentId\(match\.globalInstrumentId\)/);
30 assert.match(workspace, /void loadSearchPresentation\(match\.globalInstrumentId, match\.companyName, match\.region\)/);
31 assert.match(workspace, /void openResearchReadiness\(match\.globalInstrumentId, match\.companyName\)/);
32 });
33
34 test("search uses a debounced autocomplete with min length, bounded results, and keyboard navigation", () => {
35 assert.match(workspace, /function StockSearchField\(/);
36 assert.match(workspace, /const DEBOUNCE_MS = 300;/);
37 assert.match(workspace, /trimmed\.length < 3/);
38 assert.match(workspace, /researchApi\.searchInstruments\(region, trimmed, 20\)/);
39 assert.match(workspace, /results\.slice\(0, 20\)/);
40 assert.match(workspace, /aria-autocomplete="list"/);
41 assert.match(workspace, /aria-expanded=\{open\}/);
42 assert.match(workspace, /onSelect: \(match: ResearchInstrumentMatch\) => void/);
43 assert.match(workspace, /"ArrowDown"[\s\S]*?setHighlight[\s\S]*?"ArrowUp"[\s\S]*?setHighlight[\s\S]*?"Enter"[\s\S]*?commit\([\s\S]*?"Escape"/);
44 assert.match(workspace, /onSelect=\{handleSearchSelect\}/);
45 });
46
47 test("searched stock is added to the region-aware watchlist reusing the existing service", () => {
48 const toggle = workspace.match(/function toggleSearchWatchlist\(\)[\s\S]*?\n }/)?.[0] ?? "";
49 assert.match(toggle, /researchApi\.ensureDefaultWatchlist\(match\.region\)/);
50 assert.match(toggle, /researchApi\.addWatchlistInstrument\(list\.watchlistId, \{/);
51 assert.match(toggle, /researchApi\.removeWatchlistInstrument\(list\.watchlistId, match\.globalInstrumentId\)/);
52 assert.match(workspace, /Saved to \$\{regionalWatchlistName\(searchSelectedMatch.region\)\}/);
53 assert.match(workspace, /selectedGlobalInstrumentId=\{selectedResearchInstrumentId\}/);
54 });
55
56 test("searched stock is never presented as a portfolio position (no quantity/cost/P&L)", () => {
57 const drawerStart = workspace.indexOf("function StockResearchDrawer");
58 const drawerEnd = workspace.indexOf("function AllocationCharts", drawerStart);
59 const drawer = workspace.slice(drawerStart, drawerEnd);
60 assert.match(drawer, /position\?: PortfolioPosition/);
61 const nonHeld = drawer.slice(drawer.indexOf("<h3>Watchlist status"), drawer.indexOf('<section><h3>Market Data'));
62 assert.doesNotMatch(nonHeld, /Quantity|Average Cost|Market Value|Unrealized P\/L/);
63 assert.match(drawer, /Watchlist status<\/h3><p>Public company research<\/p>/);
64 });
65
66 test("drawer projection reads durable provider identity, sector, industry, and market-data facts", () => {
67 const drawerStart = workspace.indexOf("function StockResearchDrawer");
68 const drawerEnd = workspace.indexOf("function AllocationCharts", drawerStart);
69 const drawer = workspace.slice(drawerStart, drawerEnd);
70 assert.match(drawer, /market\?\.resolution\.providerTicker/);
71 assert.match(workspace, /research\?\.structuredMarket\?\.facts\[key\]/);
72 assert.match(drawer, /research\?\.valuation\.state/);
73 assert.match(drawer, /structuredFact\(research, "sector"\)/);
74 assert.match(drawer, /structuredFact\(research, "industry"\)/);
75 assert.match(drawer, /structuredFact\(research, "latestPrice"\)/);
76 assert.match(drawer, /currentPrice/);
77 });
78
79 test("the portfolio/watchlist company select is preserved for non-search contexts", () => {
80 assert.match(workspace, /className="research-company-select"/);
81 assert.match(workspace, /<option key=\{`\$\{option\.value\}-\$\{option\.companyName\}`\} value=\{option\.value\} title=\{option\.companyName\}>/);
82 assert.match(workspace, /\{option\.companyName\}/);
83 assert.match(workspace, /title=\{option\.companyName\}/);
84 assert.match(workspace, /join\(" · "\)/);
85 assert.match(workspace, /function legacyCompositeCompanyName\(/);
86 assert.match(workspace, /segments\.length >= 3 && segments\[2\] \? segments\[2\] : null/);
87 });
88
89 test("canRefreshResearchIdentity and the global identity selector are unchanged", () => {
90 assert.match(workspace, /selectedResearchInstrumentId=\{selectedResearchInstrumentId\}/);
91 assert.match(workspace, /selectedContextResearchCompany\?\.status\s*\?\? \(selectedContextResearchCompany \? undefined : selectedResearchOption\?\.status\)/);
92 assert.match(workspace, /"COMPANY_NOT_RESOLVED"/);
93 assert.match(workspace, /"RESEARCH_NOT_APPLICABLE"/);
94 assert.match(workspace, /"ETF_UNSUPPORTED"/);
95 });
96
97 test("sector performance remains backend-owned and the dashboard keeps its single call", () => {
98 assert.equal((workspace.match(/portfolioApi\.getSectorPerformance\(/g) ?? []).length, 1);
99 assert.match(api, /getSectorPerformance: \(region: SectorPerformance\["region"\], sector: string, period: SectorPerformance\["period"\]\) =>/);
100 });
101
102 test("selected search identity is readable and never shows position-style wording", () => {
103 const smallStart = workspace.indexOf('<small className="research-search-selected"');
104 const smallEnd = workspace.indexOf(") : null}", smallStart);
105 const small = workspace.slice(smallStart, smallEnd);
106 assert.match(small, /className="research-search-name"/);
107 assert.match(small, /searchPresentation\?\.companyName \?\? searchSelectedMatch\?\.companyName \?\? selectedResearchInstrumentId/);
108 assert.match(small, /className="research-search-symbol"/);
109 assert.match(small, /searchSelectedMatch\?\.canonicalSymbol \?\? searchSelectedMatch\?\.symbol/);
110 assert.match(small, /Public company research/);
111 assert.match(small, /Not held/);
112 assert.doesNotMatch(small, /Quantity|Average Cost|Market Value|Unrealized P\/L|Qty/);
113 });
114
115 test("research filters are grouped as a secondary block below the primary identity", () => {
116 assert.match(workspace, /className="research-controls-filters"/);
117 assert.match(workspace, /flexBasis: "100%"/);
118 assert.match(workspace, /<div className="research-controls">[\s\S]*<div className="research-controls-filters"/);
119 assert.match(workspace, /className="research-company-select"/);
120 });
121
122 test("search autocomplete debounces input and discards stale responses", () => {
123 assert.match(workspace, /const DEBOUNCE_MS = 300;/);
124 assert.match(workspace, /trimmed\.length < 3/);
125 assert.match(workspace, /let cancelled = false;/);
126 assert.match(workspace, /if \(cancelled\) return;/);
127 assert.match(workspace, /clearTimeout\(active\)/);
128 assert.match(workspace, /researchApi\.searchInstruments\(region, trimmed, 20\)/);
129 assert.match(workspace, /results\.slice\(0, 20\)/);
130 assert.match(workspace, /onSelect=\{handleSearchSelect\}/);
131 });
132
133 test("research search input is full width and its listbox is position-contained", () => {
134 const styles = readFileSync(new URL("../app/styles.css", import.meta.url), "utf8");
135 assert.match(styles, /research-search-input \{[\s\S]*?width: 100%/);
136 assert.match(styles, /research-search-control \{[\s\S]*?position: relative/);
137 assert.match(styles, /research-search-input \{[\s\S]*?min-height: 52px/);
138 });
139
140 test("autocomplete result rows have explicit theme-safe colors in all states", () => {
141 const styles = readFileSync(new URL("../app/styles.css", import.meta.url), "utf8");
142 assert.match(styles, /\.research-search-listbox li \{[\s\S]*?background:[\s\S]*?color:/);
143 assert.match(styles, /\.research-search-listbox li:hover \{[\s\S]*?background:[\s\S]*?color:/);
144 assert.match(styles, /\.research-search-listbox li\.highlighted[\s\S]*?color:/);
145 assert.match(styles, /li\[aria-selected="true"\]/);
146 assert.match(styles, /\.research-search-listbox li\.selected/);
147 assert.match(styles, /li:focus-visible/);
148 });
149
150 test("research option colors have theme-safe dark-mode overrides", () => {
151 const styles = readFileSync(new URL("../app/styles.css", import.meta.url), "utf8");
152 assert.match(styles, /--research-option-bg: #f8fafc;/);
153 assert.equal((styles.match(/--research-option-bg: #1e2b3a;/g) ?? []).length, 2);
154 assert.equal((styles.match(/--research-option-hover: #334457;/g) ?? []).length, 2);
155 assert.equal((styles.match(/--research-option-text: #f1f5f9;/g) ?? []).length, 2);
156 assert.equal((styles.match(/--research-option-muted: #94a3b8;/g) ?? []).length, 2);
157 });
158
159 test("readiness acquisition observation is null-safe for the news-run shape (no replaceAll on undefined outcome/provider)", () => {
160 assert.doesNotMatch(workspace, /requirement\.acquisitionObservation \? <p>Last acquisition/);
161 assert.match(workspace, /requirement\.acquisitionObservation && requirement\.acquisitionObservation\.outcome && requirement\.acquisitionObservation\.provider \? <p>Last acquisition/);
162 });
163
164 test("readiness acquisition observation type models the news-run shape with optional outcome/provider", () => {
165 assert.match(api, /outcome\?: string \| null/);
166 assert.match(api, /provider\?: string \| null/);
167 assert.match(api, /news_readiness\?: string \| null/);
168 assert.match(api, /coverage\?: number \| null/);
169 assert.match(api, /run_id\?: string \| null/);
170 });