token-tv 0.2.0__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.
- token_tv-0.2.0/LICENSE +21 -0
- token_tv-0.2.0/PKG-INFO +31 -0
- token_tv-0.2.0/README.md +18 -0
- token_tv-0.2.0/pyproject.toml +26 -0
- token_tv-0.2.0/setup.cfg +4 -0
- token_tv-0.2.0/tests/test_app.py +41 -0
- token_tv-0.2.0/tests/test_catalog.py +194 -0
- token_tv-0.2.0/tests/test_cli.py +280 -0
- token_tv-0.2.0/tests/test_codexbar.py +237 -0
- token_tv-0.2.0/tests/test_device.py +283 -0
- token_tv-0.2.0/tests/test_display_styles.py +123 -0
- token_tv-0.2.0/tests/test_live.py +147 -0
- token_tv-0.2.0/tests/test_watch.py +67 -0
- token_tv-0.2.0/tests/test_web.py +101 -0
- token_tv-0.2.0/token_tv/__init__.py +2 -0
- token_tv-0.2.0/token_tv/__main__.py +3 -0
- token_tv-0.2.0/token_tv/app.py +75 -0
- token_tv-0.2.0/token_tv/assets/claude-pixel.png +0 -0
- token_tv-0.2.0/token_tv/assets/codex-pixel.png +0 -0
- token_tv-0.2.0/token_tv/assets/grok-pixel.png +0 -0
- token_tv-0.2.0/token_tv/assets/theme-catalog.json +75 -0
- token_tv-0.2.0/token_tv/assets/theme-votes.json +36 -0
- token_tv-0.2.0/token_tv/bridge.py +47 -0
- token_tv-0.2.0/token_tv/catalog.py +138 -0
- token_tv-0.2.0/token_tv/cli.py +366 -0
- token_tv-0.2.0/token_tv/connect.py +72 -0
- token_tv-0.2.0/token_tv/device.py +145 -0
- token_tv-0.2.0/token_tv/display.py +375 -0
- token_tv-0.2.0/token_tv/live.py +265 -0
- token_tv-0.2.0/token_tv/providers/__init__.py +1 -0
- token_tv-0.2.0/token_tv/providers/base.py +23 -0
- token_tv-0.2.0/token_tv/providers/mock.py +11 -0
- token_tv-0.2.0/token_tv/sample.py +25 -0
- token_tv-0.2.0/token_tv/sources.py +450 -0
- token_tv-0.2.0/token_tv/space.py +238 -0
- token_tv-0.2.0/token_tv/state.py +53 -0
- token_tv-0.2.0/token_tv/themes.py +531 -0
- token_tv-0.2.0/token_tv/usage.py +90 -0
- token_tv-0.2.0/token_tv/web/app.js +292 -0
- token_tv-0.2.0/token_tv/web/chakra-petch-500.woff2 +0 -0
- token_tv-0.2.0/token_tv/web/chakra-petch-700.woff2 +0 -0
- token_tv-0.2.0/token_tv/web/chakra-petch-LICENSE.txt +93 -0
- token_tv-0.2.0/token_tv/web/dseg-LICENSE.txt +95 -0
- token_tv-0.2.0/token_tv/web/dseg7-classic-bold.woff2 +0 -0
- token_tv-0.2.0/token_tv/web/index.html +79 -0
- token_tv-0.2.0/token_tv/web/jersey-10-LICENSE.txt +93 -0
- token_tv-0.2.0/token_tv/web/jersey-10.woff2 +0 -0
- token_tv-0.2.0/token_tv/web/manrope-LICENSE.txt +93 -0
- token_tv-0.2.0/token_tv/web/manrope.ttf +0 -0
- token_tv-0.2.0/token_tv/web/orbitron-LICENSE.txt +93 -0
- token_tv-0.2.0/token_tv/web/orbitron.ttf +0 -0
- token_tv-0.2.0/token_tv/web/oxanium-LICENSE.txt +93 -0
- token_tv-0.2.0/token_tv/web/oxanium.woff2 +0 -0
- token_tv-0.2.0/token_tv/web/press-start-2p-LICENSE.txt +93 -0
- token_tv-0.2.0/token_tv/web/press-start-2p.ttf +0 -0
- token_tv-0.2.0/token_tv/web/style.css +717 -0
- token_tv-0.2.0/token_tv/web/tokens.css +79 -0
- token_tv-0.2.0/token_tv/web/vt323-LICENSE.txt +93 -0
- token_tv-0.2.0/token_tv/web/vt323.woff2 +0 -0
- token_tv-0.2.0/token_tv/web_assets.py +28 -0
- token_tv-0.2.0/token_tv.egg-info/PKG-INFO +31 -0
- token_tv-0.2.0/token_tv.egg-info/SOURCES.txt +64 -0
- token_tv-0.2.0/token_tv.egg-info/dependency_links.txt +1 -0
- token_tv-0.2.0/token_tv.egg-info/entry_points.txt +2 -0
- token_tv-0.2.0/token_tv.egg-info/requires.txt +1 -0
- token_tv-0.2.0/token_tv.egg-info/top_level.txt +1 -0
token_tv-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 click6067-ship-it
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
token_tv-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: token-tv
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Your AI usage limits on a $5 desk clock: Claude, Codex and Grok on a 240x240 Wi-Fi clock
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/click6067-ship-it/token-tv
|
|
7
|
+
Project-URL: Demo, https://token-tv.vercel.app
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: Pillow<13,>=10.2
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
<h1 align="center">TokenTV</h1>
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="https://github.com/click6067-ship-it/token-tv/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/click6067-ship-it/token-tv"></a>
|
|
17
|
+
<a href="https://github.com/click6067-ship-it/token-tv/blob/main/LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
|
18
|
+
<a href="https://hackaday.com/2026/10/06/smalltv-hacking-with-surprisingly-little-fuss/"><img alt="Featured on Hackaday" src="https://img.shields.io/badge/featured%20on-Hackaday-1a1a1a"></a>
|
|
19
|
+
<a href="https://github.com/steipete/CodexBar#desk-display"><img alt="Listed in CodexBar" src="https://img.shields.io/badge/listed%20in-CodexBar-555555"></a>
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
<p align="center"><img src="https://raw.githubusercontent.com/click6067-ship-it/token-tv/main/docs/images/real-clock.jpg" alt="TokenTV on a real tiny desk clock"></p>
|
|
23
|
+
<p align="center"><img src="https://raw.githubusercontent.com/click6067-ship-it/token-tv/main/docs/images/clock-faces.png" alt="Six clock faces (sample data)"></p>
|
|
24
|
+
|
|
25
|
+
Your Claude and Codex usage limits on a ~$5 Wi-Fi desk clock. No firmware flashing.
|
|
26
|
+
**Try it:** [live demo](https://token-tv.vercel.app), `npx token-tv demo` or `uvx token-tv demo`, no clock or login needed.
|
|
27
|
+
**Set it up:** give this repo to your AI and say *"Set up TokenTV for me."* It follows [AGENTS.md](https://github.com/click6067-ship-it/token-tv/blob/main/AGENTS.md).
|
|
28
|
+
**Make your own face:** no clock needed, start from the [Game Boy example](https://github.com/click6067-ship-it/token-tv/tree/main/examples/gameboy) and ask your AI to change it. [Guide](https://github.com/click6067-ship-it/token-tv/blob/main/docs/clock-faces.md) · [Share it](https://github.com/click6067-ship-it/token-tv/issues/new?template=share_a_face.md)
|
|
29
|
+
Details: [how it works](https://github.com/click6067-ship-it/token-tv/blob/main/docs/stock-photo-display.md) · [setup](https://github.com/click6067-ship-it/token-tv/blob/main/docs/setup.md) · [compatible clocks](https://github.com/click6067-ship-it/token-tv/blob/main/docs/hardware-compatibility.md) · [CodexBar input](https://github.com/click6067-ship-it/token-tv/blob/main/docs/codexbar.md) · [Apple Watch](https://github.com/click6067-ship-it/token-tv/blob/main/docs/apple-watch.md) · [한국어](https://github.com/click6067-ship-it/token-tv/blob/main/README.ko.md)
|
|
30
|
+
|
|
31
|
+
<p align="center"><a href="https://github.com/click6067-ship-it/token-tv/tree/main/examples/gameboy"><img src="https://raw.githubusercontent.com/click6067-ship-it/token-tv/main/examples/gameboy/comparison.png" width="640" alt="Change a face: original pixel style and a four-colour Game Boy example, rendered from the same sample readings"></a></p>
|
token_tv-0.2.0/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<h1 align="center">TokenTV</h1>
|
|
2
|
+
<p align="center">
|
|
3
|
+
<a href="https://github.com/click6067-ship-it/token-tv/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/click6067-ship-it/token-tv"></a>
|
|
4
|
+
<a href="https://github.com/click6067-ship-it/token-tv/blob/main/LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
|
5
|
+
<a href="https://hackaday.com/2026/10/06/smalltv-hacking-with-surprisingly-little-fuss/"><img alt="Featured on Hackaday" src="https://img.shields.io/badge/featured%20on-Hackaday-1a1a1a"></a>
|
|
6
|
+
<a href="https://github.com/steipete/CodexBar#desk-display"><img alt="Listed in CodexBar" src="https://img.shields.io/badge/listed%20in-CodexBar-555555"></a>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center"><img src="https://raw.githubusercontent.com/click6067-ship-it/token-tv/main/docs/images/real-clock.jpg" alt="TokenTV on a real tiny desk clock"></p>
|
|
10
|
+
<p align="center"><img src="https://raw.githubusercontent.com/click6067-ship-it/token-tv/main/docs/images/clock-faces.png" alt="Six clock faces (sample data)"></p>
|
|
11
|
+
|
|
12
|
+
Your Claude and Codex usage limits on a ~$5 Wi-Fi desk clock. No firmware flashing.
|
|
13
|
+
**Try it:** [live demo](https://token-tv.vercel.app), `npx token-tv demo` or `uvx token-tv demo`, no clock or login needed.
|
|
14
|
+
**Set it up:** give this repo to your AI and say *"Set up TokenTV for me."* It follows [AGENTS.md](https://github.com/click6067-ship-it/token-tv/blob/main/AGENTS.md).
|
|
15
|
+
**Make your own face:** no clock needed, start from the [Game Boy example](https://github.com/click6067-ship-it/token-tv/tree/main/examples/gameboy) and ask your AI to change it. [Guide](https://github.com/click6067-ship-it/token-tv/blob/main/docs/clock-faces.md) · [Share it](https://github.com/click6067-ship-it/token-tv/issues/new?template=share_a_face.md)
|
|
16
|
+
Details: [how it works](https://github.com/click6067-ship-it/token-tv/blob/main/docs/stock-photo-display.md) · [setup](https://github.com/click6067-ship-it/token-tv/blob/main/docs/setup.md) · [compatible clocks](https://github.com/click6067-ship-it/token-tv/blob/main/docs/hardware-compatibility.md) · [CodexBar input](https://github.com/click6067-ship-it/token-tv/blob/main/docs/codexbar.md) · [Apple Watch](https://github.com/click6067-ship-it/token-tv/blob/main/docs/apple-watch.md) · [한국어](https://github.com/click6067-ship-it/token-tv/blob/main/README.ko.md)
|
|
17
|
+
|
|
18
|
+
<p align="center"><a href="https://github.com/click6067-ship-it/token-tv/tree/main/examples/gameboy"><img src="https://raw.githubusercontent.com/click6067-ship-it/token-tv/main/examples/gameboy/comparison.png" width="640" alt="Change a face: original pixel style and a four-colour Game Boy example, rendered from the same sample readings"></a></p>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "token-tv"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Your AI usage limits on a $5 desk clock: Claude, Codex and Grok on a 240x240 Wi-Fi clock"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["Pillow>=10.2,<13"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/click6067-ship-it/token-tv"
|
|
16
|
+
Demo = "https://token-tv.vercel.app"
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
token-tv = "token_tv.cli:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
packages = ["token_tv", "token_tv.providers", "token_tv.web", "token_tv.assets"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.package-data]
|
|
25
|
+
"token_tv.web" = ["*"]
|
|
26
|
+
"token_tv.assets" = ["*.png", "*.json"]
|
token_tv-0.2.0/setup.cfg
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
import unittest
|
|
4
|
+
from urllib.request import urlopen
|
|
5
|
+
|
|
6
|
+
from http.server import ThreadingHTTPServer
|
|
7
|
+
|
|
8
|
+
from token_tv.app import MOCK_ACCOUNTS, make_handler
|
|
9
|
+
from token_tv.providers.mock import MockProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SnapshotTests(unittest.TestCase):
|
|
13
|
+
def test_snapshot_exposes_distinct_aliases_as_theme_paths(self):
|
|
14
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), make_handler(MOCK_ACCOUNTS, {
|
|
15
|
+
"claude": MockProvider(),
|
|
16
|
+
"codex": MockProvider(),
|
|
17
|
+
}))
|
|
18
|
+
thread = threading.Thread(target=server.serve_forever)
|
|
19
|
+
thread.start()
|
|
20
|
+
try:
|
|
21
|
+
with urlopen(f"http://127.0.0.1:{server.server_port}/snapshot") as response:
|
|
22
|
+
payload = response.read()
|
|
23
|
+
self.assertLessEqual(len(payload), 2048)
|
|
24
|
+
self.assertEqual(response.headers["Content-Type"], "application/json; charset=utf-8")
|
|
25
|
+
data = json.loads(payload)
|
|
26
|
+
self.assertEqual(data["schema"], 1)
|
|
27
|
+
self.assertEqual(set(data["accounts"]), {"claude_personal", "claude_work", "codex_personal"})
|
|
28
|
+
self.assertEqual(data["accounts"]["claude_work"]["alias"], "Claude work")
|
|
29
|
+
self.assertEqual(data["accounts"]["claude_work"]["status"], "mock")
|
|
30
|
+
with urlopen(f"http://127.0.0.1:{server.server_port}/snapshot/claude_work") as response:
|
|
31
|
+
account = json.load(response)
|
|
32
|
+
self.assertEqual(account["session_percent"], 61)
|
|
33
|
+
self.assertEqual(account["alias"], "Claude work")
|
|
34
|
+
finally:
|
|
35
|
+
server.shutdown()
|
|
36
|
+
server.server_close()
|
|
37
|
+
thread.join()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
unittest.main()
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import json
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from token_tv import __version__, catalog
|
|
8
|
+
from token_tv.display import STYLES
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
spec = importlib.util.spec_from_file_location('update_theme_votes', ROOT / 'scripts' / 'update_theme_votes.py')
|
|
12
|
+
updater = importlib.util.module_from_spec(spec)
|
|
13
|
+
spec.loader.exec_module(updater)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def entry(id, added, issue=None, min_version='0.1.0', **extra):
|
|
17
|
+
return dict({'id': id, 'name': id.title(), 'author': 'tester', 'added_at': added, 'min_version': min_version,
|
|
18
|
+
'source_url': 'https://github.com/example/token-tv', 'license': 'MIT', 'like_issue': issue}, **extra)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
CATALOG = {'schema_version': 1, 'themes': [
|
|
22
|
+
entry('digital', '2026-10-01T00:00:00Z', issue=11),
|
|
23
|
+
entry('neon', '2026-10-02T00:00:00Z', issue=12),
|
|
24
|
+
entry('retro', '2026-10-03T00:00:00Z', issue=13),
|
|
25
|
+
entry('hud', '2026-10-02T00:00:00Z', issue=14),
|
|
26
|
+
entry('pixel', '2026-09-01T00:00:00Z'),
|
|
27
|
+
]}
|
|
28
|
+
OLD = '2026-10-01T09:00:00Z'
|
|
29
|
+
VOTES = {'schema_version': 1, 'last_attempt_at': '2026-10-03T12:00:00Z', 'counts': {
|
|
30
|
+
'11': {'count': 8, 'status': 'fresh', 'fetched_at': '2026-10-03T12:00:00Z'},
|
|
31
|
+
'12': {'count': 0, 'status': 'fresh', 'fetched_at': '2026-10-03T12:00:00Z'},
|
|
32
|
+
'14': {'count': 8, 'status': 'stale', 'fetched_at': OLD},
|
|
33
|
+
}}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def listed(installed=('pixel', 'digital', 'neon', 'retro', 'hud', 'space'), cat=CATALOG, votes=VOTES, version='0.1.0'):
|
|
37
|
+
return catalog.theme_list(installed=installed, catalog=cat, votes=votes, version=version)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CatalogTest(unittest.TestCase):
|
|
41
|
+
def ids(self, data, mode):
|
|
42
|
+
return [t['id'] for t in catalog.sort_themes(data['themes'], mode)]
|
|
43
|
+
|
|
44
|
+
def test_popular_and_new_order(self):
|
|
45
|
+
data = listed()
|
|
46
|
+
# counted (8, 8 stale, 0) first; then never-counted and Local, newest first.
|
|
47
|
+
self.assertEqual(self.ids(data, 'popular'), ['hud', 'digital', 'neon', 'retro', 'pixel', 'space'])
|
|
48
|
+
self.assertEqual(self.ids(data, 'new'), ['retro', 'hud', 'neon', 'digital', 'pixel', 'space'])
|
|
49
|
+
|
|
50
|
+
def test_ties_break_by_newest_then_id(self):
|
|
51
|
+
cat = {'schema_version': 1, 'themes': [entry('neon', '2026-10-02T00:00:00Z', 1), entry('hud', '2026-10-02T00:00:00Z', 2)]}
|
|
52
|
+
stamp = '2026-10-03T00:00:00Z'
|
|
53
|
+
votes = {'schema_version': 1, 'last_attempt_at': stamp, 'counts': {
|
|
54
|
+
'1': {'count': 3, 'status': 'fresh', 'fetched_at': stamp}, '2': {'count': 3, 'status': 'fresh', 'fetched_at': stamp}}}
|
|
55
|
+
data = listed(installed=('neon', 'hud'), cat=cat, votes=votes)
|
|
56
|
+
self.assertEqual(self.ids(data, 'popular'), ['hud', 'neon'])
|
|
57
|
+
self.assertEqual(self.ids(data, 'new'), ['hud', 'neon'])
|
|
58
|
+
|
|
59
|
+
def test_unknown_likes_are_not_zero(self):
|
|
60
|
+
themes = {t['id']: t for t in listed()['themes']}
|
|
61
|
+
self.assertEqual((themes['retro']['likes'], themes['retro']['likes_state']), (None, 'unavailable'))
|
|
62
|
+
self.assertEqual((themes['pixel']['likes'], themes['pixel']['likes_state']), (None, 'not_open'))
|
|
63
|
+
self.assertIsNone(themes['pixel']['like_url'])
|
|
64
|
+
self.assertEqual((themes['hud']['likes'], themes['hud']['likes_state']), (8, 'stale'))
|
|
65
|
+
self.assertEqual(themes['hud']['likes_counted_at'], OLD) # an old count keeps its own time
|
|
66
|
+
|
|
67
|
+
def test_zero_likes_stay_zero(self):
|
|
68
|
+
neon = next(t for t in listed()['themes'] if t['id'] == 'neon')
|
|
69
|
+
self.assertEqual((neon['likes'], neon['likes_state']), (0, 'counted'))
|
|
70
|
+
self.assertTrue(neon['like_url'].endswith('/issues/12'))
|
|
71
|
+
|
|
72
|
+
def test_blank_snapshot_is_still_useful(self):
|
|
73
|
+
data = listed(votes=catalog.BLANK_VOTES)
|
|
74
|
+
self.assertIsNone(data['last_attempt_at'])
|
|
75
|
+
self.assertEqual(len(data['themes']), 6)
|
|
76
|
+
self.assertTrue(all(t['likes'] is None for t in data['themes']))
|
|
77
|
+
self.assertEqual(self.ids(data, 'popular'), self.ids(data, 'new'))
|
|
78
|
+
|
|
79
|
+
def test_local_face_without_catalog_entry_is_listed_as_local(self):
|
|
80
|
+
space = next(t for t in listed()['themes'] if t['id'] == 'space')
|
|
81
|
+
self.assertTrue(space['local'] and space['installed'])
|
|
82
|
+
self.assertEqual(space['likes_state'], 'local')
|
|
83
|
+
self.assertFalse(space['needs_update'])
|
|
84
|
+
|
|
85
|
+
def test_newer_theme_needs_update_and_is_not_installed(self):
|
|
86
|
+
cat = {'schema_version': 1, 'themes': CATALOG['themes'] + [entry('vapor', '2026-10-04T00:00:00Z', min_version='0.3.0')]}
|
|
87
|
+
vapor = next(t for t in listed(cat=cat)['themes'] if t['id'] == 'vapor')
|
|
88
|
+
self.assertEqual((vapor['installed'], vapor['needs_update']), (False, True))
|
|
89
|
+
self.assertEqual(vapor['min_version'], '0.3.0')
|
|
90
|
+
|
|
91
|
+
def test_version_compare(self):
|
|
92
|
+
self.assertTrue(catalog.version_at_least('0.10.0', '0.9.1'))
|
|
93
|
+
self.assertFalse(catalog.version_at_least('0.1.0', '0.2.0'))
|
|
94
|
+
self.assertTrue(catalog.version_at_least('1.0', '1.0.0'))
|
|
95
|
+
|
|
96
|
+
def test_bad_catalog_is_rejected(self):
|
|
97
|
+
for broken in ({'schema_version': 2, 'themes': []},
|
|
98
|
+
{'schema_version': 1, 'themes': [entry('digital', 'x'), entry('digital', 'x')]},
|
|
99
|
+
{'schema_version': 1, 'themes': [dict(entry('digital', '2026-10-01T00:00:00Z'), like_issue='12')]},
|
|
100
|
+
{'schema_version': 1, 'themes': [dict(entry('Bad Id', '2026-10-01T00:00:00Z'))]},
|
|
101
|
+
{'schema_version': 1, 'themes': [dict(entry('digital', '2026-10-01T00:00:00Z'), source_url='javascript:x')]}):
|
|
102
|
+
with self.assertRaises(ValueError):
|
|
103
|
+
catalog.validate_catalog(broken)
|
|
104
|
+
|
|
105
|
+
def test_corrupt_vote_file_means_unavailable_not_zero(self):
|
|
106
|
+
path = Path(tempfile.mkdtemp()) / 'votes.json'
|
|
107
|
+
path.write_text('{"counts": ')
|
|
108
|
+
votes = catalog.load_votes(path)
|
|
109
|
+
self.assertEqual(votes['counts'], {})
|
|
110
|
+
self.assertIsNone(votes['last_attempt_at'])
|
|
111
|
+
|
|
112
|
+
def test_bad_or_missing_count_times_are_unknown_not_new(self):
|
|
113
|
+
path = Path(tempfile.mkdtemp()) / 'votes.json'
|
|
114
|
+
path.write_text(json.dumps({'last_attempt_at': 'yesterday', 'counts': {
|
|
115
|
+
'11': {'count': 5, 'status': 'fresh', 'fetched_at': 'not a time'}, # unreadable -> unavailable
|
|
116
|
+
'12': {'count': 5, 'status': 'fresh'}, # fresh without time -> unavailable
|
|
117
|
+
'14': {'count': 7, 'status': 'stale'}}})) # old count, time unknown
|
|
118
|
+
votes = catalog.load_votes(path)
|
|
119
|
+
self.assertIsNone(votes['last_attempt_at'])
|
|
120
|
+
themes = {t['id']: t for t in listed(votes=votes)['themes']}
|
|
121
|
+
self.assertEqual([themes[i]['likes_state'] for i in ('digital', 'neon')], ['unavailable', 'unavailable'])
|
|
122
|
+
self.assertIsNone(themes['digital']['likes'])
|
|
123
|
+
self.assertEqual((themes['hud']['likes'], themes['hud']['likes_state'], themes['hud']['likes_counted_at']),
|
|
124
|
+
(7, 'stale', None))
|
|
125
|
+
|
|
126
|
+
def test_installed_face_needing_newer_version_cannot_be_picked_from_catalog(self):
|
|
127
|
+
cat = {'schema_version': 1, 'themes': [entry('neon', '2026-10-02T00:00:00Z', min_version='0.2.0')]}
|
|
128
|
+
neon = next(t for t in listed(installed=('neon', 'mine'), cat=cat)['themes'] if t['id'] == 'neon')
|
|
129
|
+
self.assertEqual((neon['installed'], neon['needs_update']), (True, True))
|
|
130
|
+
mine = next(t for t in listed(installed=('neon', 'mine'), cat=cat)['themes'] if t['id'] == 'mine')
|
|
131
|
+
self.assertEqual((mine['local'], mine['needs_update']), (True, False))
|
|
132
|
+
|
|
133
|
+
def test_bundled_catalog_matches_installed_faces(self):
|
|
134
|
+
data = catalog.validate_catalog(json.loads(catalog.CATALOG_PATH.read_text()))
|
|
135
|
+
self.assertLessEqual({t['id'] for t in data['themes']}, set(STYLES)) # a fork may add Local faces
|
|
136
|
+
# Existing public issues stay mapped; new faces have no voting issue yet.
|
|
137
|
+
self.assertEqual({t['id']: t['like_issue'] for t in data['themes']},
|
|
138
|
+
{'pixel': 1, 'digital': 2, 'neon': 3, 'retro': 4, 'hud': 5, 'space': 6, 'gameboy': None})
|
|
139
|
+
votes = catalog.load_votes()
|
|
140
|
+
self.assertTrue(set(votes['counts']) <= {str(t['like_issue']) for t in data['themes']})
|
|
141
|
+
self.assertTrue(all(v['fetched_at'] for v in votes['counts'].values())) # every count is dated
|
|
142
|
+
|
|
143
|
+
def test_broken_bundled_catalog_still_lists_installed_faces(self):
|
|
144
|
+
from unittest import mock
|
|
145
|
+
with mock.patch.object(catalog, 'CATALOG_PATH', Path(tempfile.mkdtemp()) / 'missing.json'):
|
|
146
|
+
data = catalog.payload()
|
|
147
|
+
self.assertTrue(data['catalog_error'])
|
|
148
|
+
self.assertEqual({t['id'] for t in data['themes'] if t['local']}, set(STYLES))
|
|
149
|
+
|
|
150
|
+
def test_package_version_matches_pyproject(self):
|
|
151
|
+
self.assertIn(f'version = "{__version__}"', (ROOT / 'pyproject.toml').read_text())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class RefreshTest(unittest.TestCase):
|
|
155
|
+
def test_refresh_keeps_last_count_on_failure(self):
|
|
156
|
+
def fetch(issue):
|
|
157
|
+
if issue == 11:
|
|
158
|
+
return 9
|
|
159
|
+
raise OSError('timeout')
|
|
160
|
+
now = '2026-10-04T00:00:00Z'
|
|
161
|
+
out = updater.refresh(CATALOG, VOTES, fetch, now=now)
|
|
162
|
+
self.assertEqual(out['counts']['11'], {'count': 9, 'status': 'fresh', 'fetched_at': now})
|
|
163
|
+
# kept, marked stale, and still dated when it was really counted
|
|
164
|
+
self.assertEqual(out['counts']['12'], {'count': 0, 'status': 'stale', 'fetched_at': '2026-10-03T12:00:00Z'})
|
|
165
|
+
self.assertEqual(out['counts']['14']['fetched_at'], OLD)
|
|
166
|
+
self.assertNotIn('13', out['counts']) # never counted stays unknown, not 0
|
|
167
|
+
self.assertEqual(out['last_attempt_at'], now)
|
|
168
|
+
themes = {t['id']: t for t in listed(votes=out)['themes']}
|
|
169
|
+
self.assertEqual((themes['neon']['likes_state'], themes['neon']['likes_counted_at']), ('stale', '2026-10-03T12:00:00Z'))
|
|
170
|
+
|
|
171
|
+
def test_refresh_without_issue_skips_network(self):
|
|
172
|
+
calls = []
|
|
173
|
+
cat = {'schema_version': 1, 'themes': [entry('pixel', '2026-09-01T00:00:00Z')]}
|
|
174
|
+
out = updater.refresh(cat, catalog.BLANK_VOTES, lambda n: calls.append(n), now='2026-10-04T00:00:00Z')
|
|
175
|
+
self.assertEqual((calls, out['counts']), ([], {}))
|
|
176
|
+
|
|
177
|
+
def test_refresh_counts_issue_body_thumbs_only(self):
|
|
178
|
+
payload = {'reactions': {'+1': 4, 'heart': 9, 'total_count': 13}, 'comments': 7}
|
|
179
|
+
self.assertEqual(updater.thumbs_up(payload), 4)
|
|
180
|
+
for bad in ({}, {'reactions': {'+1': -1}}, {'reactions': {'+1': '4'}}, [], {'reactions': None}):
|
|
181
|
+
with self.assertRaises(ValueError):
|
|
182
|
+
updater.thumbs_up(bad)
|
|
183
|
+
|
|
184
|
+
def test_all_failures_keep_every_count_time(self):
|
|
185
|
+
def fail(issue):
|
|
186
|
+
raise ValueError('bad json')
|
|
187
|
+
out = updater.refresh(CATALOG, VOTES, fail, now='2026-10-04T00:00:00Z')
|
|
188
|
+
self.assertTrue(all(v['status'] == 'stale' for v in out['counts'].values()))
|
|
189
|
+
self.assertEqual({k: v['fetched_at'] for k, v in out['counts'].items()},
|
|
190
|
+
{k: v['fetched_at'] for k, v in VOTES['counts'].items()})
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == '__main__':
|
|
194
|
+
unittest.main()
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
from token_tv import cli, sources
|
|
11
|
+
from token_tv.display import STYLES
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(*argv):
|
|
15
|
+
out, err = io.StringIO(), io.StringIO()
|
|
16
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
17
|
+
code = cli.main(list(argv))
|
|
18
|
+
return code, out.getvalue(), err.getvalue()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CliTest(unittest.TestCase):
|
|
22
|
+
def setUp(self):
|
|
23
|
+
self.tmp = Path(tempfile.mkdtemp())
|
|
24
|
+
self.config = self.tmp / 'config.json'
|
|
25
|
+
|
|
26
|
+
def test_setup_writes_metadata_only_and_never_overwrites(self):
|
|
27
|
+
code, out, _ = run('setup', '--config', str(self.config), '--yes',
|
|
28
|
+
'--claude-email', 'a@example.com', '--device-url', 'http://10.0.0.9/')
|
|
29
|
+
self.assertEqual(code, 0)
|
|
30
|
+
data = json.loads(self.config.read_text())
|
|
31
|
+
self.assertEqual(data['device_url'], 'http://10.0.0.9')
|
|
32
|
+
self.assertEqual(data['accounts'][0], {'key': 'claude_a', 'alias': 'CLAUDE A', 'provider': 'claude',
|
|
33
|
+
'email': 'a@example.com', 'source_home': '~/.claude'})
|
|
34
|
+
sources.load_config(self.config) # the daemon accepts what setup writes
|
|
35
|
+
before = self.config.read_text()
|
|
36
|
+
code, _, err = run('setup', '--config', str(self.config), '--yes', '--codex-email', 'b@example.com')
|
|
37
|
+
self.assertEqual(code, 1)
|
|
38
|
+
self.assertIn('never overwrites', err)
|
|
39
|
+
self.assertEqual(self.config.read_text(), before)
|
|
40
|
+
|
|
41
|
+
def test_setup_without_accounts_fails_and_writes_nothing(self):
|
|
42
|
+
code, _, err = run('setup', '--config', str(self.config), '--yes')
|
|
43
|
+
self.assertEqual(code, 1)
|
|
44
|
+
self.assertFalse(self.config.exists())
|
|
45
|
+
self.assertIn('--claude-email', err)
|
|
46
|
+
|
|
47
|
+
def test_doctor_points_missing_logins_at_connect(self):
|
|
48
|
+
home = self.tmp / 'codex-home'
|
|
49
|
+
self.config.write_text(json.dumps({'accounts': [
|
|
50
|
+
{'key': 'codex_a', 'alias': 'CODEX A', 'provider': 'codex', 'email': 'b@example.com',
|
|
51
|
+
'source_home': str(home)}]}))
|
|
52
|
+
with mock.patch.object(cli, 'cli_path', return_value='/usr/bin/codex'):
|
|
53
|
+
code, out, _ = run('doctor', '--config', str(self.config))
|
|
54
|
+
self.assertEqual(code, 1)
|
|
55
|
+
self.assertIn('token-tv connect --config', out)
|
|
56
|
+
home.mkdir()
|
|
57
|
+
(home / 'auth.json').write_text('{}') # existence only; doctor never opens it
|
|
58
|
+
code, out, _ = run('doctor', '--config', str(self.config))
|
|
59
|
+
self.assertEqual(code, 0)
|
|
60
|
+
self.assertIn('not verified', out)
|
|
61
|
+
self.assertNotIn('Ready', out)
|
|
62
|
+
bad = {'status': 'identity_mismatch', 'identity_verified': False}
|
|
63
|
+
with mock.patch.object(cli, 'cli_path', return_value='/usr/bin/codex'), \
|
|
64
|
+
mock.patch('token_tv.sources.fetch_account', return_value=bad):
|
|
65
|
+
code, out, _ = run('doctor', '--config', str(self.config), '--live')
|
|
66
|
+
self.assertEqual(code, 1)
|
|
67
|
+
self.assertIn('IDENTITY_MISMATCH', out)
|
|
68
|
+
good = {'status': 'ok', 'identity_verified': True}
|
|
69
|
+
with mock.patch.object(cli, 'cli_path', return_value='/usr/bin/codex'), \
|
|
70
|
+
mock.patch('token_tv.sources.fetch_account', return_value=good):
|
|
71
|
+
code, out, _ = run('doctor', '--config', str(self.config), '--live')
|
|
72
|
+
self.assertEqual(code, 0)
|
|
73
|
+
self.assertIn('Ready', out)
|
|
74
|
+
|
|
75
|
+
def test_doctor_never_calls_an_unchecked_keychain_ready(self):
|
|
76
|
+
self.config.write_text(json.dumps({'accounts': [
|
|
77
|
+
{'key': 'claude_a', 'alias': 'CLAUDE A', 'provider': 'claude', 'email': 'a@example.com',
|
|
78
|
+
'source_home': str(self.tmp / 'empty')}]}))
|
|
79
|
+
with mock.patch.object(cli, 'cli_path', return_value='/usr/bin/claude'), \
|
|
80
|
+
mock.patch.object(cli.sys, 'platform', 'darwin'):
|
|
81
|
+
code, out, _ = run('doctor', '--config', str(self.config))
|
|
82
|
+
self.assertEqual(code, 1)
|
|
83
|
+
self.assertIn('Keychain unchecked', out)
|
|
84
|
+
|
|
85
|
+
def test_doctor_checks_codexbar_instead_of_a_login_file(self):
|
|
86
|
+
self.config.write_text(json.dumps({'accounts': [
|
|
87
|
+
{'key': 'claude_a', 'alias': 'CLAUDE A', 'provider': 'claude', 'email': 'a@example.com',
|
|
88
|
+
'codexbar_command': str(self.tmp / 'missing-codexbar'), 'codexbar_source': 'oauth'}]}))
|
|
89
|
+
code, out, _ = run('doctor', '--config', str(self.config))
|
|
90
|
+
self.assertEqual(code, 1)
|
|
91
|
+
self.assertIn('install the CodexBar CLI', out)
|
|
92
|
+
self.assertNotIn('token-tv connect', out)
|
|
93
|
+
unreported = {'status': 'ok', 'identity_verified': False}
|
|
94
|
+
with mock.patch('token_tv.sources.fetch_account', return_value=unreported):
|
|
95
|
+
code, out, _ = run('doctor', '--config', str(self.config), '--live')
|
|
96
|
+
self.assertEqual(code, 0)
|
|
97
|
+
self.assertIn('CodexBar did not report the email for CLAUDE A', out)
|
|
98
|
+
failed = {'status': 'error', 'identity_verified': False}
|
|
99
|
+
with mock.patch('token_tv.sources.fetch_account', return_value=failed):
|
|
100
|
+
code, out, _ = run('doctor', '--config', str(self.config), '--live')
|
|
101
|
+
self.assertEqual(code, 1)
|
|
102
|
+
self.assertIn('live check ERROR', out)
|
|
103
|
+
|
|
104
|
+
def test_broken_config_gets_a_sentence_not_a_traceback(self):
|
|
105
|
+
self.config.write_text('{"accounts": [')
|
|
106
|
+
for command in ('doctor', 'run'):
|
|
107
|
+
code, _, err = run(command, '--config', str(self.config))
|
|
108
|
+
self.assertEqual(code, 1)
|
|
109
|
+
self.assertIn('not a valid TokenTV config', err)
|
|
110
|
+
|
|
111
|
+
def test_setup_two_accounts_reuse_then_isolate(self):
|
|
112
|
+
code, out, _ = run('setup', '--config', str(self.config), '--yes',
|
|
113
|
+
'--codex-email', 'a@example.com', '--codex-email', 'b@example.com')
|
|
114
|
+
self.assertEqual(code, 0)
|
|
115
|
+
a, b = json.loads(self.config.read_text())['accounts']
|
|
116
|
+
self.assertEqual((a['key'], a['source_home']), ('codex_a', '~/.codex'))
|
|
117
|
+
self.assertEqual((b['key'], b['alias'], b['source_home']),
|
|
118
|
+
('codex_b', 'CODEX B', str(self.tmp / 'homes' / 'codex_b')))
|
|
119
|
+
self.assertIn('separate login', out)
|
|
120
|
+
sources.load_config(self.config)
|
|
121
|
+
|
|
122
|
+
def test_setup_isolate_never_points_at_the_everyday_home(self):
|
|
123
|
+
run('setup', '--config', str(self.config), '--yes', '--isolate', '--claude-email', 'a@example.com')
|
|
124
|
+
self.assertNotEqual(json.loads(self.config.read_text())['accounts'][0]['source_home'], '~/.claude')
|
|
125
|
+
|
|
126
|
+
def test_bad_clock_address_is_rejected_before_writing(self):
|
|
127
|
+
for bad in ('192.168.0.50', 'ftp://clock', 'http://', 'http://clock/photo', 'http://clock:99999'):
|
|
128
|
+
code, _, err = run('setup', '--config', str(self.config), '--yes',
|
|
129
|
+
'--claude-email', 'a@example.com', '--device-url', bad)
|
|
130
|
+
self.assertEqual(code, 1, bad)
|
|
131
|
+
self.assertFalse(self.config.exists())
|
|
132
|
+
self.assertEqual(cli.device_address('http://10.0.0.9:8080/'), 'http://10.0.0.9:8080')
|
|
133
|
+
|
|
134
|
+
def test_doctor_without_config_explains_setup(self):
|
|
135
|
+
code, _, err = run('doctor', '--config', str(self.config))
|
|
136
|
+
self.assertEqual(code, 1)
|
|
137
|
+
self.assertIn('token-tv setup', err)
|
|
138
|
+
|
|
139
|
+
def test_demo_renders_every_face_from_sample_data(self):
|
|
140
|
+
code, out, _ = run('demo', '--out', str(self.tmp / 'demo'), '--scale', '2')
|
|
141
|
+
self.assertEqual(code, 0)
|
|
142
|
+
files = list((self.tmp / 'demo').iterdir())
|
|
143
|
+
self.assertEqual(len([p for p in files if '@' not in p.name]), len(STYLES))
|
|
144
|
+
self.assertIn('demo-space.gif', [p.name for p in files])
|
|
145
|
+
signature = {'.jpg': b'\xff\xd8\xff', '.gif': b'GIF8', '.png': b'\x89PNG'}
|
|
146
|
+
for path in files: # every extension matches the bytes inside
|
|
147
|
+
self.assertTrue(path.read_bytes().startswith(signature[path.suffix]), path.name)
|
|
148
|
+
self.assertIn('Sample data only', out)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class KeychainTest(unittest.TestCase):
|
|
152
|
+
def test_service_names_follow_claude_code(self):
|
|
153
|
+
root = Path('/Users/me/.token-tv/claude-b')
|
|
154
|
+
self.assertEqual(sources.keychain_services(root), ['Claude Code-credentials-' + __import__('hashlib')
|
|
155
|
+
.sha256(str(root).encode()).hexdigest()[:8]])
|
|
156
|
+
default = Path('~/.claude').expanduser()
|
|
157
|
+
self.assertEqual(sources.keychain_services(default)[-1], 'Claude Code-credentials')
|
|
158
|
+
|
|
159
|
+
def test_keychain_is_used_only_on_macos_without_a_file(self):
|
|
160
|
+
root = Path(tempfile.mkdtemp())
|
|
161
|
+
with mock.patch.object(sources.sys, 'platform', 'linux'):
|
|
162
|
+
with self.assertRaises(FileNotFoundError):
|
|
163
|
+
sources.claude_token(root)
|
|
164
|
+
found = mock.Mock(returncode=0, stdout=json.dumps({'claudeAiOauth': {'accessToken': 'synthetic'}}))
|
|
165
|
+
with mock.patch.object(sources.sys, 'platform', 'darwin'), \
|
|
166
|
+
mock.patch.object(sources.subprocess, 'run', return_value=found) as call, \
|
|
167
|
+
mock.patch.dict(os.environ, {'USER': 'me'}):
|
|
168
|
+
self.assertEqual(sources.claude_token(root), 'synthetic')
|
|
169
|
+
self.assertEqual(call.call_args[0][0][:5], ['security', 'find-generic-password', '-a', 'me', '-w'])
|
|
170
|
+
missing = mock.Mock(returncode=44, stdout='')
|
|
171
|
+
with mock.patch.object(sources.sys, 'platform', 'darwin'), \
|
|
172
|
+
mock.patch.object(sources.subprocess, 'run', return_value=missing):
|
|
173
|
+
with self.assertRaises(sources.SourceError):
|
|
174
|
+
sources.claude_token(root)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == '__main__':
|
|
178
|
+
unittest.main()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ConnectGuardTest(unittest.TestCase):
|
|
182
|
+
def test_everyday_login_is_kept_without_explicit_replace(self):
|
|
183
|
+
from token_tv import connect
|
|
184
|
+
home = Path(tempfile.mkdtemp())
|
|
185
|
+
(home / '.codex').mkdir()
|
|
186
|
+
(home / '.codex' / 'auth.json').write_text('{}')
|
|
187
|
+
config = home / 'config.json'
|
|
188
|
+
config.write_text(json.dumps({'accounts': [{'key': 'codex_a', 'alias': 'CODEX A', 'provider': 'codex',
|
|
189
|
+
'email': 'a@example.com', 'source_home': '~/.codex'}]}))
|
|
190
|
+
with mock.patch.dict(os.environ, {'HOME': str(home)}), \
|
|
191
|
+
mock.patch.object(connect.subprocess, 'run') as login, \
|
|
192
|
+
mock.patch.object(connect.sys, 'argv', ['connect', '--config', str(config), '--account', 'codex_a']):
|
|
193
|
+
out = io.StringIO()
|
|
194
|
+
with contextlib.redirect_stdout(out):
|
|
195
|
+
connect.main()
|
|
196
|
+
login.assert_not_called()
|
|
197
|
+
self.assertIn('Kept the existing login', out.getvalue())
|
|
198
|
+
|
|
199
|
+
def test_macos_keychain_login_is_protected_without_a_file(self):
|
|
200
|
+
from token_tv import connect
|
|
201
|
+
home = Path(tempfile.mkdtemp()) # no ~/.claude/.credentials.json at all
|
|
202
|
+
config = home / 'config.json'
|
|
203
|
+
config.write_text(json.dumps({'accounts': [{'key': 'claude_a', 'alias': 'CLAUDE A', 'provider': 'claude',
|
|
204
|
+
'email': 'a@example.com', 'source_home': '~/.claude'}]}))
|
|
205
|
+
with mock.patch.dict(os.environ, {'HOME': str(home)}), \
|
|
206
|
+
mock.patch.object(connect.sys, 'platform', 'darwin'), \
|
|
207
|
+
mock.patch.object(connect.subprocess, 'run') as login, \
|
|
208
|
+
mock.patch.object(connect.sys, 'argv', ['connect', '--config', str(config), '--account', 'claude_a']):
|
|
209
|
+
out = io.StringIO()
|
|
210
|
+
with contextlib.redirect_stdout(out):
|
|
211
|
+
connect.main()
|
|
212
|
+
login.assert_not_called()
|
|
213
|
+
self.assertIn('Keychain', out.getvalue())
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class StartTest(unittest.TestCase):
|
|
217
|
+
def setUp(self):
|
|
218
|
+
self.config = Path(tempfile.mkdtemp()) / 'config.json'
|
|
219
|
+
|
|
220
|
+
def start(self, *extra):
|
|
221
|
+
from token_tv import live
|
|
222
|
+
with mock.patch.object(cli, 'cli_path', return_value='/usr/bin/x'), \
|
|
223
|
+
mock.patch.object(cli, 'login_state', side_effect=lambda p, h: 'yes' if p != 'grok' else 'no'), \
|
|
224
|
+
mock.patch('token_tv.sources.logged_in_email',
|
|
225
|
+
side_effect=lambda p, h: {'claude': 'me@example.com', 'codex': None}[p]) as detect, \
|
|
226
|
+
mock.patch.object(live, 'main') as run:
|
|
227
|
+
code, out, err = run_cli('start', '--config', str(self.config), '--yes', '--no-browser', *extra)
|
|
228
|
+
return code, out, err, detect, run
|
|
229
|
+
|
|
230
|
+
def test_detects_signed_in_email_and_runs(self):
|
|
231
|
+
code, out, _, detect, run = self.start('--device-url', '192.168.0.50')
|
|
232
|
+
self.assertEqual(code, 0)
|
|
233
|
+
data = json.loads(self.config.read_text())
|
|
234
|
+
self.assertEqual([(a['provider'], a['email']) for a in data['accounts']], [('claude', 'me@example.com')])
|
|
235
|
+
self.assertEqual(data['device_url'], 'http://192.168.0.50') # bare IP is accepted
|
|
236
|
+
run.assert_called_once()
|
|
237
|
+
|
|
238
|
+
def test_existing_config_skips_detection(self):
|
|
239
|
+
self.config.write_text(json.dumps({'accounts': [{'key': 'codex_a', 'alias': 'CODEX A', 'provider': 'codex',
|
|
240
|
+
'email': 'b@example.com', 'source_home': '~/.codex'}]}))
|
|
241
|
+
code, _, _, detect, run = self.start()
|
|
242
|
+
self.assertEqual(code, 0)
|
|
243
|
+
detect.assert_not_called()
|
|
244
|
+
run.assert_called_once()
|
|
245
|
+
|
|
246
|
+
def saved_config(self, device_url):
|
|
247
|
+
config = {'device_url': device_url, 'accounts': [{'key': 'codex_a', 'alias': 'CODEX A', 'provider': 'codex',
|
|
248
|
+
'email': 'b@example.com', 'source_home': '~/.codex'}]}
|
|
249
|
+
self.config.write_text(json.dumps(config))
|
|
250
|
+
return config
|
|
251
|
+
|
|
252
|
+
def test_new_clock_address_with_existing_config_stops_and_says_where(self):
|
|
253
|
+
# Reported in #7: the clock's IP changed and --device-url was silently ignored.
|
|
254
|
+
saved = self.saved_config('http://192.168.0.50')
|
|
255
|
+
code, _, err, _, run = self.start('--device-url', '192.168.0.77')
|
|
256
|
+
self.assertEqual(code, 1)
|
|
257
|
+
self.assertIn('http://192.168.0.50', err)
|
|
258
|
+
self.assertIn('http://192.168.0.77', err)
|
|
259
|
+
self.assertIn(str(self.config), err)
|
|
260
|
+
self.assertEqual(json.loads(self.config.read_text()), saved)
|
|
261
|
+
run.assert_not_called()
|
|
262
|
+
|
|
263
|
+
def test_same_clock_address_with_existing_config_runs(self):
|
|
264
|
+
self.saved_config('http://192.168.0.50')
|
|
265
|
+
code, _, err, _, run = self.start('--device-url', '192.168.0.50')
|
|
266
|
+
self.assertEqual((code, err), (0, ''))
|
|
267
|
+
run.assert_called_once()
|
|
268
|
+
|
|
269
|
+
def test_nothing_signed_in_explains_next_step(self):
|
|
270
|
+
from token_tv import live
|
|
271
|
+
with mock.patch.object(cli, 'cli_path', return_value=None), mock.patch.object(live, 'main') as run:
|
|
272
|
+
code, _, err = run_cli('start', '--config', str(self.config), '--yes', '--no-browser')
|
|
273
|
+
self.assertEqual(code, 1)
|
|
274
|
+
self.assertIn('token-tv demo', err)
|
|
275
|
+
self.assertFalse(self.config.exists())
|
|
276
|
+
run.assert_not_called()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def run_cli(*argv):
|
|
280
|
+
return run(*argv)
|