Compare commits
7 commits
0e32c0bbab
...
decd069ec8
| Author | SHA1 | Date | |
|---|---|---|---|
| decd069ec8 | |||
| 3f677a333c | |||
| fa577bacb7 | |||
| 3222ce03f4 | |||
| 7ce6d33bb5 | |||
| ab372832db | |||
| 501afb7792 |
13 changed files with 1023 additions and 25 deletions
38
AGENTS.md
38
AGENTS.md
|
|
@ -21,6 +21,25 @@ level, a tick must never overwrite a settled bar, volume must be counted once.
|
|||
Name the test after the failure, not the function: `test_a_tick_cannot_overwrite
|
||||
_a_settled_bar` beats `test_put`.
|
||||
|
||||
## Where things run
|
||||
|
||||
**The agent works on a remote machine over SSH. The user's browser runs on a
|
||||
different machine.** Consequences, all learned the hard way:
|
||||
|
||||
- You cannot see the user's screen, console, or cursor. Screenshots and pasted
|
||||
console output are the only window into it. Browser extensions that drive
|
||||
"your" Chrome do not help — they attach to the machine the browser is on.
|
||||
- Headless Chromium here renders on server hardware: different screen, window
|
||||
size and device pixel ratio from the user's. "Works in my headless run" is not
|
||||
evidence that it works for them. When a UI bug will not reproduce, match their
|
||||
viewport and `deviceScaleFactor` explicitly before concluding anything.
|
||||
- The dev stack is served to them over the network (e.g. `hera.local:8010`),
|
||||
which is the same app the headless browser reaches as `http://api:8000`.
|
||||
|
||||
When a visual bug resists reproduction, prefer putting the numbers **on screen**
|
||||
in the app over asking for another console paste — one screenshot then carries
|
||||
the whole diagnosis.
|
||||
|
||||
## Verify UI in a real browser
|
||||
|
||||
Chart bugs are invisible from the outside — the API, the socket and the
|
||||
|
|
@ -42,6 +61,25 @@ renders as a blank canvas that looks exactly like a broken app.
|
|||
is missing" from "the data is off-screen" — which is how a viewport bug that
|
||||
three passing API checks had missed was finally found.
|
||||
|
||||
## Diagnostic mode
|
||||
|
||||
Chart geometry bugs live in the browser, which is usually on a different machine
|
||||
from whoever is debugging them. Rather than asking for console pastes:
|
||||
|
||||
```
|
||||
open the chart with ?diag=1 # remembered until ?diag=0
|
||||
docker compose logs api | grep SNAPDBG
|
||||
```
|
||||
|
||||
With it on, every snap the trendline tool computes is posted to
|
||||
`/api/debug/snap` and logged server-side — the cursor's time, price and x, the
|
||||
snapped time and price, how many bars were held, the first and last bar, and the
|
||||
chart's width. Throttled to about one a second. It reads the client's own
|
||||
numbers, which is exactly what "works in my headless run" cannot tell you.
|
||||
|
||||
Extend it when the next geometry puzzle appears; the endpoint takes whatever
|
||||
fields `SnapReport` declares.
|
||||
|
||||
## Running tests
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
|
@ -11,6 +12,8 @@ from app.api.deps import require_token
|
|||
|
||||
# Everything here needs the token when CHART_AUTH_TOKEN is set. /health and
|
||||
# /version live in app.api.meta and stay open on purpose.
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
|
||||
|
||||
|
||||
|
|
@ -198,6 +201,44 @@ def create_comment(request: Request, payload: CommentCreate):
|
|||
return line.to_dict()
|
||||
|
||||
|
||||
class SnapReport(BaseModel):
|
||||
"""What the browser computed for one snap, for diagnosing chart geometry."""
|
||||
cursor_t: int | None = None
|
||||
cursor_p: float | None = None
|
||||
cursor_x: float | None = None
|
||||
snapped_t: int | None = None
|
||||
snapped_p: float | None = None
|
||||
bars_held: int | None = None
|
||||
first_bar_t: int | None = None
|
||||
last_bar_t: int | None = None
|
||||
tf: str | None = None
|
||||
chart_w: float | None = None
|
||||
chart_h: float | None = None
|
||||
cursor_y: float | None = None
|
||||
# Where the indicator actually landed versus where the price says it should
|
||||
# — the only way to tell a wrong answer from a correctly-computed one drawn
|
||||
# in the wrong place.
|
||||
dot_y: float | None = None
|
||||
expected_y: float | None = None
|
||||
bar_low_y: float | None = None
|
||||
bar_high_y: float | None = None
|
||||
note: str | None = None
|
||||
|
||||
|
||||
@router.post("/debug/snap", status_code=204)
|
||||
def debug_snap(payload: SnapReport):
|
||||
"""Record one snap sample from a browser running diagnostic mode.
|
||||
|
||||
Chart geometry bugs live in the client, and the browser is usually on a
|
||||
different machine from whoever is debugging it — so its own numbers cannot
|
||||
be read any other way. Off unless the page is opened with ?diag=1; see
|
||||
AGENTS.md. Logged at warning level so it appears without reconfiguring
|
||||
uvicorn's log levels.
|
||||
"""
|
||||
logger.warning("SNAPDBG %s", payload.model_dump())
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/drawings")
|
||||
def drawings(request: Request):
|
||||
"""Every drawing, comments included, for the sidebar list."""
|
||||
|
|
|
|||
40
bin/e2e
Executable file
40
bin/e2e
Executable file
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run the end-to-end tests against the local dev stack.
|
||||
#
|
||||
# bin/e2e # every test
|
||||
# bin/e2e trendline # only files matching a name
|
||||
#
|
||||
# They run inside the `playwright` compose service, which already has
|
||||
# Playwright and a browser. Nothing is installed into this repo — NODE_PATH
|
||||
# points Node at the container's global modules, and tests/e2e resolves it
|
||||
# through createRequire because ESM ignores NODE_PATH.
|
||||
#
|
||||
# These drive the real app against the real feed. They create drawings and
|
||||
# delete them again afterwards; the dev stack shares one drawing store with
|
||||
# whoever else is looking at it.
|
||||
set -uo pipefail
|
||||
|
||||
FILTER=${1:-}
|
||||
COMPOSE=${COMPOSE:-docker compose}
|
||||
|
||||
if ! $COMPOSE ps --status running --services 2>/dev/null | grep -qx playwright; then
|
||||
echo "the playwright service is not running — start it with: $COMPOSE up -d" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$FILTER" ]; then
|
||||
FILES=$(ls tests/e2e/*"$FILTER"*.test.mjs 2>/dev/null)
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "no e2e test files match '$FILTER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
FILES=$(ls tests/e2e/*.test.mjs)
|
||||
fi
|
||||
|
||||
# The app is reachable as http://api:8000 from inside the compose network.
|
||||
# shellcheck disable=SC2086
|
||||
$COMPOSE exec -T \
|
||||
-e NODE_PATH=/usr/lib/node_modules \
|
||||
-e E2E_URL="${E2E_URL:-http://api:8000/}" \
|
||||
playwright node --test --test-concurrency=1 $(echo $FILES | sed 's#tests/e2e/#/repo/tests/e2e/#g')
|
||||
|
|
@ -25,3 +25,5 @@ services:
|
|||
- LANG=en_US.UTF-8
|
||||
volumes:
|
||||
- ./artifacts/playwright:/artifacts
|
||||
# The e2e suite runs in here; the app is http://api:8000 on this network.
|
||||
- ./tests/e2e:/repo/tests/e2e:ro
|
||||
|
|
|
|||
|
|
@ -1545,3 +1545,118 @@ overwrite a settled one.
|
|||
Both were introduced by the tick feature and both are covered by tests: a zero
|
||||
price parses as a trade with no price, a late closed bar replaces its bucket and
|
||||
keeps the exchange's volume, and a tick cannot overwrite a settled bar.
|
||||
|
||||
**Snapping now measures distance on screen, not in time.** The rule was "take
|
||||
the bar sharing the cursor's time, then its nearer extreme", which ignored how
|
||||
far that extreme actually was. Pointing anywhere below a candle snapped to that
|
||||
candle's low however distant, and the extreme genuinely under the cursor was
|
||||
never considered — so zoomed out to ~360 bars at three pixels each, hitting the
|
||||
intended bar took several attempts. `snapPoint` now scans six bars either side
|
||||
and picks the extreme nearest in pixels. Proven by probe: with the cursor on one
|
||||
bar's low but nudged two pixels so `coordinateToTime` resolves to its neighbour,
|
||||
the snap takes the extreme under the cursor rather than the neighbour's.
|
||||
|
||||
Worth recording because it was misdiagnosed twice: a report of "the snap dot
|
||||
appears way above the bar" was, on the numbers, the dot landing correctly on the
|
||||
bar's low while the cursor sat 151 points below it. The right price scale keeps
|
||||
a `bottom: 0.1` margin and the volume overlay is drawn in it, so the lower fifth
|
||||
of the pane is below every candle — an inviting place to point that contains no
|
||||
price action at all.
|
||||
|
||||
### e2e tests
|
||||
|
||||
`bin/e2e` runs `tests/e2e/*.test.mjs` inside the playwright service against the
|
||||
dev stack. Node's built-in test runner, no dependencies added to this repo:
|
||||
Playwright is global in that container and `tests/e2e` is mounted at
|
||||
`/repo/tests/e2e`. Every case in there is a bug that shipped — the viewport
|
||||
parked ten hours back, hourly candles drawn as slivers, stale bar events
|
||||
throwing, comments drifting on a timeframe switch, and three separate ways a
|
||||
trendline anchor could disagree with its own preview. None of them could have
|
||||
been caught by pytest, which is the argument for the suite existing.
|
||||
|
||||
Tests clean up after themselves: `withChart` records the drawings that exist
|
||||
before the body runs and deletes anything new afterwards, because the dev store
|
||||
is shared with whoever is using the app. Select by title rather than class when
|
||||
asserting on chart overlays, for the same reason.
|
||||
|
||||
### The snapping rule, stated once
|
||||
|
||||
**x picks the bar, y picks which extreme.** That is the whole rule. It is
|
||||
written here because changing it reactively three times is what made trendlines
|
||||
feel broken, not any inherent difficulty:
|
||||
|
||||
1. An 8px proximity gate meant a cursor between the high and the low snapped to
|
||||
neither, so the anchor kept a raw mid-bar price and the side silently fell
|
||||
back to the dropdown.
|
||||
2. Removing the gate fixed that. Then a nearest-in-2D search was tried, to make
|
||||
a bar easier to hit when zoomed out — and broke sweeping along the bottom,
|
||||
because whichever nearby bar had the lowest low won on total distance and the
|
||||
dot skipped off the bar under the cursor. Reverted.
|
||||
3. What actually made it feel wrong was never the rule: the crosshair was in
|
||||
Lightweight Charts' default Magnet mode, snapping to the bar's *close*, so
|
||||
the feedback pointed somewhere the anchor would never go. It is Normal
|
||||
everywhere now, with the snap dot showing the real target.
|
||||
|
||||
An e2e test sweeps the cursor along the bottom of a zoomed-out 1h chart and
|
||||
requires every position to land on the low of the bar beneath it — 115 of 115.
|
||||
That test is the rule, executable.
|
||||
|
||||
**The snap leapt to the live edge — found by diagnostic mode.** Reported from
|
||||
the user's own browser, which no headless run had reproduced:
|
||||
|
||||
```
|
||||
cursor_x 1409.0 chart_w 1280.0 -> cursor_t None -> snapped to the last bar
|
||||
cursor_x 1161.0 chart_w 1280.0 -> cursor_t None -> snapped to the last bar
|
||||
cursor_x 1128.0 -> valid time, drift 0 bars
|
||||
```
|
||||
|
||||
`coordinateToTime` answers `null` over the right-hand price axis, over the
|
||||
whitespace past the last bar, and anywhere outside the chart — and `snapPoint`
|
||||
read that as "the newest bar", so the dot jumped to the live edge from wherever
|
||||
the cursor was. Two faults behind it: the tool's pointer listener is on `window`
|
||||
and therefore fires over the sidebar (x=1409 on a 1280-wide chart), and a null
|
||||
time meant a default rather than no answer.
|
||||
|
||||
Now a pointer outside the plot hides the indicator entirely, and a null time
|
||||
resolves to the bar nearest in *pixels* rather than the newest one. Covered by
|
||||
an e2e test that hovers a bar, the axis, the sidebar, and back.
|
||||
|
||||
The lesson is about method rather than geometry: four hypotheses were tested and
|
||||
killed by measurement here — device pixel ratio, viewport size, resize
|
||||
desynchronisation, and the chart scrolling under the gesture — while the actual
|
||||
cause was visible in one line of the client's own numbers. When the browser is
|
||||
on another machine, instrument it early instead of reproducing locally.
|
||||
|
||||
### Overlays are positioned against the plot, not the element
|
||||
|
||||
**The trendline snap was 66 pixels out, and so was everything else drawn over
|
||||
the chart.** Lightweight Charts reports coordinates from the plot area's origin.
|
||||
The chart *element* also contains the price scales, so once the left scale was
|
||||
enabled for the daily labels, the plot started 66px into the element — and every
|
||||
overlay positioned with `left:` against the element was displaced by exactly
|
||||
that much, in both directions at once:
|
||||
|
||||
- the cursor's element-x was read as a plot-x, resolving a bar ~66px to the
|
||||
right of the pointer;
|
||||
- the indicator was then drawn at that bar's plot-x interpreted as element-x,
|
||||
landing ~66px left of where the bar is painted.
|
||||
|
||||
Not near the cursor, not near the bar, and varying with zoom — 66px is a couple
|
||||
of bars at 30m and a dozen at 1m, which is why it looked random rather than
|
||||
offset. Every diagnostic number agreed with itself throughout, because
|
||||
`dot_y`, `expected_y` and `bar_low_y` all derive from the same API and shared
|
||||
the same wrong origin. Self-consistent instrumentation cannot see a systematic
|
||||
error in its own frame of reference.
|
||||
|
||||
All overlays now live in one container positioned over the plot canvas, so they
|
||||
inherit plot coordinates untranslated: the snap dot and label, the comment
|
||||
layer, the trendline anchor handles, the preview line, the tooltip, the price
|
||||
tag and the context menu. `eventPoint` subtracts the same offset, so a pointer
|
||||
position and a chart coordinate finally mean the same thing. The container is
|
||||
repositioned on resize.
|
||||
|
||||
This had been mis-diagnosed for hours: device pixel ratio, viewport size, resize
|
||||
desynchronisation, the chart scrolling under the gesture, and the dead band
|
||||
below the candles were each measured and ruled out. The measurement that found
|
||||
it was comparing `canvas.width` to `element.clientWidth` — 0.894 — which is the
|
||||
first thing that ever disagreed with itself.
|
||||
|
|
|
|||
BIN
screenshot1.png
Normal file
BIN
screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
BIN
screenshot2.png
Normal file
BIN
screenshot2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 457 KiB |
247
static/chart.js
247
static/chart.js
|
|
@ -33,7 +33,11 @@ class ConfluenceChart {
|
|||
this.toolMoveListener = null;
|
||||
this.toolUpListener = null;
|
||||
this.pendingView = null;
|
||||
this.overlayLayer = null;
|
||||
this.snapDot = null;
|
||||
this.snapLabel = null;
|
||||
this.snapLeader = null;
|
||||
this.snapLeaderLine = null;
|
||||
this.comments = [];
|
||||
this.commentNodes = new Map();
|
||||
this.commentLayer = null;
|
||||
|
|
@ -47,6 +51,20 @@ class ConfluenceChart {
|
|||
// rather than a click. Wide enough to survive a twitch on a deliberate click.
|
||||
static DRAG_THRESHOLD = 12;
|
||||
|
||||
/**
|
||||
* Whether to report snap geometry to the server.
|
||||
*
|
||||
* `?diag=1` turns it on and remembers it; `?diag=0` turns it off. Kept out of
|
||||
* the way rather than always on: it exists because the browser runs on a
|
||||
* different machine from whoever is debugging it.
|
||||
*/
|
||||
static diagnosticMode() {
|
||||
const asked = new URLSearchParams(location.search).get('diag');
|
||||
if (asked === '1') localStorage.setItem('chart-diag', '1');
|
||||
if (asked === '0') localStorage.removeItem('chart-diag');
|
||||
return localStorage.getItem('chart-diag') === '1';
|
||||
}
|
||||
|
||||
static snapToTick(price) {
|
||||
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
|
||||
}
|
||||
|
|
@ -94,6 +112,11 @@ class ConfluenceChart {
|
|||
rightPriceScale: { borderVisible: false },
|
||||
// Daily context lives on the left, intraday on the right.
|
||||
leftPriceScale: { visible: true, borderVisible: false },
|
||||
// Never magnet. The default snaps the crosshair to the bar's *close*, so
|
||||
// hovering beside a low reads a price several ticks away, and everything
|
||||
// that snaps in this app snaps to extremes. The crosshair tracks the
|
||||
// cursor; the snap dot says where an anchor would actually land.
|
||||
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||||
});
|
||||
this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
|
||||
upColor: '#27825c', downColor: '#bd4545', borderVisible: true,
|
||||
|
|
@ -121,24 +144,42 @@ class ConfluenceChart {
|
|||
priceLineVisible: false,
|
||||
});
|
||||
this.chart.priceScale('volume').applyOptions({
|
||||
scaleMargins: { top: 0.82, bottom: 0 },
|
||||
scaleMargins: { top: 0.88, bottom: 0 },
|
||||
visible: false,
|
||||
});
|
||||
// The default leaves a tenth of the pane empty beneath the lowest bar,
|
||||
// which is where the volume draws and where a cursor tracing swing lows
|
||||
// naturally sits — far from any actual price. Give most of it back.
|
||||
this.chart.priceScale('right').applyOptions({ scaleMargins: { top: 0.12, bottom: 0.04 } });
|
||||
this.chart.priceScale('left').applyOptions({ scaleMargins: { top: 0.12, bottom: 0.04 } });
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
|
||||
requestAnimationFrame(() => this.renderAnchorHandles());
|
||||
requestAnimationFrame(() => {
|
||||
this.syncOverlayLayer();
|
||||
this.renderAnchorHandles();
|
||||
this.renderComments();
|
||||
});
|
||||
});
|
||||
this.resizeObserver.observe(el);
|
||||
// Every overlay lives in here, and this is positioned over the *plot* — not
|
||||
// the element, which also contains the price scales. Lightweight Charts
|
||||
// reports coordinates from the plot's origin, so anchoring the container
|
||||
// there lets each overlay use those coordinates untranslated. Enabling the
|
||||
// left price scale moved the plot 66px right and silently displaced the
|
||||
// snap indicator, the comments and the trendline handles by that much.
|
||||
this.overlayLayer = document.createElement('div');
|
||||
this.overlayLayer.className = 'chart-overlays';
|
||||
el.appendChild(this.overlayLayer);
|
||||
this.tooltip = document.createElement('div');
|
||||
this.tooltip.className = 'chart-tooltip';
|
||||
el.appendChild(this.tooltip);
|
||||
this.overlayLayer.appendChild(this.tooltip);
|
||||
const preview = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
preview.classList.add('chart-preview');
|
||||
preview.setAttribute('aria-hidden', 'true');
|
||||
this.previewLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
this.previewLine.setAttribute('hidden', '');
|
||||
preview.appendChild(this.previewLine);
|
||||
el.appendChild(preview);
|
||||
this.overlayLayer.appendChild(preview);
|
||||
const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
handles.classList.add('chart-handles');
|
||||
handles.setAttribute('aria-hidden', 'true');
|
||||
|
|
@ -153,7 +194,7 @@ class ConfluenceChart {
|
|||
handles.appendChild(handle);
|
||||
this.anchorHandles.push(handle);
|
||||
}
|
||||
el.appendChild(handles);
|
||||
this.overlayLayer.appendChild(handles);
|
||||
this.contextMenu = document.createElement('div');
|
||||
this.contextMenu.className = 'chart-context-menu';
|
||||
this.contextMenu.hidden = true;
|
||||
|
|
@ -166,18 +207,35 @@ class ConfluenceChart {
|
|||
});
|
||||
this.contextMenu.addEventListener('click', event => event.stopPropagation());
|
||||
this.contextMenu.appendChild(endHere);
|
||||
el.appendChild(this.contextMenu);
|
||||
this.overlayLayer.appendChild(this.contextMenu);
|
||||
this.priceTag = document.createElement('div');
|
||||
this.priceTag.className = 'chart-price-tag';
|
||||
this.priceTag.hidden = true;
|
||||
el.appendChild(this.priceTag);
|
||||
this.overlayLayer.appendChild(this.priceTag);
|
||||
this.snapDot = document.createElement('div');
|
||||
this.snapDot.className = 'chart-snap-dot';
|
||||
this.snapDot.hidden = true;
|
||||
el.appendChild(this.snapDot);
|
||||
this.overlayLayer.appendChild(this.snapDot);
|
||||
// States the price the anchor will use. Useful in itself, and it means a
|
||||
// single screenshot answers "is the dot in the wrong place, or on the
|
||||
// wrong bar?" without anyone pasting console output.
|
||||
this.snapLabel = document.createElement('div');
|
||||
this.snapLabel.className = 'chart-snap-label';
|
||||
this.snapLabel.hidden = true;
|
||||
this.overlayLayer.appendChild(this.snapLabel);
|
||||
// A leader from the cursor to the target. The snap is often far from the
|
||||
// pointer — hovering below the candles legitimately snaps up to a bar's low
|
||||
// — and without a line joining them the dot reads as unrelated to where you
|
||||
// are pointing.
|
||||
this.snapLeader = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.snapLeader.classList.add('chart-snap-leader');
|
||||
this.snapLeader.setAttribute('aria-hidden', 'true');
|
||||
this.snapLeaderLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
this.snapLeader.appendChild(this.snapLeaderLine);
|
||||
this.overlayLayer.appendChild(this.snapLeader);
|
||||
this.commentLayer = document.createElement('div');
|
||||
this.commentLayer.className = 'chart-comments';
|
||||
el.appendChild(this.commentLayer);
|
||||
this.overlayLayer.appendChild(this.commentLayer);
|
||||
this.toolDownListener = event => this.startToolGesture(event);
|
||||
this.toolMoveListener = event => this.moveToolGesture(event);
|
||||
this.toolUpListener = event => this.finishToolGesture(event);
|
||||
|
|
@ -209,6 +267,7 @@ class ConfluenceChart {
|
|||
}
|
||||
this.updateLineTooltip(param);
|
||||
});
|
||||
requestAnimationFrame(() => this.syncOverlayLayer());
|
||||
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
|
||||
this.renderAnchorHandles();
|
||||
this.renderComments();
|
||||
|
|
@ -536,17 +595,7 @@ class ConfluenceChart {
|
|||
*/
|
||||
armTool(tool) {
|
||||
this.armedTool = tool;
|
||||
// Magnet snaps the crosshair to the bar's close, so hovering by a bar's low
|
||||
// drew it mid-bar and made a correctly-placed anchor look wrong. While a
|
||||
// tool is armed the crosshair tracks the cursor and the snap dot shows
|
||||
// where the anchor will actually land.
|
||||
this.chart.applyOptions({
|
||||
handleScroll: !tool,
|
||||
handleScale: !tool,
|
||||
crosshair: {
|
||||
mode: tool ? LightweightCharts.CrosshairMode.Normal : LightweightCharts.CrosshairMode.Magnet,
|
||||
},
|
||||
});
|
||||
this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool });
|
||||
this.hideSnapDot();
|
||||
this.chartEl.classList.toggle('armed', Boolean(tool));
|
||||
this.pendingAnchor = null;
|
||||
|
|
@ -554,9 +603,40 @@ class ConfluenceChart {
|
|||
this.clearLinePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixels between the element's left edge and the plot area.
|
||||
*
|
||||
* Lightweight Charts measures coordinates from the plot, not the element, so
|
||||
* a visible left price scale shifts the two apart. Overlays are positioned
|
||||
* against the element, so every one of them must add this back. Enabling the
|
||||
* left scale for the daily labels silently moved the snap indicator, the
|
||||
* comments and the anchor handles 66px out of place.
|
||||
*/
|
||||
/** Put the overlay container exactly over the plot area. */
|
||||
syncOverlayLayer() {
|
||||
if (!this.overlayLayer || !this.chartEl) return;
|
||||
const canvas = [...this.chartEl.querySelectorAll('canvas')]
|
||||
.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0];
|
||||
if (!canvas) return;
|
||||
const chartRect = this.chartEl.getBoundingClientRect();
|
||||
const plot = canvas.getBoundingClientRect();
|
||||
this.overlayLayer.style.left = `${Math.round(plot.left - chartRect.left)}px`;
|
||||
this.overlayLayer.style.top = `${Math.round(plot.top - chartRect.top)}px`;
|
||||
this.overlayLayer.style.width = `${Math.round(plot.width)}px`;
|
||||
this.overlayLayer.style.height = `${Math.round(plot.height)}px`;
|
||||
}
|
||||
|
||||
plotOffsetX() {
|
||||
if (!this.chartEl) return 0;
|
||||
const canvas = [...this.chartEl.querySelectorAll('canvas')]
|
||||
.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0];
|
||||
if (!canvas) return 0;
|
||||
return canvas.getBoundingClientRect().left - this.chartEl.getBoundingClientRect().left;
|
||||
}
|
||||
|
||||
eventPoint(event) {
|
||||
const bounds = this.chartEl.getBoundingClientRect();
|
||||
const x = event.clientX - bounds.left;
|
||||
const x = event.clientX - bounds.left - this.plotOffsetX();
|
||||
const y = event.clientY - bounds.top;
|
||||
const price = this.candles.coordinateToPrice(y);
|
||||
if (price == null) return null;
|
||||
|
|
@ -578,7 +658,15 @@ class ConfluenceChart {
|
|||
if (!this.armedTool) return;
|
||||
const point = this.eventPoint(event);
|
||||
if (!point) return;
|
||||
if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point));
|
||||
// The listener is on window, so it fires over the sidebar and the price
|
||||
// axis too. Reported from a real session: x=1409 on a 1280-wide chart, which
|
||||
// has no time under it — and the snap fell back to the newest bar, throwing
|
||||
// the dot to the right-hand edge.
|
||||
if (!this.withinPlot(point)) {
|
||||
this.hideSnapDot();
|
||||
return;
|
||||
}
|
||||
if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point), point);
|
||||
if (this.gesture) {
|
||||
this.gesture.end = point;
|
||||
this.renderGesture();
|
||||
|
|
@ -588,7 +676,7 @@ class ConfluenceChart {
|
|||
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
|
||||
}
|
||||
|
||||
showSnapDot(snapped) {
|
||||
showSnapDot(snapped, point = {}) {
|
||||
if (snapped.t == null) return this.hideSnapDot();
|
||||
const x = this.chart.timeScale().timeToCoordinate(snapped.t);
|
||||
const y = this.candles.priceToCoordinate(snapped.p);
|
||||
|
|
@ -598,10 +686,83 @@ class ConfluenceChart {
|
|||
this.snapDot.style.top = `${Math.round(y)}px`;
|
||||
// Says which it is before you commit, since the extreme decides the side.
|
||||
this.snapDot.dataset.side = snapped.snappedSide || '';
|
||||
|
||||
// Diagnostic mode: report what the client computed so it can be read on
|
||||
// the server. Off unless asked for, because the browser is usually on a
|
||||
// different machine and this is the only way to see its numbers.
|
||||
// turn on: ?diag=1 (remembered) turn off: ?diag=0
|
||||
if (ConfluenceChart.diagnosticMode() && snapped.t != null && this.bars.length) {
|
||||
const step = this.bars.length > 1
|
||||
? this.bars[this.bars.length - 1].t - this.bars[this.bars.length - 2].t : 60;
|
||||
const drift = point.t == null ? null : Math.abs(snapped.t - point.t);
|
||||
{
|
||||
const now = Date.now();
|
||||
if (!this._lastSnapReport || now - this._lastSnapReport > 900) {
|
||||
this._lastSnapReport = now;
|
||||
const body = {
|
||||
cursor_t: point.t, cursor_p: point.p, cursor_x: Math.round(point.x),
|
||||
snapped_t: snapped.t, snapped_p: snapped.p,
|
||||
bars_held: this.bars.length,
|
||||
first_bar_t: this.bars[0].t, last_bar_t: this.bars[this.bars.length - 1].t,
|
||||
chart_w: this.chartEl.clientWidth,
|
||||
chart_h: this.chartEl.clientHeight,
|
||||
cursor_y: Math.round(point.y),
|
||||
dot_y: this.snapDot && !this.snapDot.hidden
|
||||
? Math.round(this.snapDot.getBoundingClientRect().top
|
||||
- this.chartEl.getBoundingClientRect().top) + 5
|
||||
: null,
|
||||
expected_y: Math.round(y),
|
||||
bar_low_y: (() => {
|
||||
const bar = this.bars.find(candle => candle.t === snapped.t);
|
||||
const at = bar && this.candles.priceToCoordinate(bar.l);
|
||||
return at == null ? null : Math.round(at);
|
||||
})(),
|
||||
bar_high_y: (() => {
|
||||
const bar = this.bars.find(candle => candle.t === snapped.t);
|
||||
const at = bar && this.candles.priceToCoordinate(bar.h);
|
||||
return at == null ? null : Math.round(at);
|
||||
})(),
|
||||
note: point.t == null
|
||||
? 'coordinateToTime returned null'
|
||||
: `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`,
|
||||
};
|
||||
fetch('/api/debug/snap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json',
|
||||
...(localStorage.getItem('chart-token') ? { 'X-Chart-Token': localStorage.getItem('chart-token') } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const side = snapped.snappedSide === 'resistance' ? 'H' : snapped.snappedSide === 'support' ? 'L' : '';
|
||||
const clock = new Date(snapped.t * 1000)
|
||||
.toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
this.snapLabel.hidden = false;
|
||||
this.snapLabel.dataset.side = snapped.snappedSide || '';
|
||||
this.snapLabel.textContent = `${snapped.p.toFixed(2)}${side ? ' ' + side : ''} · ${clock}`;
|
||||
// Flipped to the left near the right-hand edge so it never leaves the pane.
|
||||
const flip = x > this.chartEl.clientWidth - 120;
|
||||
this.snapLabel.style.left = `${Math.round(flip ? x - 12 : x + 12)}px`;
|
||||
this.snapLabel.style.top = `${Math.round(y)}px`;
|
||||
this.snapLabel.style.transform = flip ? 'translate(-100%, -50%)' : 'translate(0, -50%)';
|
||||
|
||||
if (point.x != null && point.y != null) {
|
||||
this.snapLeaderLine.setAttribute('x1', point.x);
|
||||
this.snapLeaderLine.setAttribute('y1', point.y);
|
||||
this.snapLeaderLine.setAttribute('x2', x);
|
||||
this.snapLeaderLine.setAttribute('y2', y);
|
||||
this.snapLeader.style.display = '';
|
||||
} else {
|
||||
this.snapLeader.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
hideSnapDot() {
|
||||
if (this.snapDot) this.snapDot.hidden = true;
|
||||
if (this.snapLabel) this.snapLabel.hidden = true;
|
||||
if (this.snapLeader) this.snapLeader.style.display = 'none';
|
||||
}
|
||||
|
||||
renderPending(a, b) {
|
||||
|
|
@ -677,11 +838,46 @@ class ConfluenceChart {
|
|||
* control you have to remember to set. An already-snapped point carries no
|
||||
* cursor y, so it is returned untouched rather than re-snapped against NaN.
|
||||
*/
|
||||
/** Is this point over the plotted area, rather than an axis or the sidebar? */
|
||||
withinPlot(point) {
|
||||
if (!this.chartEl) return true;
|
||||
const width = this.chartEl.clientWidth;
|
||||
const height = this.chartEl.clientHeight;
|
||||
return point.x >= 0 && point.x <= width && point.y >= 0 && point.y <= height;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar nearest a pixel, when the time scale will not name one.
|
||||
*
|
||||
* coordinateToTime answers null over the right-hand whitespace and the price
|
||||
* axis. Treating that as "the newest bar" put the anchor at the live edge
|
||||
* however far away the cursor was.
|
||||
*/
|
||||
barNearestX(x) {
|
||||
let best = null;
|
||||
const timeScale = this.chart.timeScale();
|
||||
for (const bar of this.bars) {
|
||||
const coordinate = timeScale.timeToCoordinate(bar.t);
|
||||
if (coordinate == null) continue;
|
||||
const distance = Math.abs(coordinate - x);
|
||||
if (!best || distance < best.distance) best = { distance, t: bar.t };
|
||||
}
|
||||
return best ? best.t : null;
|
||||
}
|
||||
|
||||
snapPoint(point) {
|
||||
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;
|
||||
const fallbackT = point.t ?? (point.x == null ? null : this.barNearestX(point.x));
|
||||
const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null };
|
||||
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
|
||||
// An already-snapped point carries no cursor position; return it untouched
|
||||
// rather than measuring against undefined.
|
||||
if (point.y == null) return base;
|
||||
|
||||
// Horizontal position chooses the bar, vertical position chooses which of
|
||||
// its extremes. Nothing else — a nearest-in-2D search was tried and is
|
||||
// wrong: sweeping along the bottom of the chart, whichever nearby bar had
|
||||
// the lowest low won on total distance, so the dot skipped off the bar
|
||||
// under the cursor entirely instead of tracing each bar's low in turn.
|
||||
const nearest = this.bars.reduce(
|
||||
(best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best),
|
||||
);
|
||||
|
|
@ -695,6 +891,7 @@ class ConfluenceChart {
|
|||
return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
|
||||
}
|
||||
|
||||
|
||||
renderGesture() {
|
||||
if (!this.gesture) return;
|
||||
const { start, end } = this.gesture;
|
||||
|
|
@ -918,7 +1115,7 @@ class ConfluenceChart {
|
|||
if (!level || !this.bars.length) return;
|
||||
event.preventDefault();
|
||||
const bounds = this.chartEl.getBoundingClientRect();
|
||||
const x = event.clientX - bounds.left;
|
||||
const x = event.clientX - bounds.left - this.plotOffsetX();
|
||||
const rawTime = this.chart.timeScale().coordinateToTime(x);
|
||||
if (rawTime == null) return;
|
||||
const cutoff = this.bars.reduce((nearest, bar) =>
|
||||
|
|
|
|||
|
|
@ -78,3 +78,6 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
|
|||
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
|
||||
.side-auto { align-self:end; padding-bottom:6px; font-size:10px; color:var(--muted); }.side-auto b { color:var(--fg); font-weight:600; }
|
||||
.chart-snap-dot { position:absolute; width:9px; height:9px; margin:-5px 0 0 -5px; border-radius:50%; border:2px solid var(--accent); background:var(--chart-bg); pointer-events:none; z-index:5; }.chart-snap-dot[data-side=resistance] { border-color:#bd4545; }.chart-snap-dot[data-side=support] { border-color:#27825c; }
|
||||
.chart-snap-label { position:absolute; padding:2px 5px; border-radius:3px; pointer-events:none; z-index:6; font-size:10px; white-space:nowrap; background:var(--chart-bg); border:1px solid var(--muted); color:var(--fg); }.chart-snap-label[data-side=resistance] { border-color:#bd4545; }.chart-snap-label[data-side=support] { border-color:#27825c; }
|
||||
.chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; }
|
||||
.chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:auto; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; }
|
||||
|
|
|
|||
62
tests/e2e/chart.test.mjs
Normal file
62
tests/e2e/chart.test.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* The chart shows the right window, at the right density, without throwing.
|
||||
*
|
||||
* Every assertion here failed at some point on 2026-08-10 while all 96 Python
|
||||
* tests passed, which is the whole argument for this file existing.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { withChart, chartState, selectTimeframe, assertNoPageErrors } from './helpers.mjs';
|
||||
|
||||
const TIMEFRAMES = ['1m', '5m', '15m', '30m', '1h', '1d'];
|
||||
|
||||
test('the viewport opens on the live edge, not in the past', { timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const state = await chartState(page);
|
||||
assert.ok(state.bars > 0, 'the chart loaded no bars at all');
|
||||
// The bug: setBars placed the viewport by logical index, and the daily MA
|
||||
// series then prepended hundreds of points, sliding the view ~10 hours back
|
||||
// while the data itself was perfectly current.
|
||||
assert.equal(
|
||||
state.visibleTo, state.lastTime,
|
||||
'the newest bar is not the right-hand edge of the viewport',
|
||||
);
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('every timeframe gives one slot per candle', { timeout: 300000 }, async () => {
|
||||
await withChart(async page => {
|
||||
for (const tf of TIMEFRAMES) {
|
||||
await selectTimeframe(page, tf);
|
||||
const state = await chartState(page);
|
||||
// The bug: a minute-resolution VWAP put its own points on the shared time
|
||||
// scale, so an hourly chart spread 160 candles over 908 slots and drew
|
||||
// them as slivers. Anything much above 1.0 means the scale is polluted.
|
||||
const ratio = state.slots / Math.max(state.inView, 1);
|
||||
assert.ok(
|
||||
ratio > 0.8 && ratio < 1.3,
|
||||
`${tf}: ${state.inView} candles spread over ${state.slots} slots (ratio ${ratio.toFixed(2)})`,
|
||||
);
|
||||
}
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('switching timeframes rapidly never throws', { timeout: 300000 }, async () => {
|
||||
await withChart(async page => {
|
||||
// The bug: bar events for the timeframe just left arrived after the new
|
||||
// snapshot, and Lightweight Charts throws "Cannot update oldest data"
|
||||
// rather than ignoring a stale bar, which took the whole app down.
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
for (const tf of ['1m', '1h', '15m', '1d', '5m']) {
|
||||
await page.click(`.timeframes button:text-is("${tf}")`);
|
||||
await page.waitForTimeout(700);
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(2000);
|
||||
const state = await chartState(page);
|
||||
assert.ok(state.bars > 0, 'the chart lost its data while switching');
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
110
tests/e2e/comments.test.mjs
Normal file
110
tests/e2e/comments.test.mjs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Comments stay where they were put, across zoom, scroll and timeframe.
|
||||
*
|
||||
* A comment is stored as a drawing but must never behave like a level, so the
|
||||
* last test here guards the invariant that matters most: a note on a chart can
|
||||
* never end up in a confluence cluster or fire a push notification.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
withChart, chartBox, at, armTool, selectTimeframe, assertNoPageErrors,
|
||||
} from './helpers.mjs';
|
||||
|
||||
const TEXT = 'e2e placement check';
|
||||
|
||||
/** Place a comment at a point on the chart. */
|
||||
async function placeComment(page, box, fx, fy, { floating = false } = {}) {
|
||||
await armTool(page, 'Comment');
|
||||
await page.fill('textarea[aria-label="Comment text"]', TEXT);
|
||||
if (floating) {
|
||||
await page.evaluate(() => {
|
||||
const body = [...document.querySelectorAll('.tool-body')].find(b => b.querySelector('textarea'));
|
||||
const box_ = body.querySelector('input[type=checkbox]');
|
||||
if (!box_.checked) box_.click();
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
const point = at(box, fx, fy);
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.mouse.down(); await page.mouse.up();
|
||||
await page.waitForTimeout(1800);
|
||||
}
|
||||
|
||||
const commentNode = page => page.evaluate(text => {
|
||||
const node = [...document.querySelectorAll('.chart-comment')].find(n => n.title === text);
|
||||
if (!node) return null;
|
||||
return {
|
||||
left: Math.round(node.getBoundingClientRect().left),
|
||||
collapsed: node.classList.contains('collapsed'),
|
||||
parked: node.classList.contains('off-left') || node.classList.contains('off-right'),
|
||||
};
|
||||
}, TEXT);
|
||||
|
||||
test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const box = await chartBox(page);
|
||||
await selectTimeframe(page, '30m');
|
||||
await placeComment(page, box, 0.55, 0.40);
|
||||
|
||||
const anchor = await page.evaluate(t => {
|
||||
const c = window.__chart.comments.find(x => x.note === t);
|
||||
return c ? c.anchor_t : null;
|
||||
}, TEXT);
|
||||
assert.ok(anchor, 'the comment was not created');
|
||||
|
||||
// The bug: timeToCoordinate answers only for exact data points, so a 30m
|
||||
// bucket returned null on 15m and null was read as "off the left edge".
|
||||
for (const tf of ['15m', '1h', '30m']) {
|
||||
await selectTimeframe(page, tf);
|
||||
const node = await commentNode(page);
|
||||
assert.ok(node, `the comment vanished on ${tf}`);
|
||||
assert.equal(node.parked, false, `the comment was edge-parked on ${tf} despite being in view`);
|
||||
}
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('clicking a comment collapses it', { timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const box = await chartBox(page);
|
||||
await placeComment(page, box, 0.5, 0.35);
|
||||
assert.equal((await commentNode(page)).collapsed, false);
|
||||
// By title, not by class: the dev store is shared, so other people's
|
||||
// comments are on the chart too and `.chart-comment` matches them first.
|
||||
await page.click(`.chart-comment[title="${TEXT}"]`);
|
||||
await page.waitForTimeout(1200);
|
||||
assert.equal((await commentNode(page)).collapsed, true, 'clicking did not collapse it');
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('a floating comment holds its position while the chart scrolls', { timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const box = await chartBox(page);
|
||||
await placeComment(page, box, 0.4, 0.3, { floating: true });
|
||||
const before = await commentNode(page);
|
||||
assert.ok(before, 'the floating comment was not created');
|
||||
await page.evaluate(() => window.__chart.chart.timeScale().scrollToPosition(-300, false));
|
||||
await page.waitForTimeout(1200);
|
||||
const after = await commentNode(page);
|
||||
assert.equal(after.left, before.left, 'a floating comment moved with the chart');
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('a comment is never a level', { timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const box = await chartBox(page);
|
||||
await placeComment(page, box, 0.5, 0.5);
|
||||
// Levels cluster and alert. A comment reaching that list would push a
|
||||
// phone notification about a piece of text.
|
||||
const leaked = await page.evaluate(t => {
|
||||
const note = window.__chart.comments.find(x => x.note === t);
|
||||
if (!note) return 'comment missing';
|
||||
return window.__chart.levels.some(level => level.id === note.id);
|
||||
}, TEXT);
|
||||
assert.equal(leaked, false, 'a comment appeared in the level list');
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
161
tests/e2e/helpers.mjs
Normal file
161
tests/e2e/helpers.mjs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* Shared plumbing for the end-to-end tests.
|
||||
*
|
||||
* These run inside the `playwright` compose service, which has Playwright
|
||||
* installed globally and can reach the app as http://api:8000. Nothing here
|
||||
* needs npm install in this repo — see bin/e2e.
|
||||
*/
|
||||
// Playwright is installed globally in the container, not in this repo. ESM
|
||||
// ignores NODE_PATH, so it is resolved through a CommonJS require, which
|
||||
// honours it — see bin/e2e.
|
||||
import { createRequire } from 'node:module';
|
||||
const { chromium } = createRequire(import.meta.url)('playwright');
|
||||
|
||||
export const APP_URL = process.env.E2E_URL || 'http://api:8000/';
|
||||
const TOKEN_KEY = 'chart-token';
|
||||
|
||||
/**
|
||||
* Launch a browser pointed at the chart.
|
||||
*
|
||||
* `--lang=en-US` is not optional. The container has no usable locale, so
|
||||
* Chromium reports `en-US@posix`, Intl throws inside the charting library, and
|
||||
* every test sees a blank canvas that looks exactly like a broken app.
|
||||
*/
|
||||
export async function launch() {
|
||||
const browser = await chromium.launch({
|
||||
args: ['--lang=en-US'],
|
||||
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' },
|
||||
});
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1600, height: 1000 },
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/Chicago',
|
||||
});
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error.message)));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
page.__errors = errors;
|
||||
return { browser, page };
|
||||
}
|
||||
|
||||
/** Open the app and wait until the chart has bars and levels. */
|
||||
export async function openChart(page) {
|
||||
await page.goto(APP_URL, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__chart && window.__chart.bars && window.__chart.bars.length > 0,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
// The socket delivers a second snapshot on subscribe; let it settle so the
|
||||
// viewport is the one the app actually ends up showing.
|
||||
await page.waitForTimeout(3000);
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `body` against a fresh browser, then close it.
|
||||
*
|
||||
* Any drawing created during the test is deleted afterwards, whatever the
|
||||
* outcome. The dev stack shares one drawing store with whoever is using the
|
||||
* app, so a test that leaves debris leaves it in someone's sidebar.
|
||||
*/
|
||||
export async function withChart(body) {
|
||||
const { browser, page } = await launch();
|
||||
let before = [];
|
||||
try {
|
||||
await openChart(page);
|
||||
before = (await drawingIds(page));
|
||||
await body(page);
|
||||
} finally {
|
||||
try {
|
||||
const after = await drawingIds(page);
|
||||
const created = after.filter(id => !before.includes(id));
|
||||
if (created.length) await deleteDrawings(page, created);
|
||||
} catch { /* the page may already be gone; nothing to clean */ }
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Ids of every drawing the server currently holds. */
|
||||
export async function drawingIds(page) {
|
||||
return page.evaluate(async key => {
|
||||
const token = localStorage.getItem(key) || '';
|
||||
const response = await fetch('/api/drawings', { headers: token ? { 'X-Chart-Token': token } : {} });
|
||||
if (!response.ok) return [];
|
||||
return (await response.json()).drawings.map(drawing => drawing.id);
|
||||
}, TOKEN_KEY);
|
||||
}
|
||||
|
||||
export async function deleteDrawings(page, ids) {
|
||||
return page.evaluate(async ([list, key]) => {
|
||||
const token = localStorage.getItem(key) || '';
|
||||
for (const id of list) {
|
||||
await fetch(`/api/lines/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE', headers: token ? { 'X-Chart-Token': token } : {},
|
||||
});
|
||||
}
|
||||
}, [ids, TOKEN_KEY]);
|
||||
}
|
||||
|
||||
/** Pixel box of the chart pane, for turning fractions into mouse positions. */
|
||||
export async function chartBox(page) {
|
||||
return page.evaluate(() => {
|
||||
const rect = document.querySelector('#chart').getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, w: rect.width, h: rect.height };
|
||||
});
|
||||
}
|
||||
|
||||
/** A point inside the chart, given as fractions of its width and height. */
|
||||
export function at(box, fx, fy) {
|
||||
return { x: box.x + box.w * fx, y: box.y + box.h * fy };
|
||||
}
|
||||
|
||||
export async function selectTimeframe(page, tf) {
|
||||
await page.click(`.timeframes button:text-is("${tf}")`);
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
export async function armTool(page, name) {
|
||||
await page.click(`.tool-head:has-text("${name}")`);
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
/** The newest hand-drawn sloped line, with the bar its anchor sits on. */
|
||||
export async function newestTrendline(page) {
|
||||
return page.evaluate(() => {
|
||||
const lines = window.__chart.levels.filter(l => l.kind === 'manual' && l.slope !== 0);
|
||||
const line = lines.sort((a, b) => b.number - a.number)[0];
|
||||
if (!line) return null;
|
||||
const bar = window.__chart.bars.find(b => b.t === line.anchor_t) || null;
|
||||
return {
|
||||
id: line.id, anchor_t: line.anchor_t, anchor_p: line.anchor_p, side: line.side,
|
||||
onExtreme: bar ? (line.anchor_p === bar.h || line.anchor_p === bar.l) : null,
|
||||
snappedTo: bar ? (line.anchor_p === bar.h ? 'high' : line.anchor_p === bar.l ? 'low' : 'neither') : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** What the chart is currently showing: series, viewport, and slot density. */
|
||||
export async function chartState(page) {
|
||||
return page.evaluate(() => {
|
||||
const chart = window.__chart;
|
||||
const data = chart.candles.data();
|
||||
const visible = chart.chart.timeScale().getVisibleRange();
|
||||
const logical = chart.chart.timeScale().getVisibleLogicalRange();
|
||||
const inView = visible ? data.filter(d => d.time >= visible.from && d.time <= visible.to).length : 0;
|
||||
return {
|
||||
bars: data.length,
|
||||
lastTime: data.length ? data[data.length - 1].time : null,
|
||||
lastClose: data.length ? data[data.length - 1].close : null,
|
||||
inView,
|
||||
slots: logical ? Math.round(logical.to - logical.from) : 0,
|
||||
visibleFrom: visible ? visible.from : null,
|
||||
visibleTo: visible ? visible.to : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Fails the test if the page logged any error, with the first few quoted. */
|
||||
export function assertNoPageErrors(page, assert) {
|
||||
const unique = [...new Set(page.__errors)];
|
||||
assert.deepStrictEqual(unique, [], `page errors: ${unique.slice(0, 3).join(' | ')}`);
|
||||
}
|
||||
229
tests/e2e/trendline.test.mjs
Normal file
229
tests/e2e/trendline.test.mjs
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Trendline placement: the line must start where the cursor said it would.
|
||||
*
|
||||
* Both idioms are supported — press-drag-release in one gesture, or click to
|
||||
* set an anchor and click again to finish — and each has shipped a bug where
|
||||
* the committed line disagreed with its own preview.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
withChart, chartBox, at, armTool, newestTrendline, assertNoPageErrors,
|
||||
} from './helpers.mjs';
|
||||
|
||||
/**
|
||||
* Time under a given page x.
|
||||
*
|
||||
* Coordinates go through the plot's origin, not the element's — a visible left
|
||||
* price scale sits between the two, and reading the scale with an
|
||||
* element-relative x is exactly the bug these tests exist to catch.
|
||||
*/
|
||||
const timeAt = (page, box, point) =>
|
||||
page.evaluate(x => {
|
||||
const c = window.__chart;
|
||||
return Number(c.chart.timeScale().coordinateToTime(x - c.plotOffsetX()));
|
||||
}, point.x - box.x);
|
||||
|
||||
test('a click, a move, then a click starts at the first click', { timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const box = await chartBox(page);
|
||||
await armTool(page, 'Trendline');
|
||||
const first = at(box, 0.30, 0.50);
|
||||
const second = at(box, 0.55, 0.35);
|
||||
|
||||
await page.mouse.move(first.x, first.y);
|
||||
await page.mouse.down(); await page.mouse.up();
|
||||
await page.waitForTimeout(400);
|
||||
await page.mouse.move(second.x, second.y);
|
||||
// A few pixels of travel while pressing: this is a click, not a drag. At a
|
||||
// 5px threshold it counted as one, which threw the placed anchor away and
|
||||
// restarted the line under the cursor.
|
||||
await page.mouse.down(); await page.mouse.move(second.x + 4, second.y + 3); await page.mouse.up();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const line = await newestTrendline(page);
|
||||
assert.ok(line, 'no trendline was created');
|
||||
const expected = await timeAt(page, box, first);
|
||||
assert.ok(
|
||||
Math.abs(line.anchor_t - expected) < 400,
|
||||
`anchored at ${line.anchor_t}, expected the first click near ${expected}`,
|
||||
);
|
||||
assert.equal(line.onExtreme, true, `anchor ${line.anchor_p} is not a bar extreme`);
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('a real drag ignores an anchor left over from an abandoned click', { timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const box = await chartBox(page);
|
||||
await armTool(page, 'Trendline');
|
||||
const abandoned = at(box, 0.20, 0.70);
|
||||
const from = at(box, 0.62, 0.30);
|
||||
const to = at(box, 0.80, 0.55);
|
||||
|
||||
await page.mouse.move(abandoned.x, abandoned.y);
|
||||
await page.mouse.down(); await page.mouse.up();
|
||||
await page.waitForTimeout(400);
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down(); await page.mouse.move(to.x, to.y, { steps: 8 }); await page.mouse.up();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const line = await newestTrendline(page);
|
||||
assert.ok(line, 'no trendline was created');
|
||||
const expected = await timeAt(page, box, from);
|
||||
const stale = await timeAt(page, box, abandoned);
|
||||
// The regression this guards: making a pending anchor always win meant a
|
||||
// genuine drag was hijacked by the click before it, starting the line far
|
||||
// from where the drag began. It shipped to production.
|
||||
assert.ok(
|
||||
Math.abs(line.anchor_t - stale) > 400,
|
||||
'the line started at the abandoned click instead of the drag',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(line.anchor_t - expected) < 600,
|
||||
`anchored at ${line.anchor_t}, expected the drag start near ${expected}`,
|
||||
);
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('the snapped extreme decides the side, overriding the dropdown', { timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const box = await chartBox(page);
|
||||
const dropdownDefault = await page.evaluate(() => {
|
||||
const app = document.querySelector('.tool-head');
|
||||
return app ? 'support' : 'support';
|
||||
});
|
||||
assert.equal(dropdownDefault, 'support');
|
||||
|
||||
// Drawn above the candles, so the nearest extreme is a bar high, so the
|
||||
// side must come out as resistance despite the dropdown saying support.
|
||||
await armTool(page, 'Trendline');
|
||||
const high1 = at(box, 0.40, 0.15);
|
||||
const high2 = at(box, 0.60, 0.15);
|
||||
await page.mouse.move(high1.x, high1.y); await page.mouse.down(); await page.mouse.up();
|
||||
await page.waitForTimeout(400);
|
||||
await page.mouse.move(high2.x, high2.y); await page.mouse.down(); await page.mouse.up();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const line = await newestTrendline(page);
|
||||
assert.ok(line, 'no trendline was created');
|
||||
assert.equal(line.snappedTo, 'high', `snapped to ${line.snappedTo}, not the bar high`);
|
||||
assert.equal(line.side, 'resistance', 'a high must infer resistance');
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('hovering shows where the anchor will land', { timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const box = await chartBox(page);
|
||||
await armTool(page, 'Trendline');
|
||||
const point = at(box, 0.45, 0.40);
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Without this the only feedback was the crosshair, which magnets to the
|
||||
// bar's close and so pointed somewhere the anchor would never go.
|
||||
const dot = await page.evaluate(() => {
|
||||
const node = document.querySelector('.chart-snap-dot');
|
||||
return node ? { shown: !node.hidden, side: node.dataset.side || null } : null;
|
||||
});
|
||||
assert.ok(dot && dot.shown, 'no snap indicator while a tool is armed');
|
||||
assert.ok(['support', 'resistance'].includes(dot.side), `snap dot side was ${dot?.side}`);
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('sweeping the bottom traces the low of each bar under the cursor',
|
||||
{ timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
await page.click('.timeframes button:text-is("1h")');
|
||||
await page.waitForTimeout(3500);
|
||||
// Zoomed out to a few pixels per bar, which is where this fell apart.
|
||||
await page.evaluate(() => {
|
||||
const c = window.__chart, ts = c.chart.timeScale();
|
||||
ts.setVisibleRange({
|
||||
from: c.bars[Math.max(0, c.bars.length - 360)].t,
|
||||
to: c.bars[c.bars.length - 1].t,
|
||||
});
|
||||
});
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// The rule, stated once: x picks the bar, y picks which extreme. Running
|
||||
// the cursor along the bottom must therefore trace each bar's low. A
|
||||
// nearest-in-2D search was tried instead and broke exactly this — the
|
||||
// lowest low nearby won on total distance and the dot skipped off the bar
|
||||
// under the cursor.
|
||||
const result = await page.evaluate(() => {
|
||||
const c = window.__chart, ts = c.chart.timeScale();
|
||||
const y = document.querySelector('#chart').clientHeight * 0.93;
|
||||
// x values below are plot-relative, matching what snapPoint expects.
|
||||
let hits = 0, total = 0;
|
||||
const misses = [];
|
||||
for (let x = 200; x < 1000; x += 7) {
|
||||
const t = ts.coordinateToTime(x);
|
||||
if (t == null) continue;
|
||||
const under = c.bars.reduce(
|
||||
(best, bar) => (Math.abs(bar.t - Number(t)) < Math.abs(best.t - Number(t)) ? bar : best));
|
||||
const snapped = c.snapPoint({ x, y, p: c.candles.coordinateToPrice(y), t: Number(t) });
|
||||
total += 1;
|
||||
if (snapped.t === under.t && snapped.p === under.l) hits += 1;
|
||||
else if (misses.length < 3) misses.push({ x, wanted: under.l, got: snapped.p });
|
||||
}
|
||||
return { hits, total, misses };
|
||||
});
|
||||
assert.ok(result.total > 50, 'the sweep did not probe enough positions');
|
||||
assert.equal(result.hits, result.total,
|
||||
`only ${result.hits}/${result.total} landed on the low of the bar under the cursor: ` +
|
||||
JSON.stringify(result.misses));
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('the crosshair never magnets to the close', { timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
// Magnet mode snaps the crosshair to the bar's close, so hovering beside a
|
||||
// low reads a price several ticks from the one that would be used.
|
||||
const mode = await page.evaluate(() => window.__chart.chart.options().crosshair.mode);
|
||||
assert.equal(mode, 0, 'crosshair is not in Normal mode');
|
||||
});
|
||||
});
|
||||
|
||||
test('the snap never leaps to the live edge when the cursor leaves the plot',
|
||||
{ timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
await armTool(page, 'Trendline');
|
||||
const box = await chartBox(page);
|
||||
const dotState = async () => page.evaluate(() => {
|
||||
const dot = document.querySelector('.chart-snap-dot');
|
||||
if (!dot || dot.hidden) return { hidden: true };
|
||||
const chart = document.querySelector('#chart');
|
||||
const left = dot.getBoundingClientRect().left - chart.getBoundingClientRect().left;
|
||||
const c = window.__chart;
|
||||
const lastX = c.chart.timeScale().timeToCoordinate(c.bars[c.bars.length - 1].t);
|
||||
return { hidden: false, atLiveEdge: Math.abs(left - lastX) < 6 };
|
||||
});
|
||||
|
||||
// Reported from a real session: coordinateToTime answers null over the
|
||||
// price axis, the whitespace past the last bar, and anywhere outside the
|
||||
// chart — and the fallback was "the newest bar", which threw the anchor
|
||||
// to the live edge from wherever the cursor actually was.
|
||||
await page.mouse.move(box.x + box.w * 0.5, box.y + box.h * 0.4);
|
||||
await page.waitForTimeout(400);
|
||||
const onBar = await dotState();
|
||||
assert.equal(onBar.hidden, false, 'no snap indicator over a bar');
|
||||
assert.equal(onBar.atLiveEdge, false, 'snapped to the live edge from mid-chart');
|
||||
|
||||
// The listener is on window, so it sees the sidebar too.
|
||||
await page.mouse.move(box.x + box.w + 120, box.y + box.h * 0.4);
|
||||
await page.waitForTimeout(400);
|
||||
assert.equal((await dotState()).hidden, true, 'the dot survived leaving the chart');
|
||||
|
||||
await page.mouse.move(box.x + box.w * 0.35, box.y + box.h * 0.5);
|
||||
await page.waitForTimeout(400);
|
||||
const back = await dotState();
|
||||
assert.equal(back.hidden, false, 'the dot did not come back');
|
||||
assert.equal(back.atLiveEdge, false, 'snapped to the live edge after returning');
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue