From 7a649ce295b9b0e113446365f7a822dcc31548c1 Mon Sep 17 00:00:00 2001 From: Boris Vasilenko Date: Sat, 11 Apr 2026 13:05:35 +0300 Subject: [PATCH 1/3] Add TradeMarkerBuilder with P/L-based exit coloring, delete vestigial configOverride/index-bak.html/TradeTableRenderer dead code, fix currency sign ordering and empty indicator pane rendering, and node:test marker regression suite --- .gitignore | 2 + Makefile | 7 +- out/index-bak.html | 840 --------------------------- out/js/ChartApplication.js | 256 ++++---- out/js/ConfigLoader.js | 19 +- out/js/PaneAssigner.js | 104 +--- out/js/TradeMarkerBuilder.js | 62 ++ out/js/TradeRowspanTransformer.js | 37 +- out/js/TradeTable.js | 98 +--- out/package.json | 3 + out/tests/TradeMarkerBuilder.test.js | 301 ++++++++++ runtime/chartdata/chartdata.go | 9 +- runtime/chartdata/chartdata_test.go | 200 +++++++ 13 files changed, 739 insertions(+), 1199 deletions(-) delete mode 100644 out/index-bak.html create mode 100644 out/js/TradeMarkerBuilder.js create mode 100644 out/package.json create mode 100644 out/tests/TradeMarkerBuilder.test.js diff --git a/.gitignore b/.gitignore index f81008f..d1705e6 100644 --- a/.gitignore +++ b/.gitignore @@ -230,3 +230,5 @@ testdata/*-output.json # Contribot contribot.*.json transcripts/ + +.playwright-cli \ No newline at end of file diff --git a/Makefile b/Makefile index 47e57b0..aec8f88 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Makefile for Runner - PineScript Go Port # Centralized build automation following Go project conventions -.PHONY: help build test test-unit test-integration test-e2e test-golden test-golden-update test-parser test-codegen test-runtime test-series test-syminfo regression-syminfo bench bench-series coverage coverage-show check ci clean clean-all cross-compile fmt vet lint build-strategy +.PHONY: help build test test-unit test-integration test-e2e test-golden test-golden-update test-parser test-codegen test-runtime test-series test-syminfo regression-syminfo test-ui bench bench-series coverage coverage-show check ci clean clean-all cross-compile fmt vet lint build-strategy # Project configuration PROJECT_NAME := runner @@ -161,6 +161,11 @@ test-syminfo: ## Run syminfo.tickerid integration tests only test-syminfo-regression: ## Run syminfo.tickerid regression test suite @./scripts/test-syminfo-regression.sh +test-ui: ## Run chart viewer JS unit tests (node:test, no install required) + @echo "Running chart viewer JS tests..." + @node --test out/tests/*.test.js + @echo "✓ Chart UI tests passed" + bench: ## Run benchmarks @echo "Running benchmarks..." @ $(GO) test $(BENCH_FLAGS) -bench=. ./... diff --git a/out/index-bak.html b/out/index-bak.html deleted file mode 100644 index 642e0fd..0000000 --- a/out/index-bak.html +++ /dev/null @@ -1,840 +0,0 @@ - - - - Financial Chart Visualization - - - - - - -
-

Financial Chart

- -
- Symbol: Loading...
- Timeframe: Loading...
- Strategy: Loading... -
- - - -
-
-
- -
- -
-
-

Trade History

-
No trades
-
-
- - - - - - - - - - - - - - - - - -
#DateDirectionEntryExitSizeProfit/Loss
No trades to display
-
-
-
- - - - diff --git a/out/js/ChartApplication.js b/out/js/ChartApplication.js index e5b4011..8cb8e42 100644 --- a/out/js/ChartApplication.js +++ b/out/js/ChartApplication.js @@ -3,9 +3,10 @@ import { PaneAssigner } from './PaneAssigner.js'; import { PaneManager } from './PaneManager.js'; import { SeriesRouter } from './SeriesRouter.js'; import { ChartManager } from './ChartManager.js'; -import { TradeDataFormatter } from './TradeTable.js'; +import { TradeDataFormatter, formatCurrency } from './TradeTable.js'; import { TradeRowspanTransformer } from './TradeRowspanTransformer.js'; import { TradeRowspanRenderer } from './TradeRowspanRenderer.js'; +import { TradeMarkerBuilder } from './TradeMarkerBuilder.js'; import { TimeIndexBuilder } from './TimeIndexBuilder.js'; import { PlotOffsetTransformer } from './PlotOffsetTransformer.js'; import { SeriesDataMapper } from './SeriesDataMapper.js'; @@ -19,88 +20,84 @@ export class ChartApplication { this.timeIndexBuilder = new TimeIndexBuilder(); this.plotOffsetTransformer = new PlotOffsetTransformer(this.timeIndexBuilder); this.seriesDataMapper = new SeriesDataMapper(); + this.tradeMarkerBuilder = new TradeMarkerBuilder(); } async initialize() { const data = await ConfigLoader.loadChartData(); - const configOverride = await ConfigLoader.loadStrategyConfig( - data.metadata?.strategy || 'strategy' - ); - - const paneAssigner = new PaneAssigner(data.candlestick); - const indicatorsWithPanes = paneAssigner.assignAllPanes( - data.indicators, - configOverride - ); - // Merge config style/color overrides into indicators - if (configOverride) { - Object.entries(indicatorsWithPanes).forEach(([key, indicator]) => { - const override = configOverride[key]; - if (override && typeof override === 'object') { - if (override.style) indicator.style = { ...indicator.style, ...override }; - } - }); - } + const indicatorsWithPanes = new PaneAssigner(data.candlestick).assignAllPanes(data.indicators); this.updateMetadataDisplay(data.metadata); - const paneConfig = this.buildPaneConfig(indicatorsWithPanes, data.ui?.panes); - + const paneConfig = this._buildPaneConfig(indicatorsWithPanes, data.ui?.panes); this.paneManager = new PaneManager(this.chartOptions); - this.createCharts(paneConfig); + this._createCharts(paneConfig); const seriesRouter = new SeriesRouter(this.paneManager, this.seriesMap); - this.routeAndLoadSeries(indicatorsWithPanes, data, seriesRouter, configOverride); + const candlestickData = this._routeAndLoadSeries(indicatorsWithPanes, data, seriesRouter); - this.loadTrades(data.strategy, data.candlestick); + this._loadTradeMarkers(data.strategy, candlestickData); + this._loadTrades(data.strategy, data.candlestick); this.updateTimestamp(data.metadata); - - this.setupEventListeners(); + this._setupEventListeners(); this.paneManager.synchronizeTimeScales(); - setTimeout(() => { - ChartManager.fitContent(this.paneManager.getAllCharts()); - }, 50); + setTimeout(() => ChartManager.fitContent(this.paneManager.getAllCharts()), 50); } - buildPaneConfig(indicatorsWithPanes, uiPanes) { - const config = { - main: { height: 400, fixed: true }, - }; + async refresh() { + this.paneManager.getAllCharts().forEach(chart => chart.remove()); + this.paneManager.dynamicPanes.forEach(({ container }) => container.remove()); + this.paneManager.mainPane.container.innerHTML = ''; - const uniquePanes = new Set(); - Object.values(indicatorsWithPanes).forEach((indicator) => { - const pane = indicator.pane; - if (pane && pane !== 'main') { - uniquePanes.add(pane); - } - }); + this.seriesMap = {}; + this.paneManager = null; + + await this.initialize(); + } + + updateMetadataDisplay(metadata) { + if (!metadata) return; + document.getElementById('chart-title').textContent = metadata.title || 'Financial Chart'; + document.getElementById('symbol-display').textContent = metadata.symbol || 'Unknown'; + document.getElementById('timeframe-display').textContent = metadata.timeframe || 'Unknown'; + document.getElementById('strategy-display').textContent = metadata.strategy || 'Unknown'; + } + + updateTimestamp(metadata) { + if (!metadata?.timestamp) return; + document.getElementById('timestamp').textContent = + 'Last updated: ' + new Date(metadata.timestamp).toLocaleString(); + } - uniquePanes.forEach((paneName) => { + _buildPaneConfig(indicatorsWithPanes, uiPanes) { + const config = { main: { height: 400, fixed: true } }; + + const dynamicPanes = new Set( + Object.values(indicatorsWithPanes) + .map(ind => ind.pane) + .filter(pane => pane && pane !== 'main') + ); + + dynamicPanes.forEach(paneName => { config[paneName] = uiPanes?.[paneName] || { height: 200, fixed: false }; }); - /* Backward compatibility: ensure 'indicator' pane exists if no dynamic panes */ - if (Object.keys(config).length === 1) { - config.indicator = { height: 200, fixed: false }; - } - return config; } - createCharts(paneConfig) { + _createCharts(paneConfig) { const mainContainer = document.getElementById('main-chart'); this.paneManager.createMainPane(mainContainer, paneConfig.main); - - Object.entries(paneConfig).forEach(([paneName, config]) => { + for (const [paneName, config] of Object.entries(paneConfig)) { if (paneName !== 'main') { this.paneManager.createDynamicPane(paneName, config); } - }); + } } - routeAndLoadSeries(indicatorsWithPanes, data, seriesRouter, configOverride) { + _routeAndLoadSeries(indicatorsWithPanes, data, seriesRouter) { const mainChart = this.paneManager.mainPane.chart; this.seriesMap.candlestick = ChartManager.addCandlestickSeries(mainChart, { @@ -113,163 +110,110 @@ export class ChartApplication { const candlestickData = data.candlestick .sort((a, b) => a.time - b.time) - .map((c) => ({ - time: c.time, - open: c.open, - high: c.high, - low: c.low, - close: c.close, - })); + .map(({ time, open, high, low, close }) => ({ time, open, high, low, close })); this.seriesMap.candlestick.setData(candlestickData); - Object.entries(indicatorsWithPanes).forEach(([key, indicator]) => { - const styleType = configOverride?.[key]?.style || 'line'; - const color = indicator.style?.color || configOverride?.[key]?.color || '#2196F3'; - const lineStyleValue = configOverride?.[key]?.lineStyle; - + for (const [key, indicator] of Object.entries(indicatorsWithPanes)) { + const color = indicator.style?.color || '#2196F3'; const seriesConfig = { - color: color, + color, lineWidth: indicator.style?.lineWidth || 2, title: indicator.title || key, chart: indicator.pane || 'main', - style: styleType, + style: indicator.style?.plotStyle || 'line', priceLineVisible: false, lastValueVisible: true, crosshairMarkerVisible: true, - lineStyle: LineStyleConverter.toNumeric(lineStyleValue), + lineStyle: LineStyleConverter.toNumeric(indicator.style?.lineStyle), }; const series = seriesRouter.routeSeries(key, seriesConfig, ChartManager); - if (!series) { console.error(`Failed to create series for '${key}'`); - return; + continue; } const offset = indicator.offset || 0; - const offsetAdjustedData = this.plotOffsetTransformer.transform( - indicator.data, - offset, - candlestickData - ); + const shifted = this.plotOffsetTransformer.transform(indicator.data, offset, candlestickData); + const colored = this.seriesDataMapper.applyColorToData(shifted, color); + const processed = window.adaptLineSeriesData(colored); - const dataWithColor = this.seriesDataMapper.applyColorToData(offsetAdjustedData, color); - const processedData = window.adaptLineSeriesData(dataWithColor); - - if (processedData.length > 0) { - series.setData(processedData); - - // Auto-zoom to Buy/Sell Potential signals if they exist - if ((key === 'Buy Potential' || key === 'Sell Potential') && processedData.length > 0) { - const validPoints = processedData.filter(p => !isNaN(p.value) && p.value !== null); - if (validPoints.length > 0) { - const firstTime = validPoints[0].time; - const lastTime = validPoints[validPoints.length - 1].time; - const mainChart = this.paneManager.mainPane.chart; - - // Zoom to show signals with context (±50 bars = 50 hours for 1h timeframe) - const contextBars = 50; - const barInterval = 3600; // 1 hour - mainChart.timeScale().setVisibleRange({ - from: firstTime - (contextBars * barInterval), - to: lastTime + (contextBars * barInterval) - }); - } - } + if (processed.length > 0) { + series.setData(processed); + this._applySignalZoom(key, processed); } + } + + return candlestickData; + } + + _applySignalZoom(key, processedData) { + if (key !== 'Buy Potential' && key !== 'Sell Potential') return; + const valid = processedData.filter(p => p.value != null && !isNaN(p.value)); + if (!valid.length) return; + const contextBars = 50; + const barInterval = 3600; + this.paneManager.mainPane.chart.timeScale().setVisibleRange({ + from: valid[0].time - contextBars * barInterval, + to: valid[valid.length - 1].time + contextBars * barInterval, }); } - loadTrades(strategy, candlestickData) { + _loadTradeMarkers(strategy, candlestickData) { + const markers = this.tradeMarkerBuilder.build(strategy, candlestickData); + if (markers.length > 0) { + this.seriesMap.candlestick.setMarkers(markers); + } + } + + _loadTrades(strategy, candlestickData) { if (!strategy) return; const allTrades = [ ...(strategy.trades || []), - ...(strategy.openTrades || []).map((t) => ({ ...t, status: 'open' })), + ...(strategy.openTrades || []).map(t => ({ ...t, status: 'open' })), ]; - // Sort trades: latest first (by entryTime descending) allTrades.sort((a, b) => (b.entryTime || 0) - (a.entryTime || 0)); const tbody = document.getElementById('trades-tbody'); const summary = document.getElementById('trades-summary'); if (allTrades.length === 0) { - tbody.innerHTML = - 'No trades to display'; + tbody.innerHTML = 'No trades to display'; summary.textContent = 'No trades'; return; } - const currentPrice = candlestickData?.length > 0 - ? candlestickData[candlestickData.length - 1].close + const currentPrice = candlestickData?.length > 0 + ? candlestickData[candlestickData.length - 1].close : null; const formatter = new TradeDataFormatter(candlestickData); - const transformer = new TradeRowspanTransformer(formatter); - const renderer = new TradeRowspanRenderer(); - - const tradeRows = transformer.transformTrades(allTrades, currentPrice); - tbody.innerHTML = renderer.renderRows(tradeRows); + const rows = new TradeRowspanTransformer(formatter).transformTrades(allTrades, currentPrice); + tbody.innerHTML = new TradeRowspanRenderer().renderRows(rows); const realizedProfit = strategy.netProfit || 0; const unrealizedProfit = currentPrice - ? (strategy.openTrades || []).reduce((sum, trade) => { - const multiplier = trade.direction === 'long' ? 1 : -1; - return sum + (currentPrice - trade.entryPrice) * trade.size * multiplier; + ? (strategy.openTrades || []).reduce((sum, t) => { + const multiplier = t.direction === 'long' ? 1 : -1; + return sum + (currentPrice - t.entryPrice) * t.size * multiplier; }, 0) : 0; const totalProfit = realizedProfit + unrealizedProfit; - - const profitClass = - totalProfit >= 0 ? 'trade-profit-positive' : 'trade-profit-negative'; - summary.innerHTML = `${allTrades.length} trades | Net P/L: $${totalProfit.toFixed( - 2 - )}`; - } - updateMetadataDisplay(metadata) { - if (!metadata) return; - - document.getElementById('chart-title').textContent = - metadata.title || 'Financial Chart'; - document.getElementById('symbol-display').textContent = - metadata.symbol || 'Unknown'; - document.getElementById('timeframe-display').textContent = - metadata.timeframe || 'Unknown'; - document.getElementById('strategy-display').textContent = - metadata.strategy || 'Unknown'; + const profitClass = totalProfit >= 0 ? 'trade-profit-positive' : 'trade-profit-negative'; + summary.innerHTML = + `${allTrades.length} trades | Net P/L: ${formatCurrency(totalProfit)}`; } - updateTimestamp(metadata) { - if (!metadata?.timestamp) return; - - document.getElementById('timestamp').textContent = - 'Last updated: ' + new Date(metadata.timestamp).toLocaleString(); - } - - setupEventListeners() { + _setupEventListeners() { window.addEventListener('resize', () => { - const containers = this.paneManager.getAllContainers(); - const charts = this.paneManager.getAllCharts(); - ChartManager.handleResize(charts, containers); - }); - } - - async refresh() { - // Clear all charts and containers - const charts = this.paneManager.getAllCharts(); - charts.forEach(chart => chart.remove()); - - const containers = this.paneManager.getAllContainers(); - containers.forEach((container) => { - container.innerHTML = ''; + ChartManager.handleResize( + this.paneManager.getAllCharts(), + this.paneManager.getAllContainers() + ); }); - - this.seriesMap = {}; - this.paneManager = null; - - await this.initialize(); } } diff --git a/out/js/ConfigLoader.js b/out/js/ConfigLoader.js index 93bc593..2eb5b59 100644 --- a/out/js/ConfigLoader.js +++ b/out/js/ConfigLoader.js @@ -1,23 +1,6 @@ -/* Config file loader for optional explicit pane overrides (SRP) */ export class ConfigLoader { - static async loadStrategyConfig(strategyName) { - try { - const configUrl = `${strategyName}.config`; - const response = await fetch(configUrl + '?' + Date.now()); - - if (!response.ok) { - return null; - } - - const config = await response.json(); - return config.indicators || null; - } catch (error) { - return null; - } - } - static async loadChartData(url = 'chart-data.json') { const response = await fetch(url + '?' + Date.now()); - return await response.json(); + return response.json(); } } diff --git a/out/js/PaneAssigner.js b/out/js/PaneAssigner.js index 54ddaec..85ebc65 100644 --- a/out/js/PaneAssigner.js +++ b/out/js/PaneAssigner.js @@ -1,94 +1,50 @@ -/* Pane assignment logic based on value range analysis (SRP) */ export class PaneAssigner { constructor(candlestickData) { - this.candlestickRange = this.calculateCandlestickRange(candlestickData); + this.candlestickRange = this._candlestickRange(candlestickData); } - calculateCandlestickRange(candlestickData) { - if (!candlestickData || candlestickData.length === 0) { - return { min: 0, max: 0 }; + _candlestickRange(candlestickData) { + if (!candlestickData?.length) return { min: 0, max: 0 }; + let min = Infinity, max = -Infinity; + for (const { low, high } of candlestickData) { + if (low < min) min = low; + if (high > max) max = high; } - - let min = Infinity; - let max = -Infinity; - - candlestickData.forEach((candle) => { - if (candle.low < min) min = candle.low; - if (candle.high > max) max = candle.high; - }); - return { min, max }; } - calculateIndicatorRange(indicatorData) { - if (!indicatorData || indicatorData.length === 0) { - return { min: 0, max: 0 }; - } - - let min = Infinity; - let max = -Infinity; - let validCount = 0; - - indicatorData.forEach((point) => { - if (point.value !== null && point.value !== undefined && !isNaN(point.value) && point.value !== 0) { - if (point.value < min) min = point.value; - if (point.value > max) max = point.value; - validCount++; + _indicatorRange(indicatorData) { + if (!indicatorData?.length) return { min: 0, max: 0 }; + let min = Infinity, max = -Infinity, count = 0; + for (const { value } of indicatorData) { + if (value != null && !isNaN(value) && value !== 0) { + if (value < min) min = value; + if (value > max) max = value; + count++; } - }); - - if (validCount === 0) { - return { min: 0, max: 0 }; } - - return { min, max }; + return count === 0 ? { min: 0, max: 0 } : { min, max }; } - rangesOverlap(range1, range2, overlapThreshold = 0.3) { - const range1Span = range1.max - range1.min; - const range2Span = range2.max - range2.min; - - if (range1Span === 0 || range2Span === 0) return false; - - const overlapMin = Math.max(range1.min, range2.min); - const overlapMax = Math.min(range1.max, range2.max); - const overlapSpan = Math.max(0, overlapMax - overlapMin); - - const overlapRatio = overlapSpan / Math.min(range1Span, range2Span); - - return overlapRatio >= overlapThreshold; + _rangesOverlap(r1, r2, threshold = 0.3) { + const span1 = r1.max - r1.min; + const span2 = r2.max - r2.min; + if (span1 === 0 || span2 === 0) return false; + const overlapSpan = Math.max(0, Math.min(r1.max, r2.max) - Math.max(r1.min, r2.min)); + return overlapSpan / Math.min(span1, span2) >= threshold; } - assignPane(indicatorKey, indicator, configOverride = null) { - if (configOverride && configOverride[indicatorKey]) { - const override = configOverride[indicatorKey]; - // Handle both string ("indicator") and object ({pane: "indicator", ...}) - return typeof override === 'string' ? override : (override.pane || 'indicator'); - } - - if (indicator.pane && indicator.pane !== '') { - return indicator.pane; - } - - const indicatorRange = this.calculateIndicatorRange(indicator.data); - - if (this.rangesOverlap(this.candlestickRange, indicatorRange)) { - return 'main'; - } - - return 'indicator'; + _assignPane(indicator) { + if (indicator.pane) return indicator.pane; + const range = this._indicatorRange(indicator.data); + return this._rangesOverlap(this.candlestickRange, range) ? 'main' : 'indicator'; } - assignAllPanes(indicators, configOverride = null) { + assignAllPanes(indicators) { const result = {}; - - Object.entries(indicators).forEach(([key, indicator]) => { - result[key] = { - ...indicator, - pane: this.assignPane(key, indicator, configOverride), - }; - }); - + for (const [key, indicator] of Object.entries(indicators)) { + result[key] = { ...indicator, pane: this._assignPane(indicator) }; + } return result; } } diff --git a/out/js/TradeMarkerBuilder.js b/out/js/TradeMarkerBuilder.js new file mode 100644 index 0000000..4bfd076 --- /dev/null +++ b/out/js/TradeMarkerBuilder.js @@ -0,0 +1,62 @@ +const DIRECTION_LONG_COLOR = '#26a69a'; +const DIRECTION_SHORT_COLOR = '#ef5350'; +const OUTCOME_PROFIT_COLOR = '#26a69a'; +const OUTCOME_LOSS_COLOR = '#ef5350'; + +export class TradeMarkerBuilder { + build(strategy, candlestickData) { + if (!strategy || !candlestickData?.length) return []; + + const markers = []; + + for (const trade of strategy.trades || []) { + const entryTime = this._resolveTime(trade.entryBar, trade.entryTime, candlestickData); + const exitTime = this._resolveTime(trade.exitBar, trade.exitTime, candlestickData); + if (entryTime !== null) markers.push(this._entryMarker(trade, entryTime)); + if (exitTime !== null) markers.push(this._exitMarker(trade, exitTime)); + } + + for (const trade of strategy.openTrades || []) { + const entryTime = this._resolveTime(trade.entryBar, trade.entryTime, candlestickData); + if (entryTime !== null) markers.push(this._entryMarker(trade, entryTime)); + } + + markers.sort((a, b) => a.time - b.time); + return markers; + } + + _resolveTime(barIndex, unixTime, candlestickData) { + if (barIndex >= 0 && barIndex < candlestickData.length) { + return candlestickData[barIndex].time; + } + return unixTime || null; + } + + _entryMarker(trade, time) { + const isLong = trade.direction === 'long'; + return { + time, + position: isLong ? 'belowBar' : 'aboveBar', + color: this._entryColor(trade), + shape: isLong ? 'arrowUp' : 'arrowDown', + }; + } + + _exitMarker(trade, time) { + const isLong = trade.direction === 'long'; + return { + time, + position: isLong ? 'aboveBar' : 'belowBar', + color: this._exitColor(trade), + shape: 'circle', + }; + } + + _entryColor(trade) { + return trade.direction === 'long' ? DIRECTION_LONG_COLOR : DIRECTION_SHORT_COLOR; + } + + _exitColor(trade) { + return trade.profit >= 0 ? OUTCOME_PROFIT_COLOR : OUTCOME_LOSS_COLOR; + } +} diff --git a/out/js/TradeRowspanTransformer.js b/out/js/TradeRowspanTransformer.js index 8b0dc39..6f28741 100644 --- a/out/js/TradeRowspanTransformer.js +++ b/out/js/TradeRowspanTransformer.js @@ -1,37 +1,25 @@ import { TradeRowData } from './TradeRowData.js'; -/** - * TradeRowspanTransformer - Transforms Trade objects into Entry/Exit row pairs - * - * SRP: Single responsibility - convert domain Trade to presentation TradeRowData pairs - * DRY: Reuses TradeDataFormatter for date/price formatting - * KISS: Simple transformation logic, no business logic - */ export class TradeRowspanTransformer { constructor(formatter) { this.formatter = formatter; } - /** - * Transform single trade into [entryRow, exitRow] pair - */ transformTrade(trade, tradeNumber, currentPrice) { const isOpen = trade.status === 'open'; const unrealizedProfit = this.formatter.calculateUnrealizedProfit(trade, currentPrice); - - const exitPrice = isOpen - ? (currentPrice !== null && currentPrice !== undefined ? currentPrice : trade.entryPrice) - : (trade.exitPrice !== null && trade.exitPrice !== undefined ? trade.exitPrice : 0); + + const exitPrice = isOpen + ? (currentPrice ?? trade.entryPrice) + : (trade.exitPrice ?? 0); const profitValue = isOpen ? unrealizedProfit : trade.profit; - const formattedProfit = this.formatter.formatProfit(profitValue); - // Entry row const entryRow = new TradeRowData({ - tradeNumber: tradeNumber, + tradeNumber, rowType: 'entry', dateTime: this.formatter.getTradeDate(trade, true), - signal: trade.entryComment || trade.EntryComment || '', + signal: trade.entryComment || trade.entryId || '', price: this.formatter.formatPrice(trade.entryPrice), size: trade.size.toFixed(2), profitLoss: '', @@ -40,27 +28,22 @@ export class TradeRowspanTransformer { profitRaw: 0, }); - // Exit row const exitRow = new TradeRowData({ - tradeNumber: tradeNumber, + tradeNumber, rowType: 'exit', dateTime: isOpen ? 'Open' : this.formatter.getTradeDate(trade, false), - signal: isOpen ? '' : (trade.exitComment || trade.ExitComment || ''), + signal: isOpen ? '' : (trade.exitComment || trade.exitId || ''), price: this.formatter.formatPrice(exitPrice), size: '', - profitLoss: formattedProfit, + profitLoss: this.formatter.formatProfit(profitValue), direction: trade.direction, - isOpen: isOpen, + isOpen, profitRaw: profitValue, }); return [entryRow, exitRow]; } - /** - * Transform array of trades into flat array of TradeRowData - * [trade1, trade2] → [trade1_entry, trade1_exit, trade2_entry, trade2_exit] - */ transformTrades(trades, currentPrice) { const rows = []; trades.forEach((trade, index) => { diff --git a/out/js/TradeTable.js b/out/js/TradeTable.js index 6033304..da41985 100644 --- a/out/js/TradeTable.js +++ b/out/js/TradeTable.js @@ -1,12 +1,21 @@ -/* Trade data formatting (SRP, DRY) */ +export function formatCurrency(value) { + if (value == null || !isFinite(value)) return '$0.00'; + if (value === 0) return '$0.00'; + const abs = value < 0 ? -value : value; + const formatted = abs.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + return value > 0 ? '+$' + formatted : '-$' + formatted; +} + export class TradeDataFormatter { constructor(candlestickData) { this.candlestickData = candlestickData || []; } formatDate(timestamp) { - const date = new Date(timestamp); - return date.toLocaleString('en-US', { + return new Date(timestamp).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', @@ -16,32 +25,30 @@ export class TradeDataFormatter { } formatPrice(price) { - if (price === null || price === undefined) return '$0.00'; - return `$${price.toFixed(2)}`; + if (price == null) return '$0.00'; + return '$' + price.toFixed(2); } formatProfit(profit) { - const formatted = `$${Math.abs(profit).toFixed(2)}`; - return profit >= 0 ? `+${formatted}` : `-${formatted}`; + return formatCurrency(profit); } getTradeDate(trade, isEntry = true) { const timeField = isEntry ? 'entryTime' : 'exitTime'; const barField = isEntry ? 'entryBar' : 'exitBar'; - + if (trade[timeField]) { return this.formatDate(trade[timeField] * 1000); } - + const barIndex = trade[barField]; if (barIndex !== undefined && barIndex >= 0 && barIndex < this.candlestickData.length) { const bar = this.candlestickData[barIndex]; - if (bar && bar.time !== undefined) { - const timestamp = bar.time * 1000; - return this.formatDate(timestamp); + if (bar?.time !== undefined) { + return this.formatDate(bar.time * 1000); } } - + return 'N/A'; } @@ -50,69 +57,4 @@ export class TradeDataFormatter { const multiplier = trade.direction === 'long' ? 1 : -1; return (currentPrice - trade.entryPrice) * trade.size * multiplier; } - - formatTrade(trade, index, currentPrice) { - const isOpen = trade.status === 'open'; - const unrealizedProfit = this.calculateUnrealizedProfit(trade, currentPrice); - - const exitPrice = isOpen - ? (currentPrice !== null && currentPrice !== undefined ? currentPrice : trade.entryPrice) - : (trade.exitPrice !== null && trade.exitPrice !== undefined ? trade.exitPrice : 0); - - return { - number: index + 1, - entryDate: this.getTradeDate(trade, true), - entryBar: trade.entryBar !== undefined ? trade.entryBar : 'N/A', - exitDate: isOpen ? 'Open' : this.getTradeDate(trade, false), - exitBar: isOpen ? '-' : (trade.exitBar !== undefined ? trade.exitBar : 'N/A'), - direction: trade.direction, - entryPrice: this.formatPrice(trade.entryPrice), - exitPrice: this.formatPrice(exitPrice), - size: trade.size.toFixed(2), - profit: isOpen ? this.formatProfit(unrealizedProfit) : this.formatProfit(trade.profit), - profitRaw: isOpen ? unrealizedProfit : trade.profit, - entryId: trade.entryId || trade.entryID || 'N/A', - isOpen: isOpen, - }; - } -} - -/* Trade table HTML renderer (SRP, KISS) */ -export class TradeTableRenderer { - constructor(formatter) { - this.formatter = formatter; - } - - renderRows(trades, currentPrice) { - return trades - .map((trade, index) => { - const formatted = this.formatter.formatTrade(trade, index, currentPrice); - const directionClass = - formatted.direction === 'long' ? 'trade-long' : 'trade-short'; - const profitClass = formatted.isOpen - ? formatted.profitRaw >= 0 - ? 'trade-profit-positive' - : 'trade-profit-negative' - : formatted.profitRaw >= 0 - ? 'trade-profit-positive' - : 'trade-profit-negative'; - - return ` - - ${formatted.number} - ${formatted.entryDate} - ${formatted.entryBar} - ${formatted.direction.toUpperCase()} - ${formatted.entryPrice} - ${formatted.exitDate} - ${formatted.exitBar} - ${formatted.exitPrice} - ${formatted.size} - ${formatted.profit} - ${formatted.entryId} - - `; - }) - .join(''); - } } diff --git a/out/package.json b/out/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/out/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/out/tests/TradeMarkerBuilder.test.js b/out/tests/TradeMarkerBuilder.test.js new file mode 100644 index 0000000..12dc2c2 --- /dev/null +++ b/out/tests/TradeMarkerBuilder.test.js @@ -0,0 +1,301 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { TradeMarkerBuilder } from '../js/TradeMarkerBuilder.js'; + +const EXPECTED_PROFIT_COLOR = '#26a69a'; +const EXPECTED_LOSS_COLOR = '#ef5350'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeCandlesticks(count) { + return Array.from({ length: count }, (_, i) => ({ time: (i + 1) * 1000 })); +} + +function makeClosedTrade(overrides = {}) { + return { + direction: 'long', + profit: 100, + entryBar: 0, + entryTime: 1000, + exitBar: 1, + exitTime: 2000, + ...overrides, + }; +} + +function makeOpenTrade(overrides = {}) { + return { + direction: 'long', + entryBar: 0, + entryTime: 1000, + ...overrides, + }; +} + +function buildMarkers(trades = [], openTrades = [], candlesticks = makeCandlesticks(10)) { + return new TradeMarkerBuilder().build({ trades, openTrades }, candlesticks); +} + +// ── Guard conditions ────────────────────────────────────────────────────────── + +describe('build() — guard conditions', () => { + const guardCases = [ + { label: 'null strategy', fn: () => new TradeMarkerBuilder().build(null, makeCandlesticks(5)) }, + { label: 'undefined strategy', fn: () => new TradeMarkerBuilder().build(undefined, makeCandlesticks(5)) }, + { label: 'null candlestick data', fn: () => new TradeMarkerBuilder().build({ trades: [], openTrades: [] }, null) }, + { label: 'empty candlestick array', fn: () => new TradeMarkerBuilder().build({ trades: [], openTrades: [] }, []) }, + { label: 'empty trade lists', fn: () => buildMarkers([], []) }, + { label: 'strategy with no fields', fn: () => new TradeMarkerBuilder().build({}, makeCandlesticks(5)) }, + ]; + + for (const { label, fn } of guardCases) { + it(`returns empty array for ${label}`, () => { + assert.deepEqual(fn(), []); + }); + } +}); + +// ── Sort order ──────────────────────────────────────────────────────────────── + +describe('build() — marker sort order', () => { + it('sorts markers ascending by time regardless of trade input order', () => { + const cd = makeCandlesticks(6); + const trades = [ + makeClosedTrade({ entryBar: 4, exitBar: 5 }), + makeClosedTrade({ entryBar: 0, exitBar: 1 }), + makeClosedTrade({ entryBar: 2, exitBar: 3 }), + ]; + const markers = buildMarkers(trades, [], cd); + for (let i = 1; i < markers.length; i++) { + assert.ok( + markers[i].time >= markers[i - 1].time, + `out-of-order at index ${i}: ${markers[i - 1].time} > ${markers[i].time}`, + ); + } + }); + + it('emits all markers when multiple trades share the same bar', () => { + const cd = makeCandlesticks(3); + const trades = [ + makeClosedTrade({ entryBar: 0, exitBar: 1, profit: 50 }), + makeClosedTrade({ entryBar: 0, exitBar: 1, profit: -30 }), + ]; + assert.equal(buildMarkers(trades, [], cd).length, 4); + }); + + it('emits entry+exit for each closed trade', () => { + const cd = makeCandlesticks(10); + const n = 5; + const trades = Array.from({ length: n }, (_, i) => + makeClosedTrade({ entryBar: i * 2, exitBar: i * 2 + 1 }), + ); + assert.equal(buildMarkers(trades, [], cd).length, n * 2); + }); +}); + +// ── Entry marker — shape and position ───────────────────────────────────────── + +describe('entry marker — shape and position', () => { + const cases = [ + { direction: 'long', shape: 'arrowUp', position: 'belowBar' }, + { direction: 'short', shape: 'arrowDown', position: 'aboveBar' }, + ]; + + for (const { direction, shape, position } of cases) { + it(`${direction} entry → shape=${shape}, position=${position}`, () => { + const cd = makeCandlesticks(3); + const markers = buildMarkers( + [makeClosedTrade({ direction, entryBar: 0, exitBar: 1 })], [], cd, + ); + const entry = markers.find(m => m.shape === shape); + assert.ok(entry, `no marker with shape=${shape}`); + assert.equal(entry.position, position); + }); + } +}); + +// ── Entry marker — color follows direction ──────────────────────────────────── + +describe('entry marker — color axis is direction', () => { + it('long entry → profit color regardless of trade P/L', () => { + const cd = makeCandlesticks(3); + for (const profit of [500, -500, 0]) { + const entry = buildMarkers( + [makeClosedTrade({ direction: 'long', profit, entryBar: 0, exitBar: 1 })], [], cd, + ).find(m => m.shape === 'arrowUp'); + assert.equal(entry.color, EXPECTED_PROFIT_COLOR, `failed for profit=${profit}`); + } + }); + + it('short entry → loss color regardless of trade P/L', () => { + const cd = makeCandlesticks(3); + for (const profit of [500, -500, 0]) { + const entry = buildMarkers( + [makeClosedTrade({ direction: 'short', profit, entryBar: 0, exitBar: 1 })], [], cd, + ).find(m => m.shape === 'arrowDown'); + assert.equal(entry.color, EXPECTED_LOSS_COLOR, `failed for profit=${profit}`); + } + }); +}); + +// ── Exit marker — shape and position ───────────────────────────────────────── + +describe('exit marker — shape and position', () => { + const cases = [ + { direction: 'long', position: 'aboveBar' }, + { direction: 'short', position: 'belowBar' }, + ]; + + for (const { direction, position } of cases) { + it(`${direction} exit → shape=circle, position=${position}`, () => { + const cd = makeCandlesticks(3); + const exit = buildMarkers( + [makeClosedTrade({ direction, entryBar: 0, exitBar: 1 })], [], cd, + ).find(m => m.shape === 'circle'); + assert.ok(exit, 'no circle marker found'); + assert.equal(exit.shape, 'circle'); + assert.equal(exit.position, position); + }); + } +}); + +// ── Exit marker — color axis is P/L ────────────────────────────────────────── + +describe('exit marker — color axis is realized P/L', () => { + const colorCases = [ + { profit: 100, expected: EXPECTED_PROFIT_COLOR, label: 'positive profit' }, + { profit: -100, expected: EXPECTED_LOSS_COLOR, label: 'negative profit' }, + { profit: 0, expected: EXPECTED_PROFIT_COLOR, label: 'zero profit (breakeven = not a loss)' }, + { profit: 0.01, expected: EXPECTED_PROFIT_COLOR, label: 'fractional positive' }, + { profit: -0.01, expected: EXPECTED_LOSS_COLOR, label: 'fractional negative' }, + { profit: 1e9, expected: EXPECTED_PROFIT_COLOR, label: 'very large profit' }, + { profit: -1e9, expected: EXPECTED_LOSS_COLOR, label: 'very large loss' }, + ]; + + for (const { profit, expected, label } of colorCases) { + it(`${label} (profit=${profit}) → correct color`, () => { + const cd = makeCandlesticks(3); + const exit = buildMarkers( + [makeClosedTrade({ profit, entryBar: 0, exitBar: 1 })], [], cd, + ).find(m => m.shape === 'circle'); + assert.equal(exit.color, expected); + }); + } +}); + +// ── Exit marker — color independent of direction ────────────────────────────── + +describe('exit marker — direction does not affect color', () => { + const directionCases = [ + { direction: 'long', profit: 100, expected: EXPECTED_PROFIT_COLOR, label: 'long + profit' }, + { direction: 'long', profit: -100, expected: EXPECTED_LOSS_COLOR, label: 'long + loss' }, + { direction: 'long', profit: 0, expected: EXPECTED_PROFIT_COLOR, label: 'long + zero' }, + { direction: 'short', profit: 100, expected: EXPECTED_PROFIT_COLOR, label: 'short + profit' }, + { direction: 'short', profit: -100, expected: EXPECTED_LOSS_COLOR, label: 'short + loss' }, + { direction: 'short', profit: 0, expected: EXPECTED_PROFIT_COLOR, label: 'short + zero' }, + ]; + + for (const { direction, profit, expected, label } of directionCases) { + it(label, () => { + const cd = makeCandlesticks(3); + const exit = buildMarkers( + [makeClosedTrade({ direction, profit, entryBar: 0, exitBar: 1 })], [], cd, + ).find(m => m.shape === 'circle'); + assert.equal(exit.color, expected); + }); + } +}); + +// ── Open trades ─────────────────────────────────────────────────────────────── + +describe('open trade markers', () => { + it('produces exactly one marker per open trade (entry only, no exit)', () => { + const cd = makeCandlesticks(5); + assert.equal(buildMarkers([], [makeOpenTrade({ entryBar: 2 })], cd).length, 1); + assert.equal(buildMarkers([], [makeOpenTrade({ entryBar: 2 }), makeOpenTrade({ entryBar: 3 })], cd).length, 2); + }); + + const openShapeCases = [ + { direction: 'long', shape: 'arrowUp', position: 'belowBar' }, + { direction: 'short', shape: 'arrowDown', position: 'aboveBar' }, + ]; + + for (const { direction, shape, position } of openShapeCases) { + it(`open ${direction} entry → shape=${shape}, position=${position}`, () => { + const cd = makeCandlesticks(5); + const [marker] = buildMarkers([], [makeOpenTrade({ direction, entryBar: 2 })], cd); + assert.equal(marker.shape, shape); + assert.equal(marker.position, position); + }); + } + + it('open trade entry color is identical to closed trade entry color for same direction', () => { + const cd = makeCandlesticks(5); + for (const direction of ['long', 'short']) { + const shape = direction === 'long' ? 'arrowUp' : 'arrowDown'; + const openColor = buildMarkers([], [makeOpenTrade({ direction, entryBar: 2 })], cd)[0].color; + const closedEntry = buildMarkers( + [makeClosedTrade({ direction, entryBar: 2, exitBar: 3 })], [], cd, + ).find(m => m.shape === shape); + assert.equal(openColor, closedEntry.color, `color mismatch for direction=${direction}`); + } + }); +}); + +// ── Time resolution ─────────────────────────────────────────────────────────── + +describe('time resolution — barIndex to candlestick time', () => { + const cd = makeCandlesticks(5); + + const boundCases = [ + { barIndex: 0, expected: 1000, label: 'first bar (barIndex=0)' }, + { barIndex: 4, expected: 5000, label: 'last bar (barIndex=length-1)' }, + { barIndex: 2, expected: 3000, label: 'mid-range barIndex' }, + ]; + + for (const { barIndex, expected, label } of boundCases) { + it(`${label} → uses candlestick time`, () => { + const [marker] = buildMarkers([], [makeOpenTrade({ entryBar: barIndex })], cd); + assert.equal(marker.time, expected); + }); + } + + it('negative barIndex falls back to unixTime', () => { + const [marker] = buildMarkers([], [makeOpenTrade({ entryBar: -1, entryTime: 9999 })], cd); + assert.equal(marker.time, 9999); + }); + + it('barIndex >= candlestick length falls back to unixTime', () => { + const [marker] = buildMarkers([], [makeOpenTrade({ entryBar: 5, entryTime: 8888 })], cd); + assert.equal(marker.time, 8888); + }); + + it('OOB barIndex with falsy unixTime produces no marker', () => { + assert.equal(buildMarkers([], [makeOpenTrade({ entryBar: 99, entryTime: 0 })], cd).length, 0); + }); +}); + +// ── Color axis invariants ───────────────────────────────────────────────────── + +describe('color axis invariants — profit and direction axes share palette', () => { + it('exit profit color equals long entry color', () => { + const cd = makeCandlesticks(3); + const longEntry = buildMarkers([makeClosedTrade({ direction: 'long', entryBar: 0, exitBar: 1 })], [], cd).find(m => m.shape === 'arrowUp'); + const profitExit = buildMarkers([makeClosedTrade({ direction: 'short', profit: 1, entryBar: 0, exitBar: 1 })], [], cd).find(m => m.shape === 'circle'); + assert.equal(profitExit.color, longEntry.color); + }); + + it('exit loss color equals short entry color', () => { + const cd = makeCandlesticks(3); + const shortEntry = buildMarkers([makeClosedTrade({ direction: 'short', entryBar: 0, exitBar: 1 })], [], cd).find(m => m.shape === 'arrowDown'); + const lossExit = buildMarkers([makeClosedTrade({ direction: 'long', profit: -1, entryBar: 0, exitBar: 1 })], [], cd).find(m => m.shape === 'circle'); + assert.equal(lossExit.color, shortEntry.color); + }); + + it('profit color and loss color are visually distinct', () => { + const cd = makeCandlesticks(3); + const profitExit = buildMarkers([makeClosedTrade({ profit: 1, entryBar: 0, exitBar: 1 })], [], cd).find(m => m.shape === 'circle'); + const lossExit = buildMarkers([makeClosedTrade({ profit: -1, entryBar: 0, exitBar: 1 })], [], cd).find(m => m.shape === 'circle'); + assert.notEqual(profitExit.color, lossExit.color); + }); +}); diff --git a/runtime/chartdata/chartdata.go b/runtime/chartdata/chartdata.go index 905ee68..dbbad7a 100644 --- a/runtime/chartdata/chartdata.go +++ b/runtime/chartdata/chartdata.go @@ -51,6 +51,7 @@ type UIConfig struct { /* Trade represents a closed trade in chart data */ type Trade struct { EntryID string `json:"entryId"` + ExitID string `json:"exitId,omitempty"` EntryPrice float64 `json:"entryPrice"` EntryBar int `json:"entryBar"` EntryTime int64 `json:"entryTime"` @@ -96,7 +97,7 @@ func (p PlotPoint) MarshalJSON() ([]byte, error) { type Alias PlotPoint var value interface{} if math.IsNaN(p.Value) || math.IsInf(p.Value, 0) { - value = nil // Encode as JSON null + value = nil } else { value = p.Value } @@ -147,8 +148,7 @@ func NewChartData(ctx *context.Context, symbol, timeframe, strategyName string) Indicators: make(map[string]IndicatorSeries), UI: UIConfig{ Panes: map[string]PaneConfig{ - "main": {Height: 400, Fixed: true}, - "indicator": {Height: 200, Fixed: false}, + "main": {Height: 400, Fixed: true}, }, }, } @@ -247,6 +247,7 @@ func (cd *ChartData) AddStrategy(strat *strategy.Strategy, currentPrice float64) for i, t := range closedTrades { trades[i] = Trade{ EntryID: t.EntryID, + ExitID: t.ExitID, EntryPrice: t.EntryPrice, EntryBar: t.EntryBar, EntryTime: t.EntryTime, @@ -283,8 +284,6 @@ func (cd *ChartData) AddStrategy(strat *strategy.Strategy, currentPrice float64) } } -/* ToJSON converts chart data to JSON bytes, with NaN as null */ func (cd *ChartData) ToJSON() ([]byte, error) { - // PlotPoint.MarshalJSON automatically converts NaN to null return json.MarshalIndent(cd, "", " ") } diff --git a/runtime/chartdata/chartdata_test.go b/runtime/chartdata/chartdata_test.go index 7144085..900eac4 100644 --- a/runtime/chartdata/chartdata_test.go +++ b/runtime/chartdata/chartdata_test.go @@ -649,3 +649,203 @@ func TestTradeCommentOmitEmpty(t *testing.T) { t.Error("Trade should not have exitComment field (omitempty)") } } + +/* TestNewChartData_UIConfigInitialPanes verifies that only the main pane is pre-declared — dynamic panes are resolved from indicator data at render time */ +func TestNewChartData_UIConfigInitialPanes(t *testing.T) { + ctx := context.New("TEST", "1h", 1) + cd := NewChartData(ctx, "TEST", "1h", "") + + mainPane, hasMain := cd.UI.Panes["main"] + if !hasMain { + t.Fatal("UIConfig.Panes must contain 'main'") + } + if mainPane.Height != 400 { + t.Errorf("main pane height: want 400, got %d", mainPane.Height) + } + if !mainPane.Fixed { + t.Error("main pane must have Fixed=true") + } + if len(cd.UI.Panes) != 1 { + t.Errorf("NewChartData should pre-declare exactly 1 pane, got %d: %v", len(cd.UI.Panes), cd.UI.Panes) + } +} + +/* TestNewChartData_UIConfigInitialPanesPreservedAfterAddPlots verifies that AddPlots does not mutate UIConfig.Panes — pane layout is the caller's responsibility */ +func TestNewChartData_UIConfigInitialPanesPreservedAfterAddPlots(t *testing.T) { + ctx := context.New("TEST", "1h", 1) + cd := NewChartData(ctx, "TEST", "1h", "") + + collector := output.NewCollector() + now := clock.Now().Unix() + collector.Add("RSI", now, 50.0, map[string]interface{}{"pane": "indicator"}) + collector.Add("MACD", now, 1.5, map[string]interface{}{"pane": "oscillator"}) + cd.AddPlots(collector) + + if len(cd.UI.Panes) != 1 { + t.Errorf("AddPlots must not add entries to UIConfig.Panes, got %d panes: %v", len(cd.UI.Panes), cd.UI.Panes) + } + if _, ok := cd.UI.Panes["main"]; !ok { + t.Error("'main' pane must still be present after AddPlots") + } +} + +/* TestTrade_ExitIDSerializationContract verifies omitempty behaviour: non-empty ExitID appears in JSON; empty string is absent */ +func TestTrade_ExitIDSerializationContract(t *testing.T) { + tests := []struct { + name string + exitID string + wantInJSON bool + }{ + {"non-empty exitId emitted", "take_profit", true}, + {"single-char exitId emitted", "x", true}, + {"numeric-style exitId emitted", "exit_123", true}, + {"empty exitId omitted", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + trade := Trade{ + EntryID: "entry_sig", + ExitID: tt.exitID, + Direction: "long", + Size: 1.0, + } + + b, err := json.Marshal(trade) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(b, &parsed); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + + _, present := parsed["exitId"] + if present != tt.wantInJSON { + if tt.wantInJSON { + t.Errorf("exitId=%q should appear in JSON but was absent", tt.exitID) + } else { + t.Errorf("exitId=%q should be omitted from JSON but was present", tt.exitID) + } + } + + if tt.wantInJSON { + if parsed["exitId"] != tt.exitID { + t.Errorf("exitId: want %q, got %v", tt.exitID, parsed["exitId"]) + } + } + }) + } +} + +/* TestAddStrategy_ClosedTradeIdentifierPropagation verifies that EntryID, ExitID, Direction, and comment fields are faithfully propagated through AddStrategy into JSON */ +func TestAddStrategy_ClosedTradeIdentifierPropagation(t *testing.T) { + tests := []struct { + name string + entryID string + exitID string + direction string + entryComment string + exitComment string + useCloseAll bool + }{ + { + name: "long trade with both IDs and comments", + entryID: "long_entry", + exitID: "take_profit", + direction: strategy.Long, + entryComment: "breakout signal", + exitComment: "target reached", + }, + { + name: "short trade with IDs, no comments", + entryID: "short_entry", + exitID: "stop_loss", + direction: strategy.Short, + entryComment: "", + exitComment: "", + }, + { + name: "trade closed via CloseAll has no exit ID in JSON", + entryID: "signal_a", + exitID: "", + direction: strategy.Long, + useCloseAll: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.New("TEST", "1h", 5) + cd := NewChartData(ctx, "TEST", "1h", "") + + strat := strategy.NewStrategy() + strat.CallWithPyramiding("test", 10000, 0) + + strat.Entry(tt.entryID, tt.direction, 10, tt.entryComment) + strat.OnBarUpdate(1, 100, 1000) + + switch { + case tt.useCloseAll: + strat.CloseAll(110, 2000, tt.exitComment) + case tt.exitID != "": + strat.Exit(tt.exitID, tt.entryID, 110, 2000, tt.exitComment) + default: + strat.Close(tt.entryID, 110, 2000, tt.exitComment) + } + strat.OnBarUpdate(2, 110, 2000) + + cd.AddStrategy(strat, 110) + + if cd.Strategy == nil || len(cd.Strategy.Trades) != 1 { + t.Fatalf("expected 1 closed trade") + } + + trade := cd.Strategy.Trades[0] + + if trade.EntryID != tt.entryID { + t.Errorf("EntryID: want %q, got %q", tt.entryID, trade.EntryID) + } + if trade.Direction != tt.direction { + t.Errorf("Direction: want %q, got %q", tt.direction, trade.Direction) + } + if trade.EntryComment != tt.entryComment { + t.Errorf("EntryComment: want %q, got %q", tt.entryComment, trade.EntryComment) + } + if trade.ExitComment != tt.exitComment { + t.Errorf("ExitComment: want %q, got %q", tt.exitComment, trade.ExitComment) + } + + if tt.exitID != "" && trade.ExitID != tt.exitID { + t.Errorf("ExitID: want %q, got %q", tt.exitID, trade.ExitID) + } + + b, err := json.Marshal(cd.Strategy.Trades) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + var parsed []map[string]interface{} + if err := json.Unmarshal(b, &parsed); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + j := parsed[0] + + if j["entryId"] != tt.entryID { + t.Errorf("JSON entryId: want %q, got %v", tt.entryID, j["entryId"]) + } + if j["direction"] != tt.direction { + t.Errorf("JSON direction: want %q, got %v", tt.direction, j["direction"]) + } + if tt.exitID != "" { + if j["exitId"] != tt.exitID { + t.Errorf("JSON exitId: want %q, got %v", tt.exitID, j["exitId"]) + } + } else { + if _, present := j["exitId"]; present { + t.Errorf("JSON exitId should be absent when empty, but was present") + } + } + }) + } +} From a5cd90386e92b448ffce36246ea41a66fcf5afc6 Mon Sep 17 00:00:00 2001 From: Boris Vasilenko Date: Sat, 11 Apr 2026 15:23:30 +0300 Subject: [PATCH 2/3] Add trade-pair row striping and open-trade styling to TradeRowspanRenderer, fix dead profitClass ternary, and node:test renderer/transformer regression suites --- out/index.html | 5 + out/js/TradeRowspanRenderer.js | 74 +++--- out/tests/TradeRowspanRenderer.test.js | 230 +++++++++++++++++ out/tests/TradeRowspanTransformer.test.js | 287 ++++++++++++++++++++++ 4 files changed, 555 insertions(+), 41 deletions(-) create mode 100644 out/tests/TradeRowspanRenderer.test.js create mode 100644 out/tests/TradeRowspanTransformer.test.js diff --git a/out/index.html b/out/index.html index d7673c4..b531a70 100644 --- a/out/index.html +++ b/out/index.html @@ -175,6 +175,11 @@ font-style: italic; } + .trades-table tbody tr:nth-child(4n+1):not(:only-child), + .trades-table tbody tr:nth-child(4n+2):not(:only-child) { + background-color: rgba(255, 255, 255, 0.03); + } + .no-trades { text-align: center; padding: 2rem; diff --git a/out/js/TradeRowspanRenderer.js b/out/js/TradeRowspanRenderer.js index 0555cce..c3e32b9 100644 --- a/out/js/TradeRowspanRenderer.js +++ b/out/js/TradeRowspanRenderer.js @@ -1,51 +1,43 @@ -/** - * TradeRowspanRenderer - Generates HTML for rowspan table structure - * - * SRP: Single responsibility - HTML generation for rowspan cells - * KISS: Simple row generation logic - * - * Rowspan Structure: - * Row 1 (Entry): Type[rowspan=2] | Entry Label | Entry Date | Entry Signal | Entry Price | Size[rowspan=2] | empty - * Row 2 (Exit): | Exit Label | Exit Date | Exit Signal | Exit Price | | P/L[rowspan=2] - */ export class TradeRowspanRenderer { - constructor() {} + #directionClass(row) { + return row.direction === 'long' ? 'trade-long' : 'trade-short'; + } - /** - * Render single TradeRowData as HTML row with rowspan cells - */ - renderRow(row) { - const directionClass = row.direction === 'long' ? 'trade-long' : 'trade-short'; - const profitClass = row.isOpen - ? (row.profitRaw >= 0 ? 'trade-profit-positive' : 'trade-profit-negative') - : (row.profitRaw >= 0 ? 'trade-profit-positive' : 'trade-profit-negative'); + #profitClass(row) { + if (row.isOpen) return 'trade-open'; + return row.profitRaw >= 0 ? 'trade-profit-positive' : 'trade-profit-negative'; + } + + #openExitRowAttr(row) { + return row.isExitRow() && row.isOpen ? ' class="trade-open"' : ''; + } - let html = ''; + #entryRowHtml(row) { + return [ + `${row.direction.toUpperCase()}`, + `Entry`, + `${row.dateTime}`, + `${row.signal}`, + `${row.price}`, + `${row.size}`, + ].join(''); + } - if (row.isEntryRow()) { - // Entry row: Type (rowspan=2), Entry label, date, signal, price, Size (rowspan=2) - html += `${row.direction.toUpperCase()}`; - html += `Entry`; - html += `${row.dateTime}`; - html += `${row.signal}`; - html += `${row.price}`; - html += `${row.size}`; - } else { - // Exit row: Exit label, date, signal, price, P/L - html += `Exit`; - html += `${row.dateTime}`; - html += `${row.signal}`; - html += `${row.price}`; - html += `${row.profitLoss}`; - } + #exitRowHtml(row) { + return [ + `Exit`, + `${row.dateTime}`, + `${row.signal}`, + `${row.price}`, + `${row.profitLoss}`, + ].join(''); + } - html += ''; - return html; + renderRow(row) { + const cells = row.isEntryRow() ? this.#entryRowHtml(row) : this.#exitRowHtml(row); + return `${cells}`; } - /** - * Render array of TradeRowData as complete HTML - */ renderRows(rows) { return rows.map(row => this.renderRow(row)).join('\n'); } diff --git a/out/tests/TradeRowspanRenderer.test.js b/out/tests/TradeRowspanRenderer.test.js new file mode 100644 index 0000000..d3eccb1 --- /dev/null +++ b/out/tests/TradeRowspanRenderer.test.js @@ -0,0 +1,230 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { TradeRowspanRenderer } from '../js/TradeRowspanRenderer.js'; +import { TradeRowData } from '../js/TradeRowData.js'; + +// ── Row fixtures ─────────────────────────────────────────────────────────────── + +function makeEntryRow(overrides = {}) { + return new TradeRowData({ + tradeNumber: 1, + rowType: 'entry', + dateTime: '2024-01-01', + signal: 'buy signal', + price: '$100.00', + size: '1.00', + profitLoss: '', + direction: 'long', + isOpen: false, + profitRaw: 0, + ...overrides, + }); +} + +function makeExitRow(overrides = {}) { + return new TradeRowData({ + tradeNumber: 1, + rowType: 'exit', + dateTime: '2024-01-02', + signal: 'sell signal', + price: '$110.00', + size: '', + profitLoss: '+$10.00', + direction: 'long', + isOpen: false, + profitRaw: 10, + ...overrides, + }); +} + +// ── Helper ───────────────────────────────────────────────────────────────────── + +const renderer = new TradeRowspanRenderer(); + +function render(row) { + return renderer.renderRow(row); +} + +// ── Entry row — structural invariants ───────────────────────────────────────── + +describe('renderRow() — entry row structure', () => { + it('row element has no class attribute', () => { + assert.ok(render(makeEntryRow()).startsWith('')); + }); + + it('open entry row element has no class attribute', () => { + assert.ok(render(makeEntryRow({ isOpen: true })).startsWith('')); + }); + + it('Type cell has rowspan="2"', () => { + assert.ok(render(makeEntryRow()).includes(' { + const html = render(makeEntryRow({ size: '2.50' })); + assert.ok(html.includes('rowspan="2">2.50<')); + }); + + it('row contains exactly 2 rowspan="2" cells', () => { + const html = render(makeEntryRow()); + assert.equal((html.match(/rowspan="2"/g) || []).length, 2); + }); + + it('contains "Entry" label cell', () => { + assert.ok(render(makeEntryRow()).includes('Entry')); + }); +}); + +// ── Entry row — cell value passthrough ──────────────────────────────────────── + +describe('renderRow() — entry row cell value passthrough', () => { + it('dateTime value appears in output', () => { + assert.ok(render(makeEntryRow({ dateTime: 'Jan 1, 2024' })).includes('Jan 1, 2024')); + }); + + it('signal value appears in output', () => { + assert.ok(render(makeEntryRow({ signal: 'my-entry-signal' })).includes('my-entry-signal')); + }); + + it('price value appears in output', () => { + assert.ok(render(makeEntryRow({ price: '$123.45' })).includes('$123.45')); + }); + + it('size value appears in output', () => { + assert.ok(render(makeEntryRow({ size: '9.99' })).includes('9.99')); + }); + + it('direction value (uppercased) appears in Type cell', () => { + for (const [direction, expected] of [['long', 'LONG'], ['short', 'SHORT']]) { + assert.ok(render(makeEntryRow({ direction })).includes(expected), `direction=${direction}`); + } + }); +}); + +// ── Entry row — direction class ──────────────────────────────────────────────── + +describe('renderRow() — direction class on Type cell', () => { + const cases = [ + { direction: 'long', expected: 'trade-long' }, + { direction: 'short', expected: 'trade-short' }, + ]; + + for (const { direction, expected } of cases) { + it(`${direction} → Type cell class="${expected}"`, () => { + assert.ok(render(makeEntryRow({ direction })).includes(`class="${expected}"`)); + }); + } +}); + +// ── Exit row — structural invariants ────────────────────────────────────────── + +describe('renderRow() — exit row structure', () => { + it('closed exit row element has no class attribute', () => { + assert.ok(render(makeExitRow({ isOpen: false })).startsWith('')); + }); + + it('exit row has no rowspan attribute', () => { + assert.ok(!render(makeExitRow()).includes('rowspan')); + }); + + it('contains "Exit" label cell', () => { + assert.ok(render(makeExitRow()).includes('Exit')); + }); +}); + +// ── Exit row — cell value passthrough ───────────────────────────────────────── + +describe('renderRow() — exit row cell value passthrough', () => { + it('dateTime value appears in output', () => { + assert.ok(render(makeExitRow({ dateTime: 'Jan 2, 2024' })).includes('Jan 2, 2024')); + }); + + it('signal value appears in output', () => { + assert.ok(render(makeExitRow({ signal: 'my-exit-signal' })).includes('my-exit-signal')); + }); + + it('price value appears in output', () => { + assert.ok(render(makeExitRow({ price: '$999.00' })).includes('$999.00')); + }); + + it('profitLoss value appears in P/L cell', () => { + assert.ok(render(makeExitRow({ profitLoss: '+$42.00' })).includes('+$42.00')); + }); +}); + +// ── Closed exit row — P/L cell class ────────────────────────────────────────── + +describe('renderRow() — closed exit row P/L cell class', () => { + const cases = [ + { label: 'positive profit', profitRaw: 10, expected: 'trade-profit-positive' }, + { label: 'zero profit', profitRaw: 0, expected: 'trade-profit-positive' }, + { label: 'fractional profit', profitRaw: 0.01, expected: 'trade-profit-positive' }, + { label: 'very large profit', profitRaw: 1e9, expected: 'trade-profit-positive' }, + { label: 'negative profit', profitRaw: -0.01, expected: 'trade-profit-negative' }, + { label: 'fractional loss', profitRaw: -10, expected: 'trade-profit-negative' }, + { label: 'very large loss', profitRaw: -1e9, expected: 'trade-profit-negative' }, + ]; + + for (const { label, profitRaw, expected } of cases) { + it(`${label} (profitRaw=${profitRaw}) → P/L cell class="${expected}"`, () => { + assert.ok(render(makeExitRow({ profitRaw, isOpen: false })).includes(``)); + }); + } +}); + +// ── Open exit row ───────────────────────────────────────────────────────────── + +describe('renderRow() — open exit row', () => { + const openProfitValues = [100, -100, 0]; + + it('row element carries class="trade-open"', () => { + assert.ok(render(makeExitRow({ isOpen: true })).startsWith('')); + }); + + it('P/L cell carries class="trade-open" regardless of profitRaw sign', () => { + for (const profitRaw of openProfitValues) { + assert.ok( + render(makeExitRow({ isOpen: true, profitRaw })).includes(''), + `profitRaw=${profitRaw}`, + ); + } + }); + + it('P/L cell does not carry profit/loss class regardless of profitRaw sign', () => { + for (const profitRaw of openProfitValues) { + const html = render(makeExitRow({ isOpen: true, profitRaw })); + assert.ok(!html.includes('trade-profit-positive'), `profitRaw=${profitRaw}`); + assert.ok(!html.includes('trade-profit-negative'), `profitRaw=${profitRaw}`); + } + }); + + it('open exit styling is invariant across trade directions', () => { + for (const direction of ['long', 'short']) { + const html = render(makeExitRow({ isOpen: true, direction })); + assert.ok(html.startsWith(''), `direction=${direction} tr`); + assert.ok(html.includes(''), `direction=${direction} td`); + } + }); +}); + +// ── renderRows() ─────────────────────────────────────────────────────────────── + +describe('renderRows() — batch output', () => { + it('empty array produces empty string', () => { + assert.equal(renderer.renderRows([]), ''); + }); + + it('N rows produces N elements joined by newlines', () => { + const rows = [makeEntryRow(), makeExitRow(), makeEntryRow({ tradeNumber: 2 }), makeExitRow({ tradeNumber: 2 })]; + const html = renderer.renderRows(rows); + assert.equal((html.match(/ { + const entryHtml = render(makeEntryRow()); + const exitHtml = render(makeExitRow()); + const html = renderer.renderRows([makeEntryRow(), makeExitRow()]); + assert.ok(html.indexOf(entryHtml) < html.indexOf(exitHtml)); + }); +}); diff --git a/out/tests/TradeRowspanTransformer.test.js b/out/tests/TradeRowspanTransformer.test.js new file mode 100644 index 0000000..211d3c3 --- /dev/null +++ b/out/tests/TradeRowspanTransformer.test.js @@ -0,0 +1,287 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { TradeRowspanTransformer } from '../js/TradeRowspanTransformer.js'; + +// ── Stub formatter ───────────────────────────────────────────────────────────── + +function makeFormatter(overrides = {}) { + return { + calculateUnrealizedProfit: (_trade, _price) => 0, + getTradeDate: (_trade, isEntry) => isEntry ? 'entry-date' : 'exit-date', + formatPrice: (price) => `$${(price ?? 0).toFixed(2)}`, + formatProfit: (value) => `profit:${value}`, + ...overrides, + }; +} + +// ── Trade fixtures ───────────────────────────────────────────────────────────── + +function makeClosedTrade(overrides = {}) { + return { + direction: 'long', + status: 'closed', + entryPrice: 100, + exitPrice: 110, + size: 1, + profit: 10, + entryId: 'eid', + exitId: 'xid', + ...overrides, + }; +} + +function makeOpenTrade(overrides = {}) { + return { + direction: 'long', + status: 'open', + entryPrice: 100, + size: 1, + entryId: 'eid', + ...overrides, + }; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function transform(trade, { formatter, currentPrice = null, tradeNumber = 1 } = {}) { + return new TradeRowspanTransformer(formatter ?? makeFormatter()) + .transformTrade(trade, tradeNumber, currentPrice); +} + +function transformAll(trades, { formatter, currentPrice = null } = {}) { + return new TradeRowspanTransformer(formatter ?? makeFormatter()) + .transformTrades(trades, currentPrice); +} + +function assertSignalFallback(rowExtractor, signalCases) { + for (const { label, overrides, expected } of signalCases) { + it(label, () => { + const rows = transform(makeClosedTrade(overrides)); + assert.equal(rowExtractor(rows).signal, expected); + }); + } +} + +const ENTRY_ROW = ([entry]) => entry; +const EXIT_ROW = ([, exit]) => exit; + +const SIGNAL_CASES = (commentKey, idKey) => [ + { label: `${commentKey} takes precedence over ${idKey}`, overrides: { [commentKey]: 'COMMENT', [idKey]: 'ID' }, expected: 'COMMENT' }, + { label: `${idKey} used when ${commentKey} absent`, overrides: { [idKey]: 'ID' }, expected: 'ID' }, + { label: 'empty string when neither present', overrides: { [commentKey]: undefined, [idKey]: undefined }, expected: '' }, +]; + +// ── Row count and types ──────────────────────────────────────────────────────── + +describe('transformTrade() — row count and types', () => { + it('produces exactly 2 rows per trade regardless of status', () => { + assert.equal(transform(makeClosedTrade()).length, 2); + assert.equal(transform(makeOpenTrade()).length, 2); + }); + + it('first row is always entry, second is always exit', () => { + for (const trade of [makeClosedTrade(), makeOpenTrade()]) { + const [entry, exit] = transform(trade); + assert.ok(entry.isEntryRow(), 'first row must be entry'); + assert.ok(exit.isExitRow(), 'second row must be exit'); + } + }); + + it('tradeNumber propagates to both rows', () => { + const [entry, exit] = transform(makeClosedTrade(), { tradeNumber: 7 }); + assert.equal(entry.tradeNumber, 7); + assert.equal(exit.tradeNumber, 7); + }); +}); + +// ── Entry row fields ─────────────────────────────────────────────────────────── + +describe('transformTrade() — entry row fields', () => { + it('direction propagates to entry row for both trade directions', () => { + for (const direction of ['long', 'short']) { + const [entry] = transform(makeClosedTrade({ direction })); + assert.equal(entry.direction, direction); + } + }); + + it('direction propagates to exit row for both trade directions', () => { + for (const direction of ['long', 'short']) { + const [, exit] = transform(makeClosedTrade({ direction })); + assert.equal(exit.direction, direction); + } + }); + + it('entry row dateTime delegates to formatter.getTradeDate(trade, true)', () => { + const formatter = makeFormatter({ getTradeDate: (_t, isEntry) => isEntry ? 'ENTRY-TS' : 'EXIT-TS' }); + const [entry] = transform(makeClosedTrade(), { formatter }); + assert.equal(entry.dateTime, 'ENTRY-TS'); + }); + + it('entry row price delegates to formatter.formatPrice(entryPrice)', () => { + const formatter = makeFormatter({ formatPrice: (p) => `FMT:${p}` }); + const [entry] = transform(makeClosedTrade({ entryPrice: 999 }), { formatter }); + assert.equal(entry.price, 'FMT:999'); + }); + + it('entry row size is trade.size.toFixed(2)', () => { + for (const [size, expected] of [[2.5, '2.50'], [1, '1.00'], [0, '0.00'], [10.123, '10.12']]) { + const [entry] = transform(makeClosedTrade({ size })); + assert.equal(entry.size, expected, `size=${size}`); + } + }); + + it('entry row isOpen is always false regardless of trade status', () => { + const [closedEntry] = transform(makeClosedTrade()); + const [openEntry] = transform(makeOpenTrade()); + assert.equal(closedEntry.isOpen, false); + assert.equal(openEntry.isOpen, false); + }); + + it('entry row P/L fields are always zeroed regardless of trade profit', () => { + const [entry] = transform(makeClosedTrade({ profit: 9999 })); + assert.equal(entry.profitLoss, ''); + assert.equal(entry.profitRaw, 0); + }); +}); + +// ── Entry signal fallback chain ──────────────────────────────────────────────── + +describe('transformTrade() — entry signal fallback chain (entryComment > entryId > "")', () => { + assertSignalFallback(ENTRY_ROW, SIGNAL_CASES('entryComment', 'entryId')); +}); + +// ── Closed exit row fields ───────────────────────────────────────────────────── + +describe('transformTrade() — closed exit row fields', () => { + it('exit row isOpen is false', () => { + const [, exit] = transform(makeClosedTrade()); + assert.equal(exit.isOpen, false); + }); + + it('exit row dateTime delegates to formatter.getTradeDate(trade, false)', () => { + const formatter = makeFormatter({ getTradeDate: (_t, isEntry) => isEntry ? 'ENTRY-TS' : 'EXIT-TS' }); + const [, exit] = transform(makeClosedTrade(), { formatter }); + assert.equal(exit.dateTime, 'EXIT-TS'); + }); + + it('exit row profitRaw equals trade.profit', () => { + for (const profit of [42, -42, 0, 0.001, -0.001]) { + const [, exit] = transform(makeClosedTrade({ profit })); + assert.equal(exit.profitRaw, profit, `profit=${profit}`); + } + }); + + it('exit row profitLoss delegates to formatter.formatProfit(trade.profit)', () => { + const formatter = makeFormatter({ formatProfit: (v) => `FORMATTED:${v}` }); + const [, exit] = transform(makeClosedTrade({ profit: 55 }), { formatter }); + assert.equal(exit.profitLoss, 'FORMATTED:55'); + }); + + it('exit row price delegates to formatter.formatPrice(exitPrice)', () => { + const formatter = makeFormatter({ formatPrice: (p) => `FMT:${p}` }); + const [, exit] = transform(makeClosedTrade({ exitPrice: 222 }), { formatter }); + assert.equal(exit.price, 'FMT:222'); + }); + + it('exit row price falls back to 0 when exitPrice is null', () => { + const formatter = makeFormatter({ formatPrice: (p) => `FMT:${p}` }); + const [, exit] = transform(makeClosedTrade({ exitPrice: null }), { formatter }); + assert.equal(exit.price, 'FMT:0'); + }); +}); + +// ── Exit signal fallback chain ───────────────────────────────────────────────── + +describe('transformTrade() — exit signal fallback chain (exitComment > exitId > "")', () => { + assertSignalFallback(EXIT_ROW, SIGNAL_CASES('exitComment', 'exitId')); +}); + +// ── Open trade exit row ──────────────────────────────────────────────────────── + +describe('transformTrade() — open trade exit row', () => { + it('exit row isOpen is true', () => { + const [, exit] = transform(makeOpenTrade()); + assert.equal(exit.isOpen, true); + }); + + it('exit row dateTime is literal "Open"', () => { + const [, exit] = transform(makeOpenTrade()); + assert.equal(exit.dateTime, 'Open'); + }); + + it('exit row signal is always empty string regardless of exitComment/exitId presence', () => { + const [, exit] = transform(makeOpenTrade({ exitComment: 'x', exitId: 'y' })); + assert.equal(exit.signal, ''); + }); + + it('exit row profitRaw delegates to formatter.calculateUnrealizedProfit(trade, currentPrice)', () => { + let capturedArgs = null; + const formatter = makeFormatter({ + calculateUnrealizedProfit: (trade, price) => { capturedArgs = { trade, price }; return 77; }, + }); + const trade = makeOpenTrade(); + const [, exit] = transform(trade, { formatter, currentPrice: 200 }); + assert.equal(exit.profitRaw, 77); + assert.equal(capturedArgs.price, 200); + assert.equal(capturedArgs.trade, trade); + }); + + it('exit row price uses currentPrice when provided', () => { + const formatter = makeFormatter({ formatPrice: (p) => `FMT:${p}` }); + const [, exit] = transform(makeOpenTrade({ entryPrice: 50 }), { formatter, currentPrice: 120 }); + assert.equal(exit.price, 'FMT:120'); + }); + + it('exit row price falls back to entryPrice when currentPrice is null', () => { + const formatter = makeFormatter({ formatPrice: (p) => `FMT:${p}` }); + const [, exit] = transform(makeOpenTrade({ entryPrice: 50 }), { formatter, currentPrice: null }); + assert.equal(exit.price, 'FMT:50'); + }); + + it('exit row price uses 0 as currentPrice (not entryPrice) when currentPrice is 0', () => { + const formatter = makeFormatter({ formatPrice: (p) => `FMT:${p}` }); + const [, exit] = transform(makeOpenTrade({ entryPrice: 50 }), { formatter, currentPrice: 0 }); + assert.equal(exit.price, 'FMT:0'); + }); +}); + +// ── transformTrades() — multi-trade composition ──────────────────────────────── + +describe('transformTrades() — ordering and row count', () => { + it('produces 2N rows for N trades', () => { + for (const n of [0, 1, 3, 5]) { + const trades = Array.from({ length: n }, () => makeClosedTrade()); + assert.equal(transformAll(trades).length, n * 2, `expected ${n * 2} rows for ${n} trades`); + } + }); + + it('row order is entry,exit,entry,exit,... preserving input trade order', () => { + const trades = [makeClosedTrade({ entryId: 'A' }), makeClosedTrade({ entryId: 'B' })]; + const rows = transformAll(trades); + assert.ok(rows[0].isEntryRow()); + assert.equal(rows[0].signal, 'A'); + assert.ok(rows[1].isExitRow()); + assert.ok(rows[2].isEntryRow()); + assert.equal(rows[2].signal, 'B'); + assert.ok(rows[3].isExitRow()); + }); + + it('tradeNumber is 1-based and increments per trade across both its rows', () => { + const trades = [makeClosedTrade(), makeClosedTrade(), makeClosedTrade()]; + const rows = transformAll(trades); + assert.equal(rows[0].tradeNumber, 1); + assert.equal(rows[1].tradeNumber, 1); + assert.equal(rows[2].tradeNumber, 2); + assert.equal(rows[3].tradeNumber, 2); + assert.equal(rows[4].tradeNumber, 3); + assert.equal(rows[5].tradeNumber, 3); + }); + + it('isOpen on exit rows reflects each trade status in mixed input', () => { + const trades = [makeClosedTrade(), makeOpenTrade(), makeClosedTrade()]; + const rows = transformAll(trades); + assert.equal(rows[1].isOpen, false); + assert.equal(rows[3].isOpen, true); + assert.equal(rows[5].isOpen, false); + }); +}); From e494d2589e27f74a9e245e7ef5f30f41d07756aa Mon Sep 17 00:00:00 2001 From: Boris Vasilenko Date: Sat, 18 Apr 2026 09:46:33 +0300 Subject: [PATCH 3/3] Add fullscreen toggle with height-restore via inline style.height, draggable pane resize handles with PaneResizeCalculator/Handle/Controller, sort toggle and trade-pair sub-row ordering, extract CSS design tokens to tokens.css/styles.css with zero hardcoded literals, fix table hover/muted-text/focus-visible, delete _setupEventListeners leak via WindowResizeHandler, and node:test regression suites for all new modules --- .interface-design/system.md | 129 +++++++ out/css/styles.css | 270 +++++++++++++ out/css/tokens.css | 48 +++ out/index.html | 182 +-------- out/js/ChartApplication.js | 164 +++++--- out/js/ChartManager.js | 6 +- out/js/FullscreenToggle.js | 92 +++++ out/js/PaneManager.js | 11 +- out/js/PaneResizeCalculator.js | 10 + out/js/PaneResizeController.js | 25 ++ out/js/PaneResizeHandle.js | 70 ++++ out/js/SortDirection.js | 4 + out/js/TradeRowData.js | 35 +- out/js/TradeRowspanRenderer.js | 44 ++- out/js/TradeRowspanTransformer.js | 67 ++-- out/js/TradeSortToggle.js | 41 ++ out/js/WindowResizeHandler.js | 12 + out/tests/ChartManager.test.js | 190 +++++++++ out/tests/DesignSystem.test.js | 201 ++++++++++ out/tests/FullscreenToggle.test.js | 449 ++++++++++++++++++++++ out/tests/PaneManager.test.js | 173 +++++++++ out/tests/PaneResizeCalculator.test.js | 106 +++++ out/tests/PaneResizeController.test.js | 171 ++++++++ out/tests/PaneResizeHandle.test.js | 266 +++++++++++++ out/tests/TradeRowspanRenderer.test.js | 311 ++++++++------- out/tests/TradeRowspanTransformer.test.js | 384 ++++++++++++------ out/tests/WindowResizeHandler.test.js | 197 ++++++++++ 27 files changed, 3096 insertions(+), 562 deletions(-) create mode 100644 .interface-design/system.md create mode 100644 out/css/styles.css create mode 100644 out/css/tokens.css create mode 100644 out/js/FullscreenToggle.js create mode 100644 out/js/PaneResizeCalculator.js create mode 100644 out/js/PaneResizeController.js create mode 100644 out/js/PaneResizeHandle.js create mode 100644 out/js/SortDirection.js create mode 100644 out/js/TradeSortToggle.js create mode 100644 out/js/WindowResizeHandler.js create mode 100644 out/tests/ChartManager.test.js create mode 100644 out/tests/DesignSystem.test.js create mode 100644 out/tests/FullscreenToggle.test.js create mode 100644 out/tests/PaneManager.test.js create mode 100644 out/tests/PaneResizeCalculator.test.js create mode 100644 out/tests/PaneResizeController.test.js create mode 100644 out/tests/PaneResizeHandle.test.js create mode 100644 out/tests/WindowResizeHandler.test.js diff --git a/.interface-design/system.md b/.interface-design/system.md new file mode 100644 index 0000000..7e7ce4d --- /dev/null +++ b/.interface-design/system.md @@ -0,0 +1,129 @@ +# Design System + +## File Structure + +``` +out/css/tokens.css — CSS custom properties (single source of truth for all tokens) +out/css/styles.css — component styles (@import ./tokens.css; zero hardcoded values) +out/index.html — +``` + +Dependency graph: `index.html → styles.css → tokens.css` + +## Tokens (`out/css/tokens.css`) + +### Surfaces +| Token | Value | Usage | +|---|---|---| +| `--surface-base` | `#0f172a` | Deepest bg: body gradient start, table `` | +| `--surface-card` | `#1a2035` | Section bg: `.trades-section`, `.info` | +| `--surface-raised` | `#1e293b` | Card bg: `#container`, `.chart-container` | +| `--surface-raised-alpha` | `rgba(30,41,59,0.85)` | Fullscreen toggle button bg | +| `--surface-hover` | `rgba(255,255,255,0.06)` | Row hover — visible on any dark surface | + +### Borders +| Token | Value | Usage | +|---|---|---| +| `--border-subtle` | `#2d3748` | Inner dividers, row borders, section internals | +| `--border-default` | `#334155` | Outer borders: container, chart, buttons | + +### Text +| Token | Value | Usage | +|---|---|---| +| `--text-primary` | `#cbd5e1` | Body text, card text | +| `--text-secondary` | `#94a3b8` | ``, summary, button default label | +| `--text-muted` | `#6b7280` | Timestamp, `.no-trades` placeholder | + +### Accent +| Token | Value | Usage | +|---|---|---| +| `--color-accent` | `#5eead4` | Section headings, hover state, focus ring | +| `--color-accent-alt` | `#2dd4bf` | h1 gradient end | +| `--color-accent-subtle` | `rgba(94,234,212,0.25)` | Pane resize handle hover fill | + +### Actions +| Token | Value | Usage | +|---|---|---| +| `--color-action` | `#2563eb` | `.refresh-btn` bg | +| `--color-action-hover` | `#1d4ed8` | `.refresh-btn:hover` bg | + +### Semantic +| Token | Value | Usage | +|---|---|---| +| `--color-long` | `#10b981` | Long direction, positive P/L | +| `--color-short` | `#ef4444` | Short direction, negative P/L | +| `--color-open` | `#3b82f6` | Open / in-progress trades | + +### Trade Table +| Token | Value | Usage | +|---|---|---| +| `--stripe-tint` | `rgba(255,255,255,0.03)` | Alternating trade-pair row tint | + +### Typography +| Token | Value | Usage | +|---|---|---| +| `--font-ui` | `'Segoe UI', system-ui, sans-serif` | All text | + +### Border Radius +| Token | Value | Usage | +|---|---|---| +| `--radius-md` | `6px` | Most elements | +| `--radius-lg` | `8px` | Outer `#container` only | + +### Shadows +| Token | Value | Usage | +|---|---|---| +| `--shadow-container` | `0 4px 6px -1px rgba(0,0,0,0.2)` | `#container` only | +| `--shadow-text` | `0 2px 4px rgba(0,0,0,0.3)` | `h1` text-shadow | + +### Transitions +| Token | Value | Usage | +|---|---|---| +| `--transition-duration` | `0.2s` | All interactive elements | + +## Spacing Scale +Rem-based: `0.5rem`, `0.75rem`, `1rem`, `1.25rem`, `1.5rem`, `2rem`, `2.5rem` +Pixel exceptions: `8px` (absolute inset positioning only) + +## Font Scale +| Size | Usage | +|---|---| +| `2.5rem` | `h1` page title | +| `1.5rem` | `h2` section title | +| `1rem` | Body, action button | +| `0.875rem` | Table, timestamp, secondary labels, compact buttons | + +Font weight `600`: profit values, table headers. + +## Component Patterns + +### Filled button (`.refresh-btn`) +- `background-color: var(--color-action)` → hover: `var(--color-action-hover)` +- `padding: 0.75rem 1.5rem`, `border-radius: var(--radius-md)`, `font-size: 1rem` +- `:focus-visible`: `outline: 2px solid var(--color-accent); outline-offset: 2px` +- `:active`: `transform: scale(0.97)` + +### Ghost/outline button (`.sort-toggle-btn`, `.fullscreen-toggle-btn`) +- `background: transparent`, `border: 1px solid var(--border-default)`, `color: var(--text-secondary)` +- Hover: `border-color: var(--color-accent); color: var(--color-accent)` +- `:focus-visible`: same accent outline ring as filled button +- `:active`: `transform: scale(0.97)` + +### Section card (`.trades-section`, `.info`) +- `background-color: var(--surface-card)`, `border: 1px solid var(--border-subtle)`, `border-radius: var(--radius-md)`, `padding: 1rem` + +### Section header (`.trades-header`) +- `display: flex`, `justify-content: space-between`, `align-items: center` +- `margin-bottom: 1rem`, `padding-bottom: 0.5rem`, `border-bottom: 1px solid var(--border-subtle)` +- Title: `color: var(--color-accent)` + +## Chart-specific (JS, not CSS) +Chart colors are LightweightCharts API options passed in `index.html` inline ` - diff --git a/out/js/ChartApplication.js b/out/js/ChartApplication.js index 8cb8e42..3a80c89 100644 --- a/out/js/ChartApplication.js +++ b/out/js/ChartApplication.js @@ -1,26 +1,45 @@ -import { ConfigLoader } from './ConfigLoader.js'; -import { PaneAssigner } from './PaneAssigner.js'; -import { PaneManager } from './PaneManager.js'; -import { SeriesRouter } from './SeriesRouter.js'; -import { ChartManager } from './ChartManager.js'; -import { TradeDataFormatter, formatCurrency } from './TradeTable.js'; +import { ConfigLoader } from './ConfigLoader.js'; +import { PaneAssigner } from './PaneAssigner.js'; +import { PaneManager } from './PaneManager.js'; +import { SeriesRouter } from './SeriesRouter.js'; +import { ChartManager } from './ChartManager.js'; +import { TradeDataFormatter, + formatCurrency } from './TradeTable.js'; import { TradeRowspanTransformer } from './TradeRowspanTransformer.js'; -import { TradeRowspanRenderer } from './TradeRowspanRenderer.js'; -import { TradeMarkerBuilder } from './TradeMarkerBuilder.js'; -import { TimeIndexBuilder } from './TimeIndexBuilder.js'; -import { PlotOffsetTransformer } from './PlotOffsetTransformer.js'; -import { SeriesDataMapper } from './SeriesDataMapper.js'; -import { LineStyleConverter } from './LineStyleConverter.js'; +import { TradeRowspanRenderer } from './TradeRowspanRenderer.js'; +import { TradeMarkerBuilder } from './TradeMarkerBuilder.js'; +import { TimeIndexBuilder } from './TimeIndexBuilder.js'; +import { PlotOffsetTransformer } from './PlotOffsetTransformer.js'; +import { SeriesDataMapper } from './SeriesDataMapper.js'; +import { LineStyleConverter } from './LineStyleConverter.js'; +import { TradeSortToggle } from './TradeSortToggle.js'; +import { FullscreenToggle } from './FullscreenToggle.js'; +import { SortDirection } from './SortDirection.js'; +import { PaneResizeController } from './PaneResizeController.js'; +import { WindowResizeHandler } from './WindowResizeHandler.js'; export class ChartApplication { constructor(chartOptions) { - this.chartOptions = chartOptions; - this.paneManager = null; - this.seriesMap = {}; - this.timeIndexBuilder = new TimeIndexBuilder(); + this.chartOptions = chartOptions; + this.paneManager = null; + this.seriesMap = {}; + this.timeIndexBuilder = new TimeIndexBuilder(); this.plotOffsetTransformer = new PlotOffsetTransformer(this.timeIndexBuilder); - this.seriesDataMapper = new SeriesDataMapper(); - this.tradeMarkerBuilder = new TradeMarkerBuilder(); + this.seriesDataMapper = new SeriesDataMapper(); + this.tradeMarkerBuilder = new TradeMarkerBuilder(); + + this.sortToggle = new TradeSortToggle( + (direction) => this._rerenderTrades(direction), + SortDirection.DESC, + ); + + this.fullscreenToggle = new FullscreenToggle( + document.querySelector('.chart-container'), + () => this._onResize(), + ); + + this.resizeController = new PaneResizeController(); + this.windowResizeHandler = new WindowResizeHandler(() => this._onResize()); } async initialize() { @@ -33,25 +52,32 @@ export class ChartApplication { const paneConfig = this._buildPaneConfig(indicatorsWithPanes, data.ui?.panes); this.paneManager = new PaneManager(this.chartOptions); this._createCharts(paneConfig); + this.resizeController.mount(this.paneManager.getAllPanes()); - const seriesRouter = new SeriesRouter(this.paneManager, this.seriesMap); + const seriesRouter = new SeriesRouter(this.paneManager, this.seriesMap); const candlestickData = this._routeAndLoadSeries(indicatorsWithPanes, data, seriesRouter); this._loadTradeMarkers(data.strategy, candlestickData); this._loadTrades(data.strategy, data.candlestick); this.updateTimestamp(data.metadata); - this._setupEventListeners(); this.paneManager.synchronizeTimeScales(); - setTimeout(() => ChartManager.fitContent(this.paneManager.getAllCharts()), 50); + setTimeout(() => { + ChartManager.fitContent(this.paneManager.getAllCharts()); + ChartManager.handleResize( + this.paneManager.getAllCharts(), + this.paneManager.getAllContainers(), + ); + }, 50); } async refresh() { + this.resizeController.destroy(); this.paneManager.getAllCharts().forEach(chart => chart.remove()); this.paneManager.dynamicPanes.forEach(({ container }) => container.remove()); this.paneManager.mainPane.container.innerHTML = ''; - this.seriesMap = {}; + this.seriesMap = {}; this.paneManager = null; await this.initialize(); @@ -59,10 +85,10 @@ export class ChartApplication { updateMetadataDisplay(metadata) { if (!metadata) return; - document.getElementById('chart-title').textContent = metadata.title || 'Financial Chart'; - document.getElementById('symbol-display').textContent = metadata.symbol || 'Unknown'; + document.getElementById('chart-title').textContent = metadata.title || 'Financial Chart'; + document.getElementById('symbol-display').textContent = metadata.symbol || 'Unknown'; document.getElementById('timeframe-display').textContent = metadata.timeframe || 'Unknown'; - document.getElementById('strategy-display').textContent = metadata.strategy || 'Unknown'; + document.getElementById('strategy-display').textContent = metadata.strategy || 'Unknown'; } updateTimestamp(metadata) { @@ -90,6 +116,9 @@ export class ChartApplication { _createCharts(paneConfig) { const mainContainer = document.getElementById('main-chart'); this.paneManager.createMainPane(mainContainer, paneConfig.main); + + this.fullscreenToggle.mount(); + for (const [paneName, config] of Object.entries(paneConfig)) { if (paneName !== 'main') { this.paneManager.createDynamicPane(paneName, config); @@ -101,10 +130,10 @@ export class ChartApplication { const mainChart = this.paneManager.mainPane.chart; this.seriesMap.candlestick = ChartManager.addCandlestickSeries(mainChart, { - upColor: '#26a69a', - downColor: '#ef5350', + upColor: '#26a69a', + downColor: '#ef5350', borderVisible: false, - wickUpColor: '#26a69a', + wickUpColor: '#26a69a', wickDownColor: '#ef5350', }); @@ -115,17 +144,17 @@ export class ChartApplication { this.seriesMap.candlestick.setData(candlestickData); for (const [key, indicator] of Object.entries(indicatorsWithPanes)) { - const color = indicator.style?.color || '#2196F3'; + const color = indicator.style?.color || '#2196F3'; const seriesConfig = { color, - lineWidth: indicator.style?.lineWidth || 2, - title: indicator.title || key, - chart: indicator.pane || 'main', - style: indicator.style?.plotStyle || 'line', - priceLineVisible: false, - lastValueVisible: true, + lineWidth: indicator.style?.lineWidth || 2, + title: indicator.title || key, + chart: indicator.pane || 'main', + style: indicator.style?.plotStyle || 'line', + priceLineVisible: false, + lastValueVisible: true, crosshairMarkerVisible: true, - lineStyle: LineStyleConverter.toNumeric(indicator.style?.lineStyle), + lineStyle: LineStyleConverter.toNumeric(indicator.style?.lineStyle), }; const series = seriesRouter.routeSeries(key, seriesConfig, ChartManager); @@ -134,9 +163,9 @@ export class ChartApplication { continue; } - const offset = indicator.offset || 0; - const shifted = this.plotOffsetTransformer.transform(indicator.data, offset, candlestickData); - const colored = this.seriesDataMapper.applyColorToData(shifted, color); + const offset = indicator.offset || 0; + const shifted = this.plotOffsetTransformer.transform(indicator.data, offset, candlestickData); + const colored = this.seriesDataMapper.applyColorToData(shifted, color); const processed = window.adaptLineSeriesData(colored); if (processed.length > 0) { @@ -152,11 +181,11 @@ export class ChartApplication { if (key !== 'Buy Potential' && key !== 'Sell Potential') return; const valid = processedData.filter(p => p.value != null && !isNaN(p.value)); if (!valid.length) return; - const contextBars = 50; - const barInterval = 3600; + const contextBars = 50; + const barInterval = 3600; this.paneManager.mainPane.chart.timeScale().setVisibleRange({ - from: valid[0].time - contextBars * barInterval, - to: valid[valid.length - 1].time + contextBars * barInterval, + from: valid[0].time - contextBars * barInterval, + to: valid[valid.length - 1].time + contextBars * barInterval, }); } @@ -170,14 +199,32 @@ export class ChartApplication { _loadTrades(strategy, candlestickData) { if (!strategy) return; - const allTrades = [ + const openTrades = strategy.openTrades || []; + const allTrades = [ ...(strategy.trades || []), - ...(strategy.openTrades || []).map(t => ({ ...t, status: 'open' })), + ...openTrades.map(t => ({ ...t, status: 'open' })), ]; allTrades.sort((a, b) => (b.entryTime || 0) - (a.entryTime || 0)); - const tbody = document.getElementById('trades-tbody'); + this._tradeState = { + allTrades, + openTrades, + netProfit: strategy.netProfit || 0, + candlestickData, + }; + + const headerEl = document.querySelector('.trades-header'); + this.sortToggle.mount(headerEl, allTrades.length > 0); + + this._rerenderTrades(this.sortToggle.direction); + } + + _rerenderTrades(sortDirection) { + if (!this._tradeState) return; + const { allTrades, openTrades, netProfit, candlestickData } = this._tradeState; + + const tbody = document.getElementById('trades-tbody'); const summary = document.getElementById('trades-summary'); if (allTrades.length === 0) { @@ -186,34 +233,35 @@ export class ChartApplication { return; } + const sorted = sortDirection === SortDirection.ASC + ? [...allTrades].reverse() + : allTrades; + const currentPrice = candlestickData?.length > 0 ? candlestickData[candlestickData.length - 1].close : null; const formatter = new TradeDataFormatter(candlestickData); - const rows = new TradeRowspanTransformer(formatter).transformTrades(allTrades, currentPrice); + const rows = new TradeRowspanTransformer(formatter).transformTrades(sorted, currentPrice, sortDirection); tbody.innerHTML = new TradeRowspanRenderer().renderRows(rows); - const realizedProfit = strategy.netProfit || 0; const unrealizedProfit = currentPrice - ? (strategy.openTrades || []).reduce((sum, t) => { + ? openTrades.reduce((sum, t) => { const multiplier = t.direction === 'long' ? 1 : -1; return sum + (currentPrice - t.entryPrice) * t.size * multiplier; }, 0) : 0; - const totalProfit = realizedProfit + unrealizedProfit; - + const totalProfit = netProfit + unrealizedProfit; const profitClass = totalProfit >= 0 ? 'trade-profit-positive' : 'trade-profit-negative'; summary.innerHTML = `${allTrades.length} trades | Net P/L: ${formatCurrency(totalProfit)}`; } - _setupEventListeners() { - window.addEventListener('resize', () => { - ChartManager.handleResize( - this.paneManager.getAllCharts(), - this.paneManager.getAllContainers() - ); - }); + _onResize() { + if (!this.paneManager) return; + ChartManager.handleResize( + this.paneManager.getAllCharts(), + this.paneManager.getAllContainers(), + ); } } diff --git a/out/js/ChartManager.js b/out/js/ChartManager.js index 89c5b79..982513e 100644 --- a/out/js/ChartManager.js +++ b/out/js/ChartManager.js @@ -1,4 +1,3 @@ -/* Chart creation and series management (SRP) */ export class ChartManager { static createChart(container, config, chartOptions) { return LightweightCharts.createChart(container, { @@ -25,7 +24,8 @@ export class ChartManager { } static handleResize(charts, containers) { - const width = containers[0].clientWidth; - charts.forEach((chart) => chart.applyOptions({ width })); + charts.forEach((chart, i) => { + chart.resize(containers[i].clientWidth, containers[i].clientHeight); + }); } } diff --git a/out/js/FullscreenToggle.js b/out/js/FullscreenToggle.js new file mode 100644 index 0000000..7a349dc --- /dev/null +++ b/out/js/FullscreenToggle.js @@ -0,0 +1,92 @@ +/** + * Prefers the native Fullscreen API; falls back to a `chart-fullscreen` CSS class + * for browsers that do not support the API on non-video elements (e.g. iPhone Safari). + * + * `onResize` is called on every viewport change so the chart library can reflow. + * + * `mount()` is idempotent — safe to call on every render cycle; attaches exactly + * one button and one pair of `fullscreenchange` listeners for the lifetime of the instance. + */ +export class FullscreenToggle { + #container; + #button; + #onResize; + #usingApiFullscreen = false; + #mounted = false; + #boundHandler = () => this.#handleFullscreenChange(); + + constructor(container, onResize) { + this.#container = container; + this.#onResize = onResize; + this.#button = this.#createButton(); + } + + mount() { + if (this.#mounted) return; + this.#mounted = true; + this.#container.appendChild(this.#button); + document.addEventListener('fullscreenchange', this.#boundHandler); + document.addEventListener('webkitfullscreenchange', this.#boundHandler); + } + + #createButton() { + const btn = document.createElement('button'); + btn.className = 'fullscreen-toggle-btn'; + btn.textContent = '⤢'; + btn.title = 'Toggle fullscreen'; + btn.addEventListener('click', () => this.#toggle()); + return btn; + } + + #supportsFullscreenApi() { + return typeof this.#container.requestFullscreen === 'function' + || typeof this.#container.webkitRequestFullscreen === 'function'; + } + + #isFullscreen() { + return !!(document.fullscreenElement || document.webkitFullscreenElement) + || this.#container.classList.contains('chart-fullscreen'); + } + + #toggle() { + if (this.#isFullscreen()) { + this.#exit(); + } else { + this.#enter(); + } + } + + #enter() { + if (this.#supportsFullscreenApi()) { + this.#usingApiFullscreen = true; + (this.#container.requestFullscreen || this.#container.webkitRequestFullscreen) + .call(this.#container); + } else { + this.#container.classList.add('chart-fullscreen'); + this.#syncButton(); + this.#onResize(); + } + } + + #exit() { + if (this.#usingApiFullscreen) { + this.#usingApiFullscreen = false; + (document.exitFullscreen || document.webkitExitFullscreen).call(document); + } else { + this.#container.classList.remove('chart-fullscreen'); + this.#syncButton(); + this.#onResize(); + } + } + + #handleFullscreenChange() { + this.#syncButton(); + this.#onResize(); + } + + #syncButton() { + const active = this.#isFullscreen(); + this.#button.textContent = active ? '✕' : '⤢'; + this.#button.title = active ? 'Exit fullscreen' : 'Toggle fullscreen'; + } +} diff --git a/out/js/PaneManager.js b/out/js/PaneManager.js index 8163fa2..7e103d3 100644 --- a/out/js/PaneManager.js +++ b/out/js/PaneManager.js @@ -1,4 +1,3 @@ -/* Multi-pane chart manager with time-scale synchronization (SRP) */ export class PaneManager { constructor(chartOptions) { this.chartOptions = chartOptions; @@ -7,6 +6,7 @@ export class PaneManager { } createMainPane(container, config) { + container.style.height = `${config.height}px`; this.mainPane = { container, chart: LightweightCharts.createChart(container, { @@ -22,7 +22,8 @@ export class PaneManager { const containerDiv = document.createElement('div'); containerDiv.id = `${paneName}-chart`; containerDiv.style.position = 'relative'; - containerDiv.style.zIndex = '1'; + containerDiv.style.zIndex = '1'; + containerDiv.style.height = `${config.height}px`; const chartContainerDiv = document.querySelector('.chart-container'); chartContainerDiv.appendChild(containerDiv); @@ -53,6 +54,12 @@ export class PaneManager { return containers; } + getAllPanes() { + const panes = [this.mainPane]; + this.dynamicPanes.forEach(pane => panes.push(pane)); + return panes; + } + synchronizeTimeScales() { const charts = this.getAllCharts(); let isUpdating = false; diff --git a/out/js/PaneResizeCalculator.js b/out/js/PaneResizeCalculator.js new file mode 100644 index 0000000..bd2ff9b --- /dev/null +++ b/out/js/PaneResizeCalculator.js @@ -0,0 +1,10 @@ +export const MIN_PANE_HEIGHT = 80; + +export class PaneResizeCalculator { + static calculate(aboveHeight, belowHeight, delta) { + const lo = MIN_PANE_HEIGHT - aboveHeight; + const hi = belowHeight - MIN_PANE_HEIGHT; + const clamped = Math.max(lo, Math.min(hi, delta)); + return { above: aboveHeight + clamped, below: belowHeight - clamped }; + } +} diff --git a/out/js/PaneResizeController.js b/out/js/PaneResizeController.js new file mode 100644 index 0000000..8da9ef8 --- /dev/null +++ b/out/js/PaneResizeController.js @@ -0,0 +1,25 @@ +import { PaneResizeHandle } from './PaneResizeHandle.js'; + +/** + * mount() — inserts N-1 handles between N panes; safe to call after destroy(). + * destroy() — removes all handles and their document listeners; safe to call + * before new panes are created (e.g. on refresh). + * + * Pane shape: { container: HTMLElement, chart: IChartApi } + */ +export class PaneResizeController { + #handles = []; + + mount(panes) { + for (let i = 0; i < panes.length - 1; i++) { + const handle = new PaneResizeHandle(panes[i], panes[i + 1]); + panes[i + 1].container.insertAdjacentElement('beforebegin', handle.element); + this.#handles.push(handle); + } + } + + destroy() { + this.#handles.forEach(h => h.destroy()); + this.#handles = []; + } +} diff --git a/out/js/PaneResizeHandle.js b/out/js/PaneResizeHandle.js new file mode 100644 index 0000000..28ae80a --- /dev/null +++ b/out/js/PaneResizeHandle.js @@ -0,0 +1,70 @@ +import { PaneResizeCalculator } from './PaneResizeCalculator.js'; + +/** + * Call destroy() when the associated panes are torn down to remove the element + * and deregister all document-level listeners. + * + * Pane shape: { container: HTMLElement, chart: IChartApi } + */ +export class PaneResizeHandle { + #above; + #below; + #element; + #drag = null; + + #onMouseMove = (e) => this.#move(e.clientY); + #onTouchMove = (e) => { if (this.#drag) { e.preventDefault(); this.#move(e.touches[0].clientY); } }; + #onDragEnd = () => { this.#drag = null; }; + + constructor(above, below) { + this.#above = above; + this.#below = below; + this.#element = this.#buildElement(); + + document.addEventListener('mousemove', this.#onMouseMove); + document.addEventListener('touchmove', this.#onTouchMove, { passive: false }); + document.addEventListener('mouseup', this.#onDragEnd); + document.addEventListener('touchend', this.#onDragEnd); + } + + get element() { return this.#element; } + + destroy() { + this.#element.remove(); + document.removeEventListener('mousemove', this.#onMouseMove); + document.removeEventListener('touchmove', this.#onTouchMove); + document.removeEventListener('mouseup', this.#onDragEnd); + document.removeEventListener('touchend', this.#onDragEnd); + } + + #buildElement() { + const el = document.createElement('div'); + el.className = 'pane-resize-handle'; + el.addEventListener('mousedown', (e) => this.#startDrag(e.clientY)); + el.addEventListener('touchstart', (e) => { e.preventDefault(); this.#startDrag(e.touches[0].clientY); }, { passive: false }); + return el; + } + + #startDrag(y) { + this.#drag = { + startY: y, + aboveH: this.#above.container.clientHeight, + belowH: this.#below.container.clientHeight, + }; + } + + #move(y) { + if (!this.#drag) return; + const delta = y - this.#drag.startY; + const { above, below } = PaneResizeCalculator.calculate( + this.#drag.aboveH, this.#drag.belowH, delta, + ); + this.#applyHeight(this.#above, above); + this.#applyHeight(this.#below, below); + } + + #applyHeight({ container, chart }, height) { + container.style.height = `${height}px`; + chart.resize(container.clientWidth, height); + } +} diff --git a/out/js/SortDirection.js b/out/js/SortDirection.js new file mode 100644 index 0000000..4fa7f45 --- /dev/null +++ b/out/js/SortDirection.js @@ -0,0 +1,4 @@ +export const SortDirection = Object.freeze({ + DESC: 'desc', + ASC: 'asc', +}); diff --git a/out/js/TradeRowData.js b/out/js/TradeRowData.js index 6cf778f..b6e7bcb 100644 --- a/out/js/TradeRowData.js +++ b/out/js/TradeRowData.js @@ -1,28 +1,23 @@ /** - * TradeRowData - Domain model for rowspan table rows - * - * SRP: Represents a single visual row (Entry or Exit) in the rowspan table - * Each trade produces TWO rows: one Entry row + one Exit row + * `isPrimary` marks the first row of the pair; only the primary row carries + * Direction[rowspan=2] and Size[rowspan=2] cells. + * P/L is always on the exit row regardless of pair position. */ export class TradeRowData { constructor(config) { this.tradeNumber = config.tradeNumber; - this.rowType = config.rowType; // 'entry' | 'exit' - this.dateTime = config.dateTime; - this.signal = config.signal; - this.price = config.price; - this.size = config.size; - this.profitLoss = config.profitLoss; - this.direction = config.direction; - this.isOpen = config.isOpen; - this.profitRaw = config.profitRaw; + this.rowType = config.rowType; + this.isPrimary = config.isPrimary; + this.dateTime = config.dateTime; + this.signal = config.signal; + this.price = config.price; + this.size = config.size; + this.profitLoss = config.profitLoss; + this.direction = config.direction; + this.isOpen = config.isOpen; + this.profitRaw = config.profitRaw; } - isEntryRow() { - return this.rowType === 'entry'; - } - - isExitRow() { - return this.rowType === 'exit'; - } + isEntryRow() { return this.rowType === 'entry'; } + isExitRow() { return this.rowType === 'exit'; } } diff --git a/out/js/TradeRowspanRenderer.js b/out/js/TradeRowspanRenderer.js index c3e32b9..4fda2c9 100644 --- a/out/js/TradeRowspanRenderer.js +++ b/out/js/TradeRowspanRenderer.js @@ -1,3 +1,17 @@ +/** + * TradeRowspanRenderer — stateless HTML generator for the rowspan trade table. + * + * Layout contract (7 columns: Type | Entry/Exit | DateTime | Signal | Price | Size | P/L): + * + * Primary row (isPrimary=true): + * Type[rowspan=2] | label | DateTime | Signal | Price | Size[rowspan=2] | P/L (exit only) + * + * Secondary row (isPrimary=false): + * label | DateTime | Signal | Price | P/L (exit only) + * + * P/L is always and only on exit rows, regardless of pair position. + * `.trade-open` class is applied to the and the P/L of open exit rows. + */ export class TradeRowspanRenderer { #directionClass(row) { return row.direction === 'long' ? 'trade-long' : 'trade-short'; @@ -8,34 +22,46 @@ export class TradeRowspanRenderer { return row.profitRaw >= 0 ? 'trade-profit-positive' : 'trade-profit-negative'; } - #openExitRowAttr(row) { - return row.isExitRow() && row.isOpen ? ' class="trade-open"' : ''; + #label(row) { + return row.isEntryRow() ? 'Entry' : 'Exit'; + } + + #plCell(row) { + if (!row.isExitRow()) return ''; + return `${row.profitLoss}`; } - #entryRowHtml(row) { + #primaryRowHtml(row) { return [ `${row.direction.toUpperCase()}`, - `Entry`, + `${this.#label(row)}`, `${row.dateTime}`, `${row.signal}`, `${row.price}`, `${row.size}`, + this.#plCell(row), ].join(''); } - #exitRowHtml(row) { + #secondaryRowHtml(row) { return [ - `Exit`, + `${this.#label(row)}`, `${row.dateTime}`, `${row.signal}`, `${row.price}`, - `${row.profitLoss}`, + this.#plCell(row), ].join(''); } + #trClass(row) { + return row.isExitRow() && row.isOpen ? ' class="trade-open"' : ''; + } + renderRow(row) { - const cells = row.isEntryRow() ? this.#entryRowHtml(row) : this.#exitRowHtml(row); - return `${cells}`; + const cells = row.isPrimary + ? this.#primaryRowHtml(row) + : this.#secondaryRowHtml(row); + return `${cells}`; } renderRows(rows) { diff --git a/out/js/TradeRowspanTransformer.js b/out/js/TradeRowspanTransformer.js index 6f28741..59cd487 100644 --- a/out/js/TradeRowspanTransformer.js +++ b/out/js/TradeRowspanTransformer.js @@ -1,55 +1,58 @@ -import { TradeRowData } from './TradeRowData.js'; +import { TradeRowData } from './TradeRowData.js'; +import { SortDirection } from './SortDirection.js'; export class TradeRowspanTransformer { constructor(formatter) { this.formatter = formatter; } - transformTrade(trade, tradeNumber, currentPrice) { - const isOpen = trade.status === 'open'; + /** + * `isPrimary` on the first row drives rowspan-cell placement in the renderer. + * `size` is populated on both rows so the renderer always finds it on the primary. + */ + transformTrade(trade, tradeNumber, currentPrice, sortDirection = SortDirection.DESC) { + const isOpen = trade.status === 'open'; const unrealizedProfit = this.formatter.calculateUnrealizedProfit(trade, currentPrice); - - const exitPrice = isOpen - ? (currentPrice ?? trade.entryPrice) - : (trade.exitPrice ?? 0); - - const profitValue = isOpen ? unrealizedProfit : trade.profit; + const exitPrice = isOpen ? (currentPrice ?? trade.entryPrice) : (trade.exitPrice ?? 0); + const profitValue = isOpen ? unrealizedProfit : trade.profit; + const sizeFormatted = trade.size.toFixed(2); const entryRow = new TradeRowData({ tradeNumber, - rowType: 'entry', - dateTime: this.formatter.getTradeDate(trade, true), - signal: trade.entryComment || trade.entryId || '', - price: this.formatter.formatPrice(trade.entryPrice), - size: trade.size.toFixed(2), + rowType: 'entry', + isPrimary: sortDirection === SortDirection.ASC, + dateTime: this.formatter.getTradeDate(trade, true), + signal: trade.entryComment || trade.entryId || '', + price: this.formatter.formatPrice(trade.entryPrice), + size: sizeFormatted, profitLoss: '', - direction: trade.direction, - isOpen: false, - profitRaw: 0, + direction: trade.direction, + isOpen: false, + profitRaw: 0, }); const exitRow = new TradeRowData({ tradeNumber, - rowType: 'exit', - dateTime: isOpen ? 'Open' : this.formatter.getTradeDate(trade, false), - signal: isOpen ? '' : (trade.exitComment || trade.exitId || ''), - price: this.formatter.formatPrice(exitPrice), - size: '', + rowType: 'exit', + isPrimary: sortDirection === SortDirection.DESC, + dateTime: isOpen ? 'Open' : this.formatter.getTradeDate(trade, false), + signal: isOpen ? '' : (trade.exitComment || trade.exitId || ''), + price: this.formatter.formatPrice(exitPrice), + size: sizeFormatted, profitLoss: this.formatter.formatProfit(profitValue), - direction: trade.direction, + direction: trade.direction, isOpen, - profitRaw: profitValue, + profitRaw: profitValue, }); - return [entryRow, exitRow]; + return sortDirection === SortDirection.ASC + ? [entryRow, exitRow] + : [exitRow, entryRow]; } - transformTrades(trades, currentPrice) { - const rows = []; - trades.forEach((trade, index) => { - const [entryRow, exitRow] = this.transformTrade(trade, index + 1, currentPrice); - rows.push(entryRow, exitRow); - }); - return rows; + transformTrades(trades, currentPrice, sortDirection = SortDirection.DESC) { + return trades.flatMap((trade, index) => + this.transformTrade(trade, index + 1, currentPrice, sortDirection) + ); } } diff --git a/out/js/TradeSortToggle.js b/out/js/TradeSortToggle.js new file mode 100644 index 0000000..03b217b --- /dev/null +++ b/out/js/TradeSortToggle.js @@ -0,0 +1,41 @@ +import { SortDirection } from './SortDirection.js'; + +export class TradeSortToggle { + #direction; + #onChange; + #button = null; + + constructor(onChange, initialDirection = SortDirection.DESC) { + this.#direction = initialDirection; + this.#onChange = onChange; + } + + get direction() { + return this.#direction; + } + + mount(headerEl, hasAnyTrades) { + if (!this.#button) { + this.#button = document.createElement('button'); + this.#button.className = 'sort-toggle-btn'; + this.#button.addEventListener('click', () => this.#toggle()); + headerEl.appendChild(this.#button); + } + this.#button.style.display = hasAnyTrades ? '' : 'none'; + this.#syncLabel(); + } + + #toggle() { + this.#direction = this.#direction === SortDirection.DESC + ? SortDirection.ASC + : SortDirection.DESC; + this.#syncLabel(); + this.#onChange(this.#direction); + } + + #syncLabel() { + if (!this.#button) return; + const arrow = this.#direction === SortDirection.DESC ? '▼' : '▲'; + this.#button.textContent = `${arrow} ${this.#direction === SortDirection.DESC ? 'Newest first' : 'Oldest first'}`; + } +} diff --git a/out/js/WindowResizeHandler.js b/out/js/WindowResizeHandler.js new file mode 100644 index 0000000..c4d5436 --- /dev/null +++ b/out/js/WindowResizeHandler.js @@ -0,0 +1,12 @@ +export class WindowResizeHandler { + #handler; + + constructor(onResize) { + this.#handler = onResize; + window.addEventListener('resize', this.#handler); + } + + destroy() { + window.removeEventListener('resize', this.#handler); + } +} diff --git a/out/tests/ChartManager.test.js b/out/tests/ChartManager.test.js new file mode 100644 index 0000000..2ca1b66 --- /dev/null +++ b/out/tests/ChartManager.test.js @@ -0,0 +1,190 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { ChartManager } from '../js/ChartManager.js'; + +// ── Stubs ────────────────────────────────────────────────────────────────────── + +function makeChartSpy() { + const resizeCalls = []; + const fitCalls = []; + return { + resize: (w, h) => resizeCalls.push({ w, h }), + timeScale: () => ({ fitContent: () => fitCalls.push(1) }), + _resizeCalls: resizeCalls, + _fitCalls: fitCalls, + }; +} + +function makeContainer(clientWidth, clientHeight) { + return { clientWidth, clientHeight }; +} + +function stubLightweightCharts() { + const calls = []; + const chartStub = {}; + global.LightweightCharts = { + createChart: (container, options) => { calls.push({ container, options }); return chartStub; }, + }; + return { calls, chartStub }; +} + +// ── ChartManager.createChart — option composition ───────────────────────────── +// +// createChart spreads chartOptions then unconditionally overrides height and +// width from config and container respectively. Non-conflicting chartOptions +// keys must pass through intact. + +describe('createChart() — height comes from config, never from chartOptions', () => { + it('config.height is used as the height option', () => { + const { calls } = stubLightweightCharts(); + ChartManager.createChart({ clientWidth: 800 }, { height: 400 }, {}); + assert.equal(calls[0].options.height, 400); + }); + + it('config.height overrides a conflicting height key in chartOptions', () => { + const { calls } = stubLightweightCharts(); + ChartManager.createChart({ clientWidth: 800 }, { height: 400 }, { height: 9999 }); + assert.equal(calls[0].options.height, 400); + }); +}); + +describe('createChart() — width comes from container.clientWidth, never from chartOptions', () => { + it('container.clientWidth is used as the width option', () => { + const { calls } = stubLightweightCharts(); + ChartManager.createChart({ clientWidth: 1200 }, { height: 400 }, {}); + assert.equal(calls[0].options.width, 1200); + }); + + it('container.clientWidth overrides a conflicting width key in chartOptions', () => { + const { calls } = stubLightweightCharts(); + ChartManager.createChart({ clientWidth: 1200 }, { height: 400 }, { width: 1 }); + assert.equal(calls[0].options.width, 1200); + }); +}); + +describe('createChart() — chartOptions pass-through and LightweightCharts delegation', () => { + it('non-conflicting chartOptions properties reach LightweightCharts.createChart', () => { + const { calls } = stubLightweightCharts(); + ChartManager.createChart({ clientWidth: 800 }, { height: 400 }, { layout: { background: '#000' } }); + assert.deepEqual(calls[0].options.layout, { background: '#000' }); + }); + + it('passes the container element as the first argument to LightweightCharts.createChart', () => { + const { calls } = stubLightweightCharts(); + const container = { clientWidth: 800 }; + ChartManager.createChart(container, { height: 400 }, {}); + assert.equal(calls[0].container, container); + }); + + it('returns the chart instance from LightweightCharts.createChart', () => { + const { chartStub } = stubLightweightCharts(); + const result = ChartManager.createChart({ clientWidth: 800 }, { height: 400 }, {}); + assert.equal(result, chartStub); + }); +}); + +// ── ChartManager.fitContent — timescale fit delegation ──────────────────────── +// +// fitContent calls timeScale().fitContent() on every chart exactly once per +// invocation. The implementation must iterate all charts, not only the first. + +describe('fitContent() — zero charts: no error', () => { + it('empty chart list produces no error', () => { + assert.doesNotThrow(() => ChartManager.fitContent([])); + }); +}); + +describe('fitContent() — timeScale().fitContent() called exactly once per chart', () => { + const chartCounts = [1, 2, 5]; + + for (const n of chartCounts) { + it(`${n} chart(s): each receives exactly one fitContent call`, () => { + const charts = Array.from({ length: n }, makeChartSpy); + ChartManager.fitContent(charts); + charts.forEach((chart, i) => { + assert.equal(chart._fitCalls.length, 1, `chart[${i}] fitContent call count`); + }); + }); + } +}); + +// ── ChartManager.handleResize — per-container resize delegation ─────────────── +// +// handleResize reads clientWidth and clientHeight from each chart's own +// container and calls chart.resize(width, height) exactly once per chart per +// invocation. Width and height must come from the same container with no +// cross-contamination between panes. + +describe('handleResize() — zero charts: no error', () => { + it('empty chart and container lists produce no error', () => { + assert.doesNotThrow(() => ChartManager.handleResize([], [])); + }); +}); + +describe('handleResize() — each chart receives its own container dimensions', () => { + const resizeCases = [ + { + label: '1 pane (main only)', + containers: [makeContainer(800, 400)], + }, + { + label: '2 panes (main + 1 indicator)', + containers: [makeContainer(1024, 600), makeContainer(1024, 200)], + }, + { + label: '5 panes — each with a distinct width and height', + containers: [ + makeContainer(320, 568), + makeContainer(768, 1024), + makeContainer(1440, 900), + makeContainer(2560, 1440), + makeContainer(3840, 2160), + ], + }, + ]; + + for (const { label, containers } of resizeCases) { + it(`${label}: resize(clientWidth, clientHeight) called once per chart with matching dimensions`, () => { + const charts = Array.from({ length: containers.length }, makeChartSpy); + ChartManager.handleResize(charts, containers); + charts.forEach((chart, i) => { + assert.deepEqual( + chart._resizeCalls, + [{ w: containers[i].clientWidth, h: containers[i].clientHeight }], + `chart[${i}]`, + ); + }); + }); + } +}); + +describe('handleResize() — reads container state at call time', () => { + it('successive calls each propagate the current container dimensions', () => { + const chart = makeChartSpy(); + const container = makeContainer(800, 400); + + ChartManager.handleResize([chart], [container]); + + container.clientHeight = 700; + ChartManager.handleResize([chart], [container]); + + assert.deepEqual(chart._resizeCalls, [ + { w: 800, h: 400 }, + { w: 800, h: 700 }, + ]); + }); +}); + +describe('handleResize() — boundary dimensions', () => { + it('zero width and height are propagated faithfully (collapsed or hidden pane)', () => { + const chart = makeChartSpy(); + ChartManager.handleResize([chart], [makeContainer(0, 0)]); + assert.deepEqual(chart._resizeCalls, [{ w: 0, h: 0 }]); + }); + + it('fractional sub-pixel HiDPI dimensions are propagated without rounding', () => { + const chart = makeChartSpy(); + ChartManager.handleResize([chart], [makeContainer(1920.5, 1080.5)]); + assert.deepEqual(chart._resizeCalls, [{ w: 1920.5, h: 1080.5 }]); + }); +}); diff --git a/out/tests/DesignSystem.test.js b/out/tests/DesignSystem.test.js new file mode 100644 index 0000000..39033d4 --- /dev/null +++ b/out/tests/DesignSystem.test.js @@ -0,0 +1,201 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +// ── File readers ────────────────────────────────────────────────────────────── +// +// Files are read once at module scope; all test bodies reference the cached +// strings. A missing file throws at load time, which fails the suite clearly. + +const tokensCSS = readFileSync(new URL('../css/tokens.css', import.meta.url), 'utf8'); +const stylesCSS = readFileSync(new URL('../css/styles.css', import.meta.url), 'utf8'); +const indexHTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + +// ── CSS parsers ─────────────────────────────────────────────────────────────── +// +// Pure functions over CSS text. Each extracts one kind of information so +// tests can compose them without re-implementing the same regex in every it(). + +function stripComments(css) { + return css.replace(/\/\*[\s\S]*?\*\//g, ''); +} + +function declaredTokensIn(css) { + return new Set(stripComments(css).match(/--[\w-]+(?=\s*:)/g) ?? []); +} + +function referencedTokensIn(css) { + return new Set( + [...stripComments(css).matchAll(/var\(\s*(--[\w-]+)/g)].map(m => m[1]), + ); +} + +function hexColorsIn(css) { + return stripComments(css).match(/#[0-9a-fA-F]{3,8}\b/g) ?? []; +} + +function rgbLiteralsIn(css) { + return stripComments(css).match(/rgba?\s*\(/g) ?? []; +} + +function importPathsIn(css) { + return [...css.matchAll(/@import\s+['"]([^'"]+)['"]/g)].map(m => m[1]); +} + +const declaredTokens = declaredTokensIn(tokensCSS); +const referencedTokens = referencedTokensIn(stylesCSS); + +// ── tokens.css — file structure ─────────────────────────────────────────────── +// +// Tokens file must be non-empty and contain the :root declaration block that +// makes the custom properties available to the entire document. + +describe('tokens.css — file is non-empty', () => { + it('file has content', () => { + assert.ok(tokensCSS.trim().length > 0); + }); +}); + +describe('tokens.css — :root block', () => { + it(':root block is present', () => { + assert.ok(stripComments(tokensCSS).includes(':root')); + }); + + it(':root block has an opening brace', () => { + assert.match(stripComments(tokensCSS), /:root\s*\{/); + }); +}); + +describe('tokens.css — token naming convention', () => { + it('at least one custom property is declared', () => { + assert.ok(declaredTokens.size > 0); + }); + + it('every declared name starts with -- (CSS custom property)', () => { + for (const token of declaredTokens) { + assert.ok(token.startsWith('--'), `'${token}' does not start with '--'`); + } + }); +}); + +// ── styles.css — file structure ─────────────────────────────────────────────── +// +// styles.css must be non-empty, must explicitly @import tokens.css so the +// dependency is visible in code, and must not re-declare the :root block +// (tokens live only in tokens.css). + +describe('styles.css — file is non-empty', () => { + it('file has content', () => { + assert.ok(stylesCSS.trim().length > 0); + }); +}); + +describe('styles.css — @import dependency declaration', () => { + it("@imports exactly one file", () => { + assert.equal(importPathsIn(stylesCSS).length, 1); + }); + + it("@imports './tokens.css'", () => { + assert.ok( + importPathsIn(stylesCSS).includes('./tokens.css'), + `imports found: ${importPathsIn(stylesCSS).join(', ')}`, + ); + }); + + it('@import is the first non-whitespace statement', () => { + assert.match(stylesCSS.trimStart(), /^@import/); + }); +}); + +describe('styles.css — no :root block (tokens live only in tokens.css)', () => { + it(':root is absent from the component stylesheet', () => { + assert.ok(!stripComments(stylesCSS).includes(':root')); + }); +}); + +// ── styles.css — single source of truth for color values ───────────────────── +// +// Every color must be defined in tokens.css and consumed via var(). A +// hardcoded color literal in styles.css means the token system has been +// bypassed and a future design change requires editing both files. + +describe('styles.css — no hardcoded hex color literals', () => { + it('zero hex color patterns after comment stripping', () => { + const found = hexColorsIn(stylesCSS); + assert.deepEqual( + found, + [], + `hardcoded hex colors found: ${found.join(', ')}`, + ); + }); +}); + +describe('styles.css — no hardcoded rgb() / rgba() literals', () => { + it('zero rgb/rgba patterns after comment stripping', () => { + const found = rgbLiteralsIn(stylesCSS); + assert.deepEqual( + found, + [], + `hardcoded rgb/rgba literals found: ${found.join(', ')}`, + ); + }); +}); + +// ── styles.css → tokens.css — variable resolution ──────────────────────────── +// +// Every var(--x) reference in styles.css must have a matching declaration +// in tokens.css. An unresolved reference silently falls back to the +// browser default (typically empty string), producing invisible breakage. + +describe('styles.css — every var() reference resolves to a declared token', () => { + for (const token of referencedTokens) { + it(`var(${token}) is declared in tokens.css`, () => { + assert.ok( + declaredTokens.has(token), + `'${token}' is referenced in styles.css but not declared in tokens.css`, + ); + }); + } +}); + +// ── tokens.css — no dead tokens ─────────────────────────────────────────────── +// +// Every declared token must be consumed by styles.css. Dead tokens add noise +// to the token surface, mislead future editors, and never survive rotation of +// the design. + +describe('tokens.css — every declared token is consumed by styles.css', () => { + for (const token of declaredTokens) { + it(`${token} is referenced in styles.css`, () => { + assert.ok( + referencedTokens.has(token), + `'${token}' is declared in tokens.css but never referenced in styles.css`, + ); + }); + } +}); + +// ── index.html — stylesheet integration ────────────────────────────────────── +// +// The page must load styles through the external stylesheet, never through an +// inline