main
mjs 163 lines 11.1 KB
Raw
1 import assert from "node:assert/strict";
2 import { readFileSync } from "node:fs";
3 import test from "node:test";
4 import ts from "typescript";
5
6 const workspace = readFileSync(new URL("../app/components/investment-workspace.tsx", import.meta.url), "utf8");
7 const apiSource = readFileSync(new URL("../app/lib/portfolio-api.ts", import.meta.url), "utf8");
8 const helperSource = readFileSync(new URL("../app/lib/market-intelligence.ts", import.meta.url), "utf8");
9 const styles = readFileSync(new URL("../app/styles.css", import.meta.url), "utf8");
10 const helperJavaScript = ts.transpileModule(helperSource, {
11 compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }
12 }).outputText;
13 const helpers = await import(`data:text/javascript;base64,${Buffer.from(helperJavaScript).toString("base64")}`);
14
15 test("sector options remain backend-owned exact values and single encoded", () => {
16 const dashboardStart = workspace.indexOf("function MultiPortfolioDashboard");
17 const dashboardEnd = workspace.indexOf("function PortfolioCreatePanel", dashboardStart);
18 const dashboard = workspace.slice(dashboardStart, dashboardEnd);
19 assert.match(apiSource, /getMarketUniverseSectors:[\s\S]*?\/api\/v1\/research\/market-universe\/sectors/);
20 assert.match(dashboard, /sectorOptions\.map\(\(value\) => <option key=\{value\.name\} value=\{value\.name\}>\{value\.name\} \(\{value\.instrumentCount\}\)<\/option>/);
21 assert.doesNotMatch(dashboard, /Basic Materials|Consumer Defensive/);
22 const method = apiSource.slice(apiSource.indexOf("getSectorPerformance:"), apiSource.indexOf("createPortfolio:"));
23 assert.match(method, /new URLSearchParams\(\{ region, sector, period, limit: "5" \}\)/);
24 assert.doesNotMatch(method, /encodeURIComponent/);
25 });
26
27 test("region reload clears stale sectors and invalid sectors never dispatch performance", () => {
28 const discoveryCall = workspace.indexOf("portfolioApi.getMarketUniverseSectors(sectorPerformanceRegion)");
29 const discovery = workspace.slice(workspace.lastIndexOf(" useEffect(() => {", discoveryCall), workspace.indexOf("\n }, [", discoveryCall));
30 assert.match(discovery, /setSectorOptions\(\[\]\)/);
31 assert.match(discovery, /setSectorPerformanceSector\(""\)/);
32 assert.match(discovery, /setSectorPerformanceSector\(value\.sectors\[0\]\?\.name \?\? ""\)/);
33 const performanceCall = workspace.indexOf("portfolioApi.getSectorPerformance(");
34 const performance = workspace.slice(workspace.lastIndexOf(" useEffect(() => {", performanceCall), workspace.indexOf("\n }, [", performanceCall));
35 assert.match(performance, /sectorOptionsRegion === sectorPerformanceRegion/);
36 assert.match(performance, /sectorOptions\.some\(\(option\) => option\.name === sectorPerformanceSector\)/);
37 });
38
39 test("regional defaults map canonically without provider routing", () => {
40 assert.equal(helpers.regionalWatchlistName("INDIA"), "WATCHLIST-IND");
41 assert.equal(helpers.regionalWatchlistName("EUROPE"), "WATCHLIST-EU");
42 assert.equal(helpers.regionalWatchlistName("USA"), "WATCHLIST-USA");
43 const helper = helperSource.slice(helperSource.indexOf("export function regionalWatchlistName"));
44 assert.doesNotMatch(helper, /NSE|SEC|EODHD|Yahoo/);
45 });
46
47 test("card click opens public readiness without changing a regional watchlist or position", () => {
48 const start = workspace.indexOf("function openMarketIntelligenceResearch");
49 const end = workspace.indexOf("\n async function openRegionalWatchlist", start);
50 const click = workspace.slice(start, end);
51 assert.match(click, /setSelectedResearchInstrumentId\(stock\.globalInstrumentId\)/);
52 assert.match(click, /openResearchReadiness\(stock\.globalInstrumentId, stock\.companyName\)/);
53 assert.doesNotMatch(click, /ensureDefaultWatchlist|addWatchlistInstrument|prefetchVisible|createPortfolio|createPosition|addPosition|updateHolding|portfolioApi\./);
54 });
55
56 test("Research context selector visibly distinguishes portfolios from watchlists", () => {
57 const start = workspace.indexOf("function ResearchContextSelector");
58 const end = workspace.indexOf("function LoadingView", start);
59 const selector = workspace.slice(start, end);
60 assert.match(selector, /<span>Research context<\/span>/);
61 assert.match(selector, /<optgroup label="Portfolios">/);
62 assert.match(selector, /<optgroup label="Watchlists">/);
63 assert.match(selector, /portfolio:\$\{portfolio\.portfolioId\}/);
64 assert.match(selector, /watchlist:\$\{watchlist\.watchlistId\}/);
65 });
66
67 test("portfolio and watchlist research rows are mutually exclusive", () => {
68 const start = workspace.indexOf("function ResearchView");
69 const end = workspace.indexOf("function ResearchOverview", start);
70 const research = workspace.slice(start, end);
71 assert.match(research, /researchContext\.kind === "WATCHLIST" \? \(watchlistResearch\?\.instruments \?\? \[\]\)\.map/);
72 assert.match(research, /researchContext\.kind === "PORTFOLIO" \? \(portfolioResearchSummary\?\.companies \?\? \[\]\)\.map/);
73 assert.doesNotMatch(research, /Portfolio holdings plus the active regional Market Intelligence context/);
74 assert.doesNotMatch(research, /transientMarketIntelligence/);
75 assert.match(research, /This research context contains actual portfolio holdings only/);
76 });
77
78 test("watchlist row is canonical non-held presentation with separated market return", () => {
79 const start = workspace.indexOf("key={`watchlist-${item.globalInstrumentId}`}");
80 const end = workspace.indexOf("researchContext.kind === \"PORTFOLIO\"", start);
81 const row = workspace.slice(start, end);
82 assert.match(row, /data-held="false"/);
83 assert.match(row, /data-global-instrument-id=\{item\.globalInstrumentId\}/);
84 assert.match(row, /Public company research · Not held/);
85 assert.match(row, /Market return \(\{item\.sourcePeriod\}\)/);
86 assert.doesNotMatch(row, /Qty 0|Portfolio P&amp;L|Average buy price|Invested amount/);
87 assert.match(row, /watchlistInstrumentId: item\.globalInstrumentId/);
88 assert.match(row, /researchInstrumentId: company\.instrumentId \?\? item\.globalInstrumentId/);
89 });
90
91 test("same drawer represents non-held state without fabricating a portfolio position", () => {
92 const start = workspace.indexOf("function StockResearchDrawer");
93 const end = workspace.indexOf("function AllocationCharts", start);
94 const drawer = workspace.slice(start, end);
95 assert.match(drawer, /position\?: PortfolioPosition/);
96 assert.match(drawer, /watchlistItem\?: WatchlistResearchInstrument/);
97 assert.match(drawer, /Watchlist status<\/h3><p>Public company research<\/p>/);
98 const nonHeld = drawer.slice(drawer.indexOf("<h3>Watchlist status"), drawer.indexOf('<section><h3>Market Data'));
99 assert.doesNotMatch(nonHeld, /Quantity|Average Cost|Market Value|Unrealized P/);
100 assert.match(drawer, /Market return \(\{watchlistItem\.sourcePeriod\}\)/);
101 });
102
103 test("direct navigation clears watchlist presentation but not durable membership", () => {
104 const clear = workspace.slice(workspace.indexOf("function clearMarketIntelligenceContext"), workspace.indexOf("async function openResearchReadiness"));
105 assert.match(clear, /setResearchContext\(\{ kind: "PORTFOLIO", portfolioId: selectedPortfolioId \}\)/);
106 assert.match(clear, /setWatchlistResearch\(null\)/);
107 assert.doesNotMatch(clear, /removeWatchlistInstrument|DELETE|localStorage/);
108 const nav = workspace.slice(workspace.indexOf("<nav aria-label=\"Primary\">"), workspace.indexOf("</nav>"));
109 assert.match(nav, /clearMarketIntelligenceContext\(\);[\s\S]*?setView\(item\.id\)/);
110 });
111
112 test("positive negative and neutral watchlist rows retain explicit signs and interaction states", () => {
113 for (const tone of ["positive", "negative", "neutral"]) {
114 assert.match(styles, new RegExp(`\\.market-intelligence-research-row-${tone}\\b`));
115 }
116 assert.match(styles, /\.market-intelligence-research-row:hover/);
117 assert.match(styles, /\.market-intelligence-research-row\[aria-current="true"\]/);
118 assert.equal(helpers.formatSignedPerformancePct(13.43), "+13.43%");
119 assert.equal(helpers.formatSignedPerformancePct(-6.8), "-6.80%");
120 assert.equal(helpers.formatSignedPerformancePct(0), "0.00%");
121 });
122
123 test("watchlist API is provider neutral and separate from portfolio holdings", () => {
124 assert.match(apiSource, /listWatchlists:[\s\S]*?\/api\/v1\/research\/watchlists/);
125 assert.match(apiSource, /ensureDefaultWatchlist:[\s\S]*?\/api\/v1\/research\/watchlists\/default\/ensure/);
126 assert.match(apiSource, /addWatchlistInstrument:[\s\S]*?\/api\/v1\/research\/watchlists\/\$\{watchlistId\}\/instruments/);
127 const watchlistApi = apiSource.slice(apiSource.indexOf("listWatchlists:"), apiSource.indexOf("getPortfolioSummary:"));
128 assert.doesNotMatch(watchlistApi, /NSE|SEC|EODHD|createPortfolio|positions/);
129 });
130
131 for (const [region, name, performancePct] of [["INDIA", "WATCHLIST-IND", 8], ["INDIA", "WATCHLIST-IND", -8], ["USA", "WATCHLIST-USA", 3], ["EUROPE", "WATCHLIST-EU", -3]]) {
132 test(`ranked ${region} ${performancePct} persists canonical membership and refreshes Saved state`, async () => {
133 const start = workspace.indexOf(" async function addRankedStockToWatchlist");
134 const end = workspace.indexOf(" async function removeWatchlistStock", start);
135 const js = ts.transpileModule(workspace.slice(start, end), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
136 const calls = [];
137 let saved = {};
138 const list = { watchlistId: "list-id", region, name };
139 const api = {
140 ensureDefaultWatchlist: async (value) => { assert.equal(value, region); return list; },
141 addWatchlistInstrument: async (...args) => { calls.push(args); },
142 getWatchlistResearch: async () => ({ watchlist: list, instruments: [{ globalInstrumentId: "canonical-id" }] }),
143 };
144 const handler = new Function("researchApi", "watchlistMutationRef", "setWatchlistMutation", "setWatchlistActionError", "setWatchlists", "setSavedWatchlistIds", "setWatchlistRevision", "getApiFailure", `${js}; return addRankedStockToWatchlist;`)(
145 api, { current: false }, () => {}, (error) => assert.equal(error, null), () => {}, (update) => { saved = update(saved); }, () => {}, (error) => { throw error; }
146 );
147 await handler({ region, period: "WEEK", stock: { globalInstrumentId: "canonical-id", companyName: "Display only", ticker: "LOOSE", performancePct } });
148 assert.deepEqual(calls, [["list-id", { globalInstrumentId: "canonical-id", sourcePeriod: "WEEK", sourcePerformancePct: performancePct }]]);
149 assert.deepEqual(saved, { "list-id": ["canonical-id"] });
150 await handler({ region, period: "WEEK", stock: { globalInstrumentId: "", performancePct } });
151 assert.equal(calls.length, 1);
152 });
153 }
154
155 test("both ranked groups expose Saved/Add and Research exposes all regional lists and removal", () => {
156 assert.equal((workspace.match(/onAddWatchlist=\{\(\) => onAddWatchlist\(marketIntelligenceSelection\(performance, stock\)\)\}/g) ?? []).length, 2);
157 assert.match(workspace, /disabled=\{saved \|\| busy \|\| !stock.globalInstrumentId/);
158 assert.match(workspace, /Saved in \$\{watchlistName\}/);
159 assert.match(workspace, /aria-label="Regional watchlists"/);
160 assert.match(workspace, /\["INDIA", "USA", "EUROPE"\] as const/);
161 assert.match(workspace, /researchApi.removeWatchlistInstrument\(watchlistId, globalInstrumentId\)/);
162 assert.match(workspace, /marketEnsureAuthReady, researchContext, watchlistRevision/);
163 });