chartremotely 0.2.10__tar.gz → 0.2.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {chartremotely-0.2.10 → chartremotely-0.2.11}/PKG-INFO +1 -1
  2. chartremotely-0.2.11/chartremotely/popups.py +258 -0
  3. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/symbol.py +11 -1
  4. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/timeframe.py +8 -7
  5. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/window.py +5 -1
  6. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely.egg-info/PKG-INFO +1 -1
  7. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely.egg-info/SOURCES.txt +2 -0
  8. {chartremotely-0.2.10 → chartremotely-0.2.11}/pyproject.toml +1 -1
  9. chartremotely-0.2.11/tests/test_popups.py +132 -0
  10. {chartremotely-0.2.10 → chartremotely-0.2.11}/LICENSE +0 -0
  11. {chartremotely-0.2.10 → chartremotely-0.2.11}/README.md +0 -0
  12. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/__init__.py +0 -0
  13. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/answer.py +0 -0
  14. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/assets/shortcut-template.plist +0 -0
  15. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/ax.py +0 -0
  16. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/cli.py +0 -0
  17. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/config.py +0 -0
  18. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/doctor.py +0 -0
  19. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/guilock.py +0 -0
  20. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/keystore.py +0 -0
  21. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/layout.py +0 -0
  22. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/mcpclient.py +0 -0
  23. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/patron.py +0 -0
  24. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/push.py +0 -0
  25. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/registry.py +0 -0
  26. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/relay.py +0 -0
  27. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/requestlog.py +0 -0
  28. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/resolve.py +0 -0
  29. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/scales.py +0 -0
  30. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/screen.py +0 -0
  31. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/server.py +0 -0
  32. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/services.py +0 -0
  33. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/setup.py +0 -0
  34. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/shortcut.py +0 -0
  35. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/snapshot.py +0 -0
  36. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/studies.py +0 -0
  37. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/tailnet.py +0 -0
  38. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/vocab.py +0 -0
  39. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely.egg-info/dependency_links.txt +0 -0
  40. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely.egg-info/entry_points.txt +0 -0
  41. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely.egg-info/requires.txt +0 -0
  42. {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely.egg-info/top_level.txt +0 -0
  43. {chartremotely-0.2.10 → chartremotely-0.2.11}/setup.cfg +0 -0
  44. {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_patron.py +0 -0
  45. {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_push.py +0 -0
  46. {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_relay.py +0 -0
  47. {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_requestlog.py +0 -0
  48. {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_resolve.py +0 -0
  49. {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_screen.py +0 -0
  50. {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_setup.py +0 -0
  51. {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_snapshot.py +0 -0
  52. {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_stamp_changelog.py +0 -0
  53. {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_timeframe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.10
3
+ Version: 0.2.11
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -0,0 +1,258 @@
1
+ """Close thinkorswim's popups before anyone looks at the chart.
2
+
3
+ Two popups get left open over the chart, and a picture taken then shows them:
4
+
5
+ * the symbol field's autocomplete list (PLTR:XTSE, PLTRX, ...), which hangs
6
+ under the field after a symbol is typed and often outlives the Return that
7
+ committed it;
8
+ * the aggregation (time frame) menu, which stays open after a preset is
9
+ picked or the current one is read.
10
+
11
+ Neither answers the obvious remedies. An Escape posted to thinkorswim lands on
12
+ whatever holds keyboard focus, which after a commit is nothing at all - so it
13
+ is silently dropped. What works, measured on thinkorswim build 1993:
14
+
15
+ * the aggregation menu closes when its toggle is pressed again (``AXPress``);
16
+ the toggle's ``AXValue`` is 1 while the menu shows;
17
+ * the autocomplete list closes on Escape once the symbol field has focus.
18
+ Escape only hides the list: text typed but not yet committed stays in the
19
+ field, so it never cancels an entry;
20
+ * focus leaves the field when the chart's own title strip (the line under
21
+ the field reading "PLTR 10 D 30m") is focused. Setting the field's
22
+ ``AXFocused`` to false is accepted and ignored.
23
+
24
+ A Swing field commits its text when it loses focus, so focus is moved off the
25
+ field only when that text is what the chart already shows. Otherwise the field
26
+ keeps focus and its pending text, and nothing changes.
27
+
28
+ The decisions are pure and live at module scope, over a :class:`Seen`
29
+ snapshot, so they are tested where PyObjC is absent. :func:`dismiss` gathers
30
+ the snapshots and acts; its macOS imports are deferred.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import re
36
+ import time
37
+ from collections.abc import Callable
38
+ from dataclasses import dataclass
39
+
40
+ Rect = tuple[float, float, float, float]
41
+
42
+ #: Longest dismiss() spends closing popups.
43
+ TIMEOUT = 1.5
44
+ #: How often dismiss() looks again after acting.
45
+ POLL = 0.1
46
+ #: Longest dismiss() waits for one action to show before trying it again.
47
+ ACT_WAIT = 0.5
48
+ #: Points of slack when deciding a list hangs from the symbol field.
49
+ SLACK = 8
50
+ #: How far under the field's bottom edge to hit-test for a list or the title.
51
+ PROBE_DY, PROBE_DX = 8, 8
52
+
53
+ #: The first word of the chart's title strip: the symbol it is drawn for.
54
+ CHART_SYMBOL = re.compile(r"^[./$^]?[A-Z0-9]{1,12}([/.][A-Z0-9]{1,3})?(:[A-Z]+)?$")
55
+
56
+ CLOSE_SCALE, CLOSE_LIST, LEAVE_FIELD = "close scale menu", "close symbol list", "leave field"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Seen:
61
+ """What the chart's popups and symbol field looked like at one moment."""
62
+
63
+ scale_open: bool = False
64
+ list_open: bool = False
65
+ field_focused: bool = False
66
+ field_value: str | None = None
67
+ #: The chart's title strip, e.g. "PLTR 10 D 30m"; None while a list covers it.
68
+ title: str | None = None
69
+
70
+ @property
71
+ def clean(self) -> bool:
72
+ """Nothing is drawn over the chart."""
73
+ return not (self.scale_open or self.list_open)
74
+
75
+
76
+ def hangs_under(field: Rect, table: Rect | None) -> bool:
77
+ """Whether ``table`` is the autocomplete list dropped from the symbol field.
78
+
79
+ thinkorswim paints that list inside the window, so the window server never
80
+ sees it; the accessibility tree reports it as a table whose top-left corner
81
+ sits on the field's bottom-left corner. ``field`` is the field's combo box.
82
+ """
83
+ if table is None:
84
+ return False
85
+ fx, fy, _, fh = field
86
+ tx, ty, tw, th = table
87
+ return (tw > 0 and th > 0 and abs(tx - fx) <= SLACK
88
+ and abs(ty - (fy + fh)) <= SLACK)
89
+
90
+
91
+ def chart_symbol(title: str | None) -> str | None:
92
+ """The symbol a chart title strip names ("PLTR 10 D 30m" -> "PLTR"), or None."""
93
+ if not isinstance(title, str) or not title.strip():
94
+ return None
95
+ word = title.split()[0]
96
+ return word if CHART_SYMBOL.match(word) else None
97
+
98
+
99
+ def shows(title: str | None, entry: str | None) -> bool:
100
+ """Whether the chart already shows what ``entry`` names.
101
+
102
+ A futures root typed as "/ES" is drawn as its front contract ("/ESZ26"),
103
+ so a root counts as shown by any of its contracts.
104
+ """
105
+ drawn = chart_symbol(title)
106
+ want = (entry or "").strip().upper()
107
+ if not drawn or not want:
108
+ return False
109
+ return drawn == want or (want.startswith("/") and drawn.startswith(want))
110
+
111
+
112
+ def next_step(seen: Seen) -> str | None:
113
+ """The one thing to do next, or None when there is nothing safe left to do.
114
+
115
+ The menu is closed before the list: its toggle is pressed through the
116
+ tree, which needs no focus, whereas closing the list moves focus into the
117
+ field. Focus leaves the field only when the chart already shows its text,
118
+ because losing focus commits whatever the field holds.
119
+ """
120
+ if seen.scale_open:
121
+ return CLOSE_SCALE
122
+ if seen.list_open:
123
+ return CLOSE_LIST
124
+ if seen.field_focused and shows(seen.title, seen.field_value):
125
+ return LEAVE_FIELD
126
+ return None
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class Dismissed:
131
+ """What dismiss() did, and whether the chart was left uncovered."""
132
+
133
+ did: tuple[str, ...] = ()
134
+ clean: bool = True
135
+
136
+ def __bool__(self) -> bool:
137
+ return bool(self.did)
138
+
139
+ def __str__(self) -> str:
140
+ done = "; ".join(self.did) or "nothing open"
141
+ return done if self.clean else done + "; a popup is still open"
142
+
143
+
144
+ def run(look: Callable[[], Seen], act: Callable[[str], None], *,
145
+ until: Callable[[Seen], bool] | None = None, timeout: float = TIMEOUT,
146
+ clock: Callable[[], float] = time.monotonic,
147
+ sleep: Callable[[float], None] = time.sleep) -> Dismissed:
148
+ """Look, act on the next step, and look again until nothing is left.
149
+
150
+ ``until``, when given, must also hold before it stops early: a symbol just
151
+ committed is waited for until the chart's title names it, so focus can
152
+ leave the field once it is safe to. One action is retried only after it
153
+ has had ACT_WAIT to show, so a slow repaint is not answered with a second
154
+ press of a toggle.
155
+ """
156
+ deadline = clock() + timeout
157
+ did: list[str] = []
158
+ last: tuple[str | None, float] = (None, -ACT_WAIT)
159
+ while True:
160
+ seen = look()
161
+ step = next_step(seen)
162
+ now = clock()
163
+ if step is None and (until is None or until(seen)) or now >= deadline:
164
+ return Dismissed(tuple(did), seen.clean)
165
+ if step is not None and (step != last[0] or now - last[1] >= ACT_WAIT):
166
+ act(step)
167
+ if step not in did:
168
+ did.append(step)
169
+ last = (step, now)
170
+ sleep(POLL)
171
+
172
+
173
+ # --------------------------------------------------------------------------
174
+ # macOS. Imported lazily so the decisions above stay importable anywhere.
175
+
176
+
177
+ def _frame(ax, element) -> Rect | None:
178
+ p, s = ax.position(element), ax.size(element)
179
+ return (p.x, p.y, s.width, s.height) if p and s else None
180
+
181
+
182
+ def _under_field(ax, ax_app, field):
183
+ """The combo's frame, the element just under it, and the list that is, if any."""
184
+ combo = ax.attr(field, "AXParent")
185
+ frame = _frame(ax, combo) if combo is not None else None
186
+ if frame is None:
187
+ return None, None, None
188
+ x, y, _, h = frame
189
+ hit = ax.element_at(ax_app, x + PROBE_DX, y + h + PROBE_DY)
190
+ table = None
191
+ for element in (hit, ax.attr(hit, "AXParent") if hit is not None else None):
192
+ if element is not None and ax.attr(element, "AXRole") == "AXTable":
193
+ table = element
194
+ break
195
+ return frame, hit, table
196
+
197
+
198
+ def dismiss(app=None, expect: str | None = None, timeout: float = TIMEOUT) -> Dismissed:
199
+ """Close the symbol list and the scale menu, and take focus off the field.
200
+
201
+ ``expect`` is a symbol just committed: dismiss() then also waits, within
202
+ ``timeout``, for the chart's title to name it before it lets focus go.
203
+
204
+ Idempotent, and cheap when nothing is open: a few accessibility reads.
205
+ Call it with thinkorswim in front (hit-testing answers from the menu bar
206
+ otherwise), inside the chart lock, after the change it tidies up after.
207
+ A field or toggle that cannot be found is left alone rather than raised
208
+ about: tidying up must never turn a change that landed into an error.
209
+ """
210
+ from . import ax, config, symbol, timeframe
211
+
212
+ app = app or ax.running_app()
213
+ pid = app.processIdentifier()
214
+ ax_app = ax.handle(app)
215
+ cfg = config.load()
216
+ try:
217
+ field, _ = symbol.locate(ax_app, cfg)
218
+ except symbol.NotFound:
219
+ field = None
220
+ try:
221
+ toggle, _ = timeframe._control(ax_app, cfg)
222
+ except timeframe.MenuError:
223
+ toggle = None
224
+ title = [None]
225
+
226
+ def look() -> Seen:
227
+ scale_open = toggle is not None and ax.attr(toggle, "AXValue") not in (0, "0", False, None)
228
+ if field is None:
229
+ return Seen(scale_open=scale_open)
230
+ frame, hit, table = _under_field(ax, ax_app, field)
231
+ list_open = frame is not None and hangs_under(
232
+ frame, _frame(ax, table) if table is not None else None)
233
+ title[0] = None if list_open else hit
234
+ return Seen(
235
+ scale_open=scale_open,
236
+ list_open=list_open,
237
+ field_focused=bool(ax.attr(field, "AXFocused")),
238
+ field_value=ax.attr(field, "AXValue"),
239
+ title=ax.attr(title[0], "AXValue") if title[0] is not None else None,
240
+ )
241
+
242
+ def act(step: str) -> None:
243
+ if step == CLOSE_SCALE:
244
+ ax.perform(toggle, "AXPress")
245
+ elif step == CLOSE_LIST:
246
+ # Escape reaches only the focused component; after a commit that
247
+ # is nothing, so the field is focused first.
248
+ if not ax.attr(field, "AXFocused"):
249
+ ax.set_attr(field, "AXFocused", True)
250
+ time.sleep(0.1)
251
+ ax.escape(pid)
252
+ elif step == LEAVE_FIELD:
253
+ # LEAVE_FIELD is only chosen when the element under the field
254
+ # read as the chart's title, so title[0] is that strip.
255
+ ax.set_attr(title[0], "AXFocused", True)
256
+
257
+ until = (lambda seen: shows(seen.title, expect)) if expect else None
258
+ return run(look, act, until=until, timeout=timeout)
@@ -13,11 +13,15 @@ import time
13
13
 
14
14
  import Quartz
15
15
 
16
- from . import ax, config, layout
16
+ from . import ax, config, layout, popups
17
17
 
18
18
  # Accepts tickers, futures (/ES), indices (.SPX) and share classes (BRK/B).
19
19
  TICKER = re.compile(r"^[./$]?[A-Z]{1,6}([/.][A-Z]{1,2})?(:[A-Z]+)?$")
20
20
 
21
+ #: Longest show() waits for the chart's title to name the new symbol.
22
+ COMMIT_WAIT = 3.0
23
+
24
+
21
25
  class NotFound(RuntimeError):
22
26
  """The symbol field could not be located or did not validate."""
23
27
 
@@ -157,4 +161,10 @@ def show(ticker: str, app=None) -> str:
157
161
  ax.key(ax.KEY_RETURN, 0, pid)
158
162
  time.sleep(0.25)
159
163
  ax.key(ax.KEY_RETURN, 0, pid)
164
+
165
+ # The list can outlive both Returns and would sit over the chart - and
166
+ # over the chart's title, the one place that says the symbol took. Close
167
+ # it (Escape never discards a typed entry), wait for the title to name the
168
+ # ticker, then take focus off the field so nothing is left hanging.
169
+ popups.dismiss(app, expect=ticker, timeout=COMMIT_WAIT)
160
170
  return previous
@@ -7,6 +7,8 @@ which is the most awkward control in the application:
7
7
  * it ignores process-targeted mouse events entirely - no hover, no click
8
8
  * arrow keys do not move its selection
9
9
  * it dismisses when window activation changes
10
+ * Escape does not close it (keyboard focus is elsewhere); pressing its
11
+ toggle again does - see :mod:`popups`
10
12
 
11
13
  Only a real HID click works, which means the target must be uncovered
12
14
  *before* the popup opens. Clearing occluders afterwards dismisses it.
@@ -21,7 +23,7 @@ from __future__ import annotations
21
23
  import re
22
24
  import time
23
25
 
24
- from . import ax, config, layout
26
+ from . import ax, config, layout, popups
25
27
  from .scales import AS_IS, MNEMONIC, canon, to_code
26
28
 
27
29
  ITEM = re.compile(r"^\s*\d+\s*[DYW]\s*:\s*\S+", re.IGNORECASE)
@@ -138,7 +140,7 @@ def presets(app=None) -> list[str]:
138
140
  ax_app = ax.handle(app)
139
141
  _open(ax_app, config.load())
140
142
  rows = _rows(ax_app)
141
- ax.escape(app.processIdentifier())
143
+ popups.dismiss(app)
142
144
  return [r["label"] for r in rows]
143
145
 
144
146
 
@@ -149,7 +151,7 @@ def current(app=None) -> tuple[str, str]:
149
151
  ax_app = ax.handle(app)
150
152
  _open(ax_app, config.load())
151
153
  rows = _rows(ax_app)
152
- ax.escape(app.processIdentifier())
154
+ popups.dismiss(app)
153
155
  active = next((r for r in rows if r["active"]), None)
154
156
  if active is None:
155
157
  raise MenuError("could not read the current scale")
@@ -202,9 +204,8 @@ def set_scale(phrase: str, app=None) -> tuple[str, str]:
202
204
  ax.click(match["x"], match["y"], pid)
203
205
  time.sleep(0.5)
204
206
  label = match["label"]
205
- return label, MNEMONIC.get(canon(label), label)
206
- except Exception:
207
- ax.escape(pid)
208
- raise
209
207
  finally:
208
+ # Picked or refused, the menu must not stay over the chart.
209
+ popups.dismiss(app)
210
210
  ax.unhide_apps(hidden)
211
+ return label, MNEMONIC.get(canon(label), label)
@@ -16,7 +16,7 @@ from pathlib import Path
16
16
 
17
17
  import Quartz
18
18
 
19
- from . import ax, config, layout, screen, snapshot, symbol
19
+ from . import ax, config, layout, popups, screen, snapshot, symbol
20
20
 
21
21
  #: NSApplicationActivateAllWindows | NSApplicationActivateIgnoringOtherApps:
22
22
  #: bring every thinkorswim window forward, even when another app is in front.
@@ -112,6 +112,8 @@ def _uncover(pid: int, bounds: dict) -> tuple[str, ...]:
112
112
  def capture(app=None) -> bytes:
113
113
  """PNG of the chart's own pane, and nothing else in the window.
114
114
 
115
+ Any popup left over the chart is closed first; see :func:`popups.dismiss`.
116
+
115
117
  Captured by window id rather than screen region, so it works even when
116
118
  something is floating on top - which is what makes it usable as proof
117
119
  that a remote command landed. Then cropped to the pane that holds the
@@ -127,6 +129,8 @@ def capture(app=None) -> bytes:
127
129
 
128
130
  # Hit-testing answers from the menu bar unless the app is in front.
129
131
  ax.activate(app)
132
+ # Never photograph a symbol list or scale menu left over the chart.
133
+ popups.dismiss(app)
130
134
  ax_app = ax.handle(app)
131
135
  prefix = config.load()["window_prefix"]
132
136
  hit = symbol.discover(ax_app, prefix)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.10
3
+ Version: 0.2.11
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -12,6 +12,7 @@ chartremotely/keystore.py
12
12
  chartremotely/layout.py
13
13
  chartremotely/mcpclient.py
14
14
  chartremotely/patron.py
15
+ chartremotely/popups.py
15
16
  chartremotely/push.py
16
17
  chartremotely/registry.py
17
18
  chartremotely/relay.py
@@ -38,6 +39,7 @@ chartremotely.egg-info/requires.txt
38
39
  chartremotely.egg-info/top_level.txt
39
40
  chartremotely/assets/shortcut-template.plist
40
41
  tests/test_patron.py
42
+ tests/test_popups.py
41
43
  tests/test_push.py
42
44
  tests/test_relay.py
43
45
  tests/test_requestlog.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.2.10"
3
+ version = "0.2.11"
4
4
  description = "Remote control of desktop charting applications on macOS"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,132 @@
1
+ """The pure half of closing thinkorswim's popups: what is open, and what to do next."""
2
+
3
+ from chartremotely import popups
4
+ from chartremotely.popups import CLOSE_LIST, CLOSE_SCALE, LEAVE_FIELD, Seen
5
+
6
+ # The symbol combo and the list thinkorswim hung under it, as measured live.
7
+ COMBO = (373, 119, 126, 25)
8
+ LIST = (375, 142, 511, 190)
9
+
10
+
11
+ def test_a_table_on_the_fields_bottom_left_corner_is_its_list():
12
+ assert popups.hangs_under(COMBO, LIST)
13
+
14
+
15
+ def test_other_tables_are_not_the_list():
16
+ assert not popups.hangs_under(COMBO, None)
17
+ assert not popups.hangs_under(COMBO, (3, 662, 340, 1900)) # the watchlist
18
+ assert not popups.hangs_under(COMBO, (375, 300, 511, 190)) # lower in the pane
19
+ assert not popups.hangs_under(COMBO, (375, 142, 0, 0)) # collapsed, not drawn
20
+
21
+
22
+ def test_the_charts_title_names_its_symbol():
23
+ assert popups.chart_symbol("PLTR 10 D 30m") == "PLTR"
24
+ assert popups.chart_symbol("/ESZ26 5 D 5m") == "/ESZ26"
25
+ assert popups.chart_symbol("BRK/B 1 Y 1D") == "BRK/B"
26
+ assert popups.chart_symbol("Lo: 166.03") is None
27
+ assert popups.chart_symbol("") is None
28
+ assert popups.chart_symbol(None) is None
29
+
30
+
31
+ def test_the_chart_shows_an_entry_only_when_its_title_names_it():
32
+ assert popups.shows("PLTR 10 D 30m", "pltr ")
33
+ assert not popups.shows("PLTR 10 D 30m", "PLT")
34
+ assert popups.shows("/ESZ26 5 D 5m", "/ES") # a futures root, drawn as its contract
35
+ assert not popups.shows("ESTC 5 D 5m", "ES") # but not a mere prefix
36
+ assert not popups.shows(None, "PLTR")
37
+ assert not popups.shows("PLTR 10 D 30m", None)
38
+
39
+
40
+ def test_nothing_open_and_nothing_focused_needs_nothing():
41
+ assert popups.next_step(Seen(title="PLTR 10 D 30m", field_value="PLTR")) is None
42
+ assert Seen().clean
43
+
44
+
45
+ def test_the_menu_is_closed_before_the_list():
46
+ both = Seen(scale_open=True, list_open=True, field_value="PLTR")
47
+ assert not both.clean
48
+ assert popups.next_step(both) == CLOSE_SCALE
49
+ assert popups.next_step(Seen(list_open=True, field_value="PLTR")) == CLOSE_LIST
50
+
51
+
52
+ def test_focus_leaves_the_field_only_when_the_chart_shows_its_text():
53
+ committed = Seen(field_focused=True, field_value="PLTR", title="PLTR 10 D 30m")
54
+ assert popups.next_step(committed) == LEAVE_FIELD
55
+ # Losing focus commits the field: a pending "PLT" must keep focus.
56
+ pending = Seen(field_focused=True, field_value="PLT", title="PLTR 10 D 30m")
57
+ assert popups.next_step(pending) is None
58
+ # A title that has not caught up yet is not proof either.
59
+ behind = Seen(field_focused=True, field_value="NVDA", title="PLTR 10 D 30m")
60
+ assert popups.next_step(behind) is None
61
+
62
+
63
+ class Chart:
64
+ """A fake thinkorswim: popups that close when acted on, and a clock."""
65
+
66
+ def __init__(self, seen: Seen, title_after: float = 0.0, *, stuck: bool = False):
67
+ self.seen, self.title_after, self.stuck = seen, title_after, stuck
68
+ self.now, self.acts = 0.0, []
69
+
70
+ def clock(self):
71
+ return self.now
72
+
73
+ def sleep(self, seconds):
74
+ self.now += seconds
75
+
76
+ def look(self):
77
+ title = self.seen.title if self.now >= self.title_after else "OLD 1 D 1m"
78
+ return Seen(self.seen.scale_open, self.seen.list_open, self.seen.field_focused,
79
+ self.seen.field_value, None if self.seen.list_open else title)
80
+
81
+ def act(self, step):
82
+ self.acts.append(step)
83
+ if self.stuck:
84
+ return
85
+ s = self.seen
86
+ if step == CLOSE_SCALE:
87
+ self.seen = Seen(False, s.list_open, s.field_focused, s.field_value, s.title)
88
+ elif step == CLOSE_LIST:
89
+ self.seen = Seen(s.scale_open, False, True, s.field_value, s.title)
90
+ elif step == LEAVE_FIELD:
91
+ self.seen = Seen(s.scale_open, s.list_open, False, s.field_value, s.title)
92
+
93
+ def run(self, **kw):
94
+ return popups.run(self.look, self.act, clock=self.clock, sleep=self.sleep, **kw)
95
+
96
+
97
+ def test_run_closes_menu_then_list_then_lets_go_of_the_field():
98
+ chart = Chart(Seen(True, True, False, "PLTR", "PLTR 10 D 30m"))
99
+ done = chart.run()
100
+ assert chart.acts == [CLOSE_SCALE, CLOSE_LIST, LEAVE_FIELD]
101
+ assert done.clean and done.did == (CLOSE_SCALE, CLOSE_LIST, LEAVE_FIELD)
102
+
103
+
104
+ def test_run_is_cheap_and_silent_when_nothing_is_open():
105
+ chart = Chart(Seen(field_value="PLTR", title="PLTR 10 D 30m"))
106
+ done = chart.run()
107
+ assert chart.acts == [] and chart.now == 0
108
+ assert done.clean and not done and str(done) == "nothing open"
109
+
110
+
111
+ def test_run_leaves_a_pending_entry_in_the_field():
112
+ chart = Chart(Seen(list_open=True, field_value="PLT", title="PLTR 10 D 30m"))
113
+ done = chart.run()
114
+ assert chart.acts == [CLOSE_LIST]
115
+ assert done.clean and chart.seen.field_focused
116
+
117
+
118
+ def test_run_waits_for_the_title_to_name_the_new_symbol_before_letting_go():
119
+ chart = Chart(Seen(list_open=True, field_value="NVDA", title="NVDA 10 D 30m"),
120
+ title_after=0.8)
121
+ done = chart.run(until=lambda seen: popups.shows(seen.title, "NVDA"), timeout=3.0)
122
+ assert chart.acts == [CLOSE_LIST, LEAVE_FIELD]
123
+ assert 0.8 <= chart.now < 3.0 and done.clean
124
+
125
+
126
+ def test_run_gives_up_at_the_deadline_and_says_so():
127
+ chart = Chart(Seen(scale_open=True, field_value="PLTR"), stuck=True)
128
+ done = chart.run(timeout=1.5)
129
+ assert not done.clean and chart.now >= 1.5
130
+ # A toggle is pressed again only after it has had time to show.
131
+ assert 2 <= len(chart.acts) <= 1.5 / popups.ACT_WAIT + 1
132
+ assert str(done).endswith("a popup is still open")
File without changes
File without changes
File without changes