@1aboveio/skills 1.0.7 → 1.0.9

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 (224) hide show
  1. package/.codex-plugin/plugin.json +13 -0
  2. package/.codex-plugin/resources.json +1827 -0
  3. package/README.md +22 -0
  4. package/package.json +9 -2
  5. package/skills/app-dev/airflow/SKILL.md +1 -1
  6. package/skills/app-dev/airflow/release-check.mjs +3 -1
  7. package/skills/app-dev/android-foundation/SKILL.md +92 -34
  8. package/skills/app-dev/android-foundation/assets/android-ci-job.yml +13 -7
  9. package/skills/app-dev/android-foundation/references/ci.md +23 -13
  10. package/skills/app-dev/android-foundation/references/delivery-focus.md +29 -0
  11. package/skills/app-dev/android-foundation/references/diagnosis.md +136 -0
  12. package/skills/app-dev/android-foundation/references/gotchas.md +20 -11
  13. package/skills/app-dev/android-foundation/references/setup.md +15 -6
  14. package/skills/app-dev/android-foundation/release-check.mjs +3 -1
  15. package/skills/app-dev/android-foundation/scripts/device-run.sh +148 -42
  16. package/skills/app-dev/app-debug/SKILL.md +1 -1
  17. package/skills/app-dev/app-debug/release-check.mjs +3 -1
  18. package/skills/app-dev/better-auth/SKILL.md +1 -1
  19. package/skills/app-dev/better-auth/release-check.mjs +3 -1
  20. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  21. package/skills/app-dev/data-catalog/release-check.mjs +3 -1
  22. package/skills/app-dev/data-quality/SKILL.md +1 -1
  23. package/skills/app-dev/data-quality/release-check.mjs +3 -1
  24. package/skills/app-dev/ios-foundation/SKILL.md +1 -1
  25. package/skills/app-dev/ios-foundation/release-check.mjs +3 -1
  26. package/skills/app-dev/monorepo/SKILL.md +1 -1
  27. package/skills/app-dev/monorepo/release-check.mjs +3 -1
  28. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  29. package/skills/app-dev/nextjs-fullstack/release-check.mjs +3 -1
  30. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  31. package/skills/app-dev/prisma-setup/release-check.mjs +3 -1
  32. package/skills/app-dev/pyspark/SKILL.md +1 -1
  33. package/skills/app-dev/pyspark/release-check.mjs +3 -1
  34. package/skills/app-dev/python-backend/SKILL.md +1 -1
  35. package/skills/app-dev/python-backend/release-check.mjs +3 -1
  36. package/skills/app-dev/shadcn/SKILL.md +1 -1
  37. package/skills/app-dev/shadcn/release-check.mjs +3 -1
  38. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  39. package/skills/app-dev/zod-v4/release-check.mjs +3 -1
  40. package/skills/cicd/cloud-build/SKILL.md +1 -1
  41. package/skills/cicd/cloud-build/release-check.mjs +3 -1
  42. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  43. package/skills/cicd/cloud-debug/release-check.mjs +3 -1
  44. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  45. package/skills/cicd/cloud-deploy/release-check.mjs +3 -1
  46. package/skills/cicd/docker/SKILL.md +1 -1
  47. package/skills/cicd/docker/release-check.mjs +3 -1
  48. package/skills/cicd/github-actions/SKILL.md +1 -1
  49. package/skills/cicd/github-actions/release-check.mjs +3 -1
  50. package/skills/cicd/github-flow/SKILL.md +1 -1
  51. package/skills/cicd/github-flow/release-check.mjs +3 -1
  52. package/skills/cicd/google-cloud/SKILL.md +1 -1
  53. package/skills/cicd/google-cloud/release-check.mjs +3 -1
  54. package/skills/cicd/mergify/SKILL.md +1 -1
  55. package/skills/cicd/mergify/release-check.mjs +3 -1
  56. package/skills/cicd/podman/SKILL.md +1 -1
  57. package/skills/cicd/podman/release-check.mjs +3 -1
  58. package/skills/content/article-review/SKILL.md +1 -1
  59. package/skills/content/article-review/release-check.mjs +3 -1
  60. package/skills/content/article-to-image/SKILL.md +1 -1
  61. package/skills/content/article-to-image/release-check.mjs +3 -1
  62. package/skills/content/flowchart-svg/SKILL.md +1 -1
  63. package/skills/content/flowchart-svg/release-check.mjs +3 -1
  64. package/skills/content/image-gen/SKILL.md +1 -1
  65. package/skills/content/image-gen/release-check.mjs +3 -1
  66. package/skills/content/mermaid/SKILL.md +1 -1
  67. package/skills/content/mermaid/release-check.mjs +3 -1
  68. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  69. package/skills/content/wechat-article-writer/release-check.mjs +3 -1
  70. package/skills/content/wechat-publisher/SKILL.md +1 -1
  71. package/skills/content/wechat-publisher/release-check.mjs +3 -1
  72. package/skills/content/wechat-search/SKILL.md +1 -1
  73. package/skills/content/wechat-search/release-check.mjs +3 -1
  74. package/skills/content/xai-search/SKILL.md +88 -13
  75. package/skills/content/xai-search/release-check.mjs +3 -1
  76. package/skills/content/xai-search/scripts/xai_search.sh +125 -149
  77. package/skills/core/distill-writing/SKILL.md +1 -1
  78. package/skills/core/distill-writing/release-check.mjs +3 -1
  79. package/skills/core/doc-to-md/SKILL.md +1 -1
  80. package/skills/core/doc-to-md/release-check.mjs +3 -1
  81. package/skills/core/editorial-review/SKILL.md +1 -1
  82. package/skills/core/editorial-review/release-check.mjs +3 -1
  83. package/skills/core/house-style/SKILL.md +1 -1
  84. package/skills/core/house-style/release-check.mjs +3 -1
  85. package/skills/core/plain-language/SKILL.md +1 -1
  86. package/skills/core/plain-language/release-check.mjs +3 -1
  87. package/skills/core/polish-cn/SKILL.md +1 -1
  88. package/skills/core/polish-cn/release-check.mjs +3 -1
  89. package/skills/core/reg-parser/SKILL.md +1 -1
  90. package/skills/core/reg-parser/release-check.mjs +3 -1
  91. package/skills/core/terminology/SKILL.md +1 -1
  92. package/skills/core/terminology/release-check.mjs +3 -1
  93. package/skills/core/write-doc/SKILL.md +1 -1
  94. package/skills/core/write-doc/release-check.mjs +3 -1
  95. package/skills/engineering/better-review/SKILL.md +1 -1
  96. package/skills/engineering/better-review/release-check.mjs +3 -1
  97. package/skills/engineering/contributing/SKILL.md +1 -1
  98. package/skills/engineering/contributing/release-check.mjs +3 -1
  99. package/skills/engineering/e2e-test/SKILL.md +1 -1
  100. package/skills/engineering/e2e-test/release-check.mjs +3 -1
  101. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  102. package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
  103. package/skills/engineering/engineering-runtime/release-check.mjs +3 -1
  104. package/skills/engineering/engineering-runtime/scripts/installation-resolver.mjs +191 -0
  105. package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +51 -10
  106. package/skills/engineering/ensure-coverage/SKILL.md +1 -1
  107. package/skills/engineering/ensure-coverage/release-check.mjs +3 -1
  108. package/skills/engineering/golden-path/SKILL.md +1 -1
  109. package/skills/engineering/golden-path/release-check.mjs +3 -1
  110. package/skills/engineering/harness-runtime/SKILL.md +1 -1
  111. package/skills/engineering/harness-runtime/release-check.mjs +3 -1
  112. package/skills/engineering/resolve-issues/SKILL.md +11 -20
  113. package/skills/engineering/resolve-issues/references/delivery-progress.md +63 -0
  114. package/skills/engineering/resolve-issues/references/expire.md +24 -11
  115. package/skills/engineering/resolve-issues/references/implementation.md +9 -1
  116. package/skills/engineering/resolve-issues/references/preflight.md +7 -0
  117. package/skills/engineering/resolve-issues/references/shared-modules.md +27 -0
  118. package/skills/engineering/resolve-issues/references/task-plan.md +12 -5
  119. package/skills/engineering/resolve-issues/release-check.mjs +3 -1
  120. package/skills/engineering/resolve-issues/scripts/active-installation.mjs +32 -0
  121. package/skills/engineering/resolve-issues/scripts/install-hooks.mjs +3 -0
  122. package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +17 -1
  123. package/skills/engineering/resolve-release/SKILL.md +1 -1
  124. package/skills/engineering/resolve-release/release-check.mjs +3 -1
  125. package/skills/engineering/rush-issues/SKILL.md +5 -2
  126. package/skills/engineering/rush-issues/release-check.mjs +3 -1
  127. package/skills/engineering/rush-release/SKILL.md +1 -1
  128. package/skills/engineering/rush-release/release-check.mjs +3 -1
  129. package/skills/engineering/test-quality/SKILL.md +1 -1
  130. package/skills/engineering/test-quality/release-check.mjs +3 -1
  131. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  132. package/skills/risk-compliance/compliance-audit/release-check.mjs +3 -1
  133. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  134. package/skills/risk-compliance/contract-compare/release-check.mjs +3 -1
  135. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  136. package/skills/risk-compliance/contract-review/release-check.mjs +3 -1
  137. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  138. package/skills/risk-compliance/doc-authenticity/release-check.mjs +3 -1
  139. package/skills/risk-compliance/fraud-analysis/SKILL.md +1 -1
  140. package/skills/risk-compliance/fraud-analysis/release-check.mjs +3 -1
  141. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  142. package/skills/risk-compliance/payment-analysis/release-check.mjs +3 -1
  143. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  144. package/skills/risk-compliance/similar-domain/release-check.mjs +3 -1
  145. package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
  146. package/skills/risk-compliance/tld-detection/release-check.mjs +3 -1
  147. package/skills/app-dev/android-foundation/scripts/device-run.test.mjs +0 -172
  148. package/skills/app-dev/data-catalog/scripts/data-catalog.test.mjs +0 -36
  149. package/skills/app-dev/data-quality/scripts/data-quality.test.mjs +0 -12
  150. package/skills/cicd/github-actions/scripts/ci-run-audit.test.mjs +0 -637
  151. package/skills/cicd/github-flow/scripts/flow.test.mjs +0 -114
  152. package/skills/cicd/github-flow/scripts/persist-conventions.test.mjs +0 -108
  153. package/skills/cicd/mergify/scripts/audit.test.mjs +0 -201
  154. package/skills/cicd/mergify/scripts/entrypoints.test.mjs +0 -106
  155. package/skills/cicd/mergify/scripts/pr-620-mergify-comments.fixture.json +0 -30
  156. package/skills/cicd/mergify/scripts/stack-push.test.mjs +0 -233
  157. package/skills/cicd/mergify/scripts/watch-pr-delivery.test.mjs +0 -1826
  158. package/skills/content/image-gen/scripts/credentials.test.mjs +0 -212
  159. package/skills/content/image-gen/scripts/options.test.mjs +0 -245
  160. package/skills/content/xai-search/tests/smoke_test.sh +0 -36
  161. package/skills/core/polish-cn/scripts/polish-cn.test.mjs +0 -244
  162. package/skills/core/reg-parser/scripts/reg-parser.test.mjs +0 -165
  163. package/skills/engineering/better-review/scripts/pr-context.test.mjs +0 -168
  164. package/skills/engineering/better-review/scripts/rereview-scope.test.mjs +0 -587
  165. package/skills/engineering/better-review/scripts/scan-diff.test.mjs +0 -414
  166. package/skills/engineering/better-review/scripts/vendor-sync.test.mjs +0 -96
  167. package/skills/engineering/contributing/scripts/persist-conventions.test.mjs +0 -118
  168. package/skills/engineering/e2e-test/scripts/presentation-checks.test.mjs +0 -106
  169. package/skills/engineering/e2e-test/scripts/presentation-perceivability.test.mjs +0 -117
  170. package/skills/engineering/e2e-test/scripts/presentation-reachability.test.mjs +0 -69
  171. package/skills/engineering/e2e-test/scripts/presentation-render-health.test.mjs +0 -98
  172. package/skills/engineering/e2e-test/scripts/presentation-sweep.test.mjs +0 -71
  173. package/skills/engineering/e2e-test/scripts/presentation-temporal.test.mjs +0 -59
  174. package/skills/engineering/e2e-test/scripts/project-route-manifest.test.mjs +0 -76
  175. package/skills/engineering/engineering-runtime/engineering-runtime.test.mjs +0 -68
  176. package/skills/engineering/engineering-runtime/scripts/invocation-policy.test.mjs +0 -95
  177. package/skills/engineering/engineering-runtime/scripts/main-module.test.mjs +0 -288
  178. package/skills/engineering/engineering-runtime/scripts/required-check-plan.test.mjs +0 -96
  179. package/skills/engineering/ensure-coverage/scripts/adapters/nextjs-inventory.test.mjs +0 -96
  180. package/skills/engineering/ensure-coverage/scripts/check-quarantine-expiry.test.mjs +0 -47
  181. package/skills/engineering/ensure-coverage/scripts/ci-audit.test.mjs +0 -86
  182. package/skills/engineering/ensure-coverage/scripts/coverage-ledger.test.mjs +0 -447
  183. package/skills/engineering/ensure-coverage/scripts/design-parity.test.mjs +0 -552
  184. package/skills/engineering/ensure-coverage/scripts/lint-tests.test.mjs +0 -289
  185. package/skills/engineering/ensure-coverage/scripts/mock-policy-config.test.mjs +0 -119
  186. package/skills/engineering/golden-path/scripts/golden-path.test.mjs +0 -1457
  187. package/skills/engineering/harness-runtime/discover-models.test.mjs +0 -363
  188. package/skills/engineering/harness-runtime/harness-runtime.test.mjs +0 -967
  189. package/skills/engineering/harness-runtime/model-catalog-seed.test.mjs +0 -829
  190. package/skills/engineering/harness-runtime/model-catalog.test.mjs +0 -366
  191. package/skills/engineering/resolve-issues/scripts/cleanup-runs.test.mjs +0 -280
  192. package/skills/engineering/resolve-issues/scripts/dropbox.test.mjs +0 -346
  193. package/skills/engineering/resolve-issues/scripts/findings-reuse.test.mjs +0 -88
  194. package/skills/engineering/resolve-issues/scripts/github-reviews.test.mjs +0 -264
  195. package/skills/engineering/resolve-issues/scripts/install-hooks.test.mjs +0 -79
  196. package/skills/engineering/resolve-issues/scripts/issue-tree.test.mjs +0 -107
  197. package/skills/engineering/resolve-issues/scripts/manual-invocation.test.mjs +0 -14
  198. package/skills/engineering/resolve-issues/scripts/next-actions-capacity.test.mjs +0 -149
  199. package/skills/engineering/resolve-issues/scripts/next-actions.test.mjs +0 -915
  200. package/skills/engineering/resolve-issues/scripts/plan-sync.test.mjs +0 -156
  201. package/skills/engineering/resolve-issues/scripts/preflight-models.test.mjs +0 -448
  202. package/skills/engineering/resolve-issues/scripts/ready-check.test.mjs +0 -217
  203. package/skills/engineering/resolve-issues/scripts/rehydrate.test.mjs +0 -91
  204. package/skills/engineering/resolve-issues/scripts/review-verdict.test.mjs +0 -1060
  205. package/skills/engineering/resolve-issues/scripts/size-check.test.mjs +0 -458
  206. package/skills/engineering/resolve-release/scripts/apply.test.mjs +0 -99
  207. package/skills/engineering/resolve-release/scripts/dark-flags.test.mjs +0 -100
  208. package/skills/engineering/resolve-release/scripts/flow.test.mjs +0 -340
  209. package/skills/engineering/resolve-release/scripts/green-head.test.mjs +0 -101
  210. package/skills/engineering/resolve-release/scripts/npm-version-absence.test.mjs +0 -426
  211. package/skills/engineering/resolve-release/scripts/plan-npm.test.mjs +0 -659
  212. package/skills/engineering/resolve-release/scripts/plan-toml-boundary.test.mjs +0 -317
  213. package/skills/engineering/resolve-release/scripts/plan-uv.test.mjs +0 -291
  214. package/skills/engineering/resolve-release/scripts/plan.test.mjs +0 -428
  215. package/skills/engineering/test-quality/scripts/lint-tests.test.mjs +0 -591
  216. package/skills/engineering/test-quality/scripts/mock-policy-config.test.mjs +0 -119
  217. package/skills/engineering/test-quality/scripts/persist-conventions.test.mjs +0 -104
  218. package/skills/risk-compliance/compliance-audit/scripts/compliance-audit.test.mjs +0 -14
  219. package/skills/risk-compliance/doc-authenticity/manual-invocation.test.mjs +0 -12
  220. package/skills/risk-compliance/doc-authenticity/scripts/validate_verdict.test.mjs +0 -212
  221. package/skills/risk-compliance/tld-detection/scripts/detect_ladders.test.mjs +0 -14
  222. package/skills/risk-compliance/tld-detection/scripts/detect_streams.test.mjs +0 -14
  223. package/skills/risk-compliance/tld-detection/tests/test_detect_ladders.py +0 -134
  224. package/skills/risk-compliance/tld-detection/tests/test_detect_streams.py +0 -520
@@ -1,520 +0,0 @@
1
- import csv
2
- from copy import deepcopy
3
- import importlib.util
4
- import json
5
- from pathlib import Path
6
- import random
7
- import subprocess
8
- import sys
9
- import tempfile
10
- from types import SimpleNamespace
11
- import unittest
12
-
13
-
14
- sys.dont_write_bytecode = True
15
- SCRIPT = Path(__file__).parents[1] / "scripts" / "detect_streams.py"
16
- SPEC = importlib.util.spec_from_file_location("detect_streams", SCRIPT)
17
- MODULE = importlib.util.module_from_spec(SPEC)
18
- assert SPEC.loader is not None
19
- sys.modules[SPEC.name] = MODULE
20
- SPEC.loader.exec_module(MODULE)
21
-
22
- DAYS = [f"2026-08-{d:02d}" for d in range(1, 21)]
23
- FIELDS = ["amount", "currency", "ts", "descriptor", "country", "response", "account", "card_type", "avs", "auth"]
24
-
25
-
26
- def rates_cache() -> dict:
27
- """USD base; EUR drifts 1.15 -> 1.17, AUD 0.70 -> 0.72 over the window (quoted as units per USD)."""
28
- rates = {}
29
- for i, day in enumerate(DAYS):
30
- eur = 1.15 + 0.001 * i
31
- aud = 0.70 + 0.001 * i
32
- rates[day] = {"EUR": 1 / eur, "AUD": 1 / aud, "GBP": 1 / 1.34, "JPY": 147.0}
33
- return {"base": "USD", "rates": rates}
34
-
35
-
36
- def synthetic_rows() -> list[dict]:
37
- rng = random.Random(3)
38
- rates = rates_cache()["rates"]
39
- rows = []
40
- acc = 0
41
-
42
- def row(amount, day, ctry, resp="A001", account=None, card="Debit", avs="Postal code not checked", auth="None", desc="HLP*shop", hour=None):
43
- nonlocal acc
44
- acc += 1
45
- h = hour if hour is not None else rng.randint(0, 23)
46
- return {"amount": f"{amount:.2f}", "currency": "USD", "ts": f"{day} {h:02d}:{rng.randint(0, 59):02d}:{rng.randint(0, 59):02d}",
47
- "descriptor": desc, "country": ctry, "response": resp, "account": account or f"4{acc:05d}******{acc % 9999:04d}",
48
- "card_type": card, "avs": avs, "auth": auth}
49
-
50
- # Euro-wallet players (Germany): presets 10/20/50 at live rate with 0-1% markup, some retries
51
- for _ in range(400):
52
- day = rng.choice(DAYS)
53
- preset = rng.choice([10, 10, 10, 20, 20, 50])
54
- usd = preset / rates[day]["EUR"] * (1 + rng.uniform(0, 0.01))
55
- rows.append(row(usd, day, "Germany", resp=rng.choice(["A001", "A001", "D102"])))
56
- # Australian players: A$30 / A$50 live
57
- for _ in range(300):
58
- day = rng.choice(DAYS)
59
- preset = rng.choice([30, 30, 50])
60
- usd = preset / rates[day]["AUD"] * (1 + rng.uniform(0, 0.01))
61
- rows.append(row(usd, day, "Australia"))
62
- # US cards: euro presets 24/48 at ONE frozen rate for six days, one attempt per card, lunchtime
63
- frozen = 1.1679
64
- for _ in range(300):
65
- day = rng.choice(DAYS[10:16])
66
- preset = rng.choice([24, 24, 48])
67
- rows.append(row(round(preset * frozen, 2), day, "United States of America", resp=rng.choice(["A001", "D102"]), avs="Postal code matched", hour=rng.choice([16, 17, 18])))
68
- # Canada: retail list x 1.06 with US-style billing address
69
- for _ in range(250):
70
- day = rng.choice(DAYS)
71
- price = rng.choice([79.99, 84.99, 89.99, 134.99, 59.95, 239.98, 25.98, 63.98, 137.99, 199.99])
72
- rows.append(row(round(price * 1.06, 2), day, "Canada", avs="Postal code matched", card="Credit"))
73
- # Japan: off-menu typed amounts
74
- for _ in range(200):
75
- day = rng.choice(DAYS)
76
- rows.append(row(rng.uniform(15, 400), day, "Japan"))
77
- return rows
78
-
79
-
80
- class RatesTest(unittest.TestCase):
81
- def test_lookup_uses_query_date_and_bounded_prior_day_carry(self):
82
- cache = {"base": "USD", "rates": {
83
- "2026-08-01": {"EUR": 0.8},
84
- "2026-08-04": {"EUR": 0.82},
85
- }}
86
- cases = [
87
- ("before_cache", "2026-07-31", 7, None, {}),
88
- ("exact_first", "2026-08-01", 7, 0.8, {}),
89
- ("gap", "2026-08-03", 7, 0.8, {"EUR": 1}),
90
- ("exact_last", "2026-08-04", 7, 0.82, {}),
91
- ("after_cache", "2026-08-05", 7, 0.82, {"EUR": 1}),
92
- ("max_carry", "2026-08-11", 7, 0.82, {"EUR": 1}),
93
- ("beyond_carry", "2026-08-12", 7, None, {}),
94
- ("far_after_cache", "2027-08-04", 7, None, {}),
95
- ("configured_limit", "2026-08-06", 1, None, {}),
96
- ("no_carry_missing", "2026-08-05", 0, None, {}),
97
- ("no_carry_exact", "2026-08-04", 0, 0.82, {}),
98
- ]
99
- for name, day, carry, expected, carried in cases:
100
- with self.subTest(name=name):
101
- rates = MODULE.Rates(cache, carry=carry)
102
- self.assertEqual(rates.rate(day, "EUR"), expected)
103
- self.assertEqual(dict(rates.carried), carried)
104
-
105
- def test_missing_currency_searches_prior_quotes_not_just_cache_dates(self):
106
- rates = MODULE.Rates({"base": "USD", "rates": {
107
- "2026-08-01": {"EUR": 0.8},
108
- "2026-08-08": {"GBP": 0.6},
109
- }}, carry=7)
110
- self.assertEqual(rates.rate("2026-08-08", "EUR"), 0.8)
111
- self.assertIsNone(rates.rate("2026-08-09", "EUR"))
112
- self.assertIsNone(rates.rate("2026-08-08", "CHF"))
113
- self.assertEqual(dict(rates.carried), {"EUR": 1})
114
-
115
- def test_identity_and_usd_pegs_do_not_require_dated_quotes(self):
116
- rates = MODULE.Rates({"base": "USD", "rates": {}}, carry=0)
117
- self.assertEqual(rates.rate("2026-08-01", "USD"), 1.0)
118
- self.assertEqual(rates.rate("2026-08-01", "SAR"), 3.75)
119
- self.assertIsNone(rates.rate("2026-08-01", "EUR"))
120
- self.assertEqual(dict(rates.carried), {})
121
-
122
-
123
- class RateConsistencyTest(unittest.TestCase):
124
- def setUp(self):
125
- self.opt = SimpleNamespace(peak_tol=0.012, frozen_days=4,
126
- frozen_min_per_day=3, frozen_market_move=0.001)
127
-
128
- def consistency(self, quotes, charges, wallet="EUR", center=24):
129
- cache = {"base": "USD", "rates": dict(zip(DAYS, quotes))}
130
- rows = [
131
- {"day": day, "currency": currency, "amount": amount}
132
- for day, daily_charges in zip(DAYS, charges)
133
- for currency, amount, count in daily_charges
134
- for _ in range(count)
135
- ]
136
- return MODULE.rate_consistency(rows, MODULE.Rates(cache), wallet,
137
- [(center, len(rows))], self.opt)
138
-
139
- def test_non_usd_source_freezes_when_cross_rate_moves_but_wallet_quote_does_not(self):
140
- quotes = [{"EUR": 0.8, "GBP": gbp} for gbp in (0.6, 0.6012, 0.6024, 0.6036)]
141
- result = self.consistency(quotes, [[("GBP", 18, 3)]] * 4)
142
- self.assertEqual(result["frozen_attempts"], 12)
143
- self.assertEqual(result["tested_attempts"], 12)
144
- self.assertEqual(result["frozen_peaks"], 1)
145
- self.assertTrue(result["detail"][0]["frozen"])
146
- # EUR per GBP changes from 4/3 to 2000/1509, about 0.5964%.
147
- self.assertEqual(result["detail"][0]["market_move"], 0.006)
148
-
149
- def test_matching_source_and_wallet_is_not_frozen_by_usd_quote_movement(self):
150
- quotes = [{"EUR": eur} for eur in (0.8, 0.804, 0.808, 0.812)]
151
- result = self.consistency(quotes, [[("EUR", 24, 3)]] * 4)
152
- self.assertEqual(result["frozen_attempts"], 0)
153
- self.assertEqual(result["tested_attempts"], 12)
154
- self.assertEqual(result["live_peaks"], 1)
155
- self.assertFalse(result["detail"][0]["frozen"])
156
- self.assertEqual(result["detail"][0]["market_move"], 0.0)
157
-
158
- def test_parallel_source_and_wallet_quotes_have_no_cross_rate_movement(self):
159
- quotes = [{"EUR": eur, "GBP": gbp} for eur, gbp in
160
- ((0.8, 0.6), (0.804, 0.603), (0.808, 0.606), (0.812, 0.609))]
161
- result = self.consistency(quotes, [[("GBP", 18, 3)]] * 4)
162
- self.assertEqual(result["frozen_attempts"], 0)
163
- self.assertEqual(result["tested_attempts"], 12)
164
- self.assertFalse(result["detail"][0]["frozen"])
165
- self.assertEqual(result["detail"][0]["market_move"], 0.0)
166
-
167
- def test_live_non_usd_source_tracks_cross_rate(self):
168
- quotes = [{"EUR": 0.8, "GBP": gbp} for gbp in (0.6, 0.6012, 0.6024, 0.6036)]
169
- charges = [[("GBP", amount, 3)] for amount in (18, 18.036, 18.072, 18.108)]
170
- result = self.consistency(quotes, charges)
171
- self.assertEqual(result["frozen_attempts"], 0)
172
- self.assertEqual(result["tested_attempts"], 12)
173
- self.assertFalse(result["detail"][0]["frozen"])
174
- self.assertEqual(result["detail"][0]["market_move"], 0.006)
175
-
176
- def test_base_currency_wallet_can_freeze_against_non_base_source(self):
177
- quotes = [{"EUR": eur} for eur in (0.8, 0.8016, 0.8032, 0.8048)]
178
- result = self.consistency(quotes, [[("EUR", 24, 3)]] * 4, wallet="USD", center=30)
179
- self.assertEqual(result["frozen_attempts"], 12)
180
- self.assertEqual(result["tested_attempts"], 12)
181
- self.assertTrue(result["detail"][0]["frozen"])
182
- self.assertEqual(result["detail"][0]["market_move"], 0.006)
183
-
184
- def test_mixed_sources_keep_modes_movements_and_attempts_separate(self):
185
- quotes = [{"EUR": 0.8, "GBP": gbp} for gbp in (0.6, 0.6012, 0.6024, 0.6036)]
186
- result = self.consistency(quotes, [[("EUR", 24, 6), ("GBP", 18, 3)]] * 4)
187
- self.assertEqual(result["tested_attempts"], 36)
188
- self.assertEqual(result["frozen_attempts"], 12)
189
- self.assertEqual(result["frozen_peaks"], 1)
190
- self.assertEqual(result["live_peaks"], 1)
191
- self.assertEqual(len(result["detail"]), 2)
192
- details = {d["source_currency"]: d for d in result["detail"]}
193
- self.assertFalse(details["EUR"]["frozen"])
194
- self.assertEqual(details["EUR"]["modal_amounts"], [24])
195
- self.assertEqual(details["EUR"]["market_move"], 0.0)
196
- self.assertEqual(details["EUR"]["attempts"], 24)
197
- self.assertTrue(details["GBP"]["frozen"])
198
- self.assertEqual(details["GBP"]["modal_amounts"], [18])
199
- self.assertEqual(details["GBP"]["market_move"], 0.006)
200
- self.assertEqual(details["GBP"]["attempts"], 12)
201
-
202
- def test_mixed_sources_cannot_pool_days_or_daily_minimum_for_testing(self):
203
- quotes = [{"EUR": 0.8, "GBP": 0.6}] * 4
204
- cases = [
205
- [[("EUR", 24, 2), ("GBP", 18, 2)]] * 4,
206
- [[("EUR", 24, 3)]] * 2 + [[("GBP", 18, 3)]] * 2,
207
- ]
208
- for charges in cases:
209
- with self.subTest(charges=charges):
210
- result = self.consistency(quotes, charges)
211
- self.assertEqual(result["tested_attempts"], 0)
212
- self.assertEqual(result["frozen_attempts"], 0)
213
- self.assertEqual(result["detail"], [])
214
-
215
-
216
- class PaymentPatternPresentationTest(unittest.TestCase):
217
- def run_cli(self, rows, candidates=("EUR",), cache=None, extra=()):
218
- with tempfile.TemporaryDirectory() as tmp:
219
- root = Path(tmp)
220
- csv_path = root / "auth.csv"
221
- with csv_path.open("w", newline="") as fh:
222
- writer = csv.DictWriter(fh, fieldnames=FIELDS)
223
- writer.writeheader()
224
- writer.writerows(rows)
225
- rates_path = root / "rates.json"
226
- rates_path.write_text(json.dumps(cache or rates_cache()))
227
- markdown_path = root / "streams.md"
228
- cmd = [sys.executable, str(SCRIPT), str(csv_path), "--rates", str(rates_path),
229
- "--amount-column", "amount", "--currency-column", "currency", "--date-column", "ts",
230
- "--descriptor-column", "descriptor", "--bin-country-column", "country",
231
- "--candidate-currencies", *candidates, "--markdown", str(markdown_path), *extra]
232
- result = subprocess.run(cmd, capture_output=True, text=True)
233
- self.assertEqual(result.returncode, 0, result.stderr)
234
- report = json.loads(result.stdout)
235
- markdown = markdown_path.read_text()
236
- before = deepcopy(report)
237
- self.assertEqual(MODULE.to_markdown(report), markdown)
238
- self.assertEqual(report, before)
239
- descriptor = report["descriptors"][0]
240
- self.assertEqual(descriptor["attempts"], len(rows))
241
- self.assertEqual(sum(s["attempts"] for s in descriptor["streams"]), len(rows))
242
- for stream in descriptor["streams"] + [s for g in descriptor["groups"] for s in g["substreams"]]:
243
- p = stream["presentation"]
244
- self.assertEqual(sum(p["pattern_counts"].values()), stream["attempts"])
245
- self.assertEqual(sum(p["charge_currencies"].values()), stream["attempts"])
246
- self.assertEqual(sum(part["attempts"] for part in p["by_charge_currency"]), stream["attempts"])
247
- return descriptor, markdown
248
-
249
- def rows(self, charges, country="United Kingdom"):
250
- return [{"amount": f"{amount:.2f}", "currency": currency, "ts": f"{day} 12:00:00",
251
- "descriptor": "pattern-fixture", "country": country}
252
- for day, daily in zip(DAYS, charges)
253
- for currency, amount, count in daily
254
- for _ in range(count)]
255
-
256
- def markdown_row(self, markdown, raw, country=None):
257
- prefix = f"| {raw} |" if country is None else f"| {country} |"
258
- matches = [line for line in markdown.splitlines() if line.startswith(prefix) and f"| {raw} |" in line]
259
- self.assertEqual(len(matches), 1, matches)
260
- return [cell.strip() for cell in matches[0].split("|")[1:-1]]
261
-
262
- def test_direct_usd_and_eur_are_based_on_charge_currency_not_legal_tender(self):
263
- for currency in ("USD", "EUR"):
264
- with self.subTest(currency=currency):
265
- rows = self.rows([[(currency, 24, 20)]] * 4)
266
- descriptor, markdown = self.run_cli(rows, candidates=(currency,))
267
- group = descriptor["groups"][0]
268
- stream = group["substreams"][0]
269
- self.assertEqual(group["legal_tender"], "GBP")
270
- self.assertEqual(stream["stream"], f"{currency} menu-12 live")
271
- self.assertEqual(stream["attempts"], 80)
272
- p = stream["presentation"]
273
- self.assertEqual(p["pattern_counts"], {"Direct top-up pattern": 80})
274
- self.assertEqual(p["charge_currencies"], {currency: 80})
275
- self.assertEqual(p["inferred_top_up_currencies"], [currency])
276
- self.assertEqual(p["pricing_characteristics"], ["fixed amount"])
277
- self.assertEqual(p["by_charge_currency"][0]["frozen_fx_attempts"], 0)
278
- cells = self.markdown_row(markdown, stream["stream"], group["country"])
279
- self.assertIn(f"Direct top-up pattern: 80 [{currency}: 80]", cells[3])
280
- self.assertEqual(cells[4:7], [currency, f"{currency}: 80", "fixed amount"])
281
- self.assertNotIn("frozen FX", cells[3])
282
- self.assertNotIn("floating", cells[3])
283
-
284
- def test_fx_currency_and_no_frozen_qualifier_without_positive_cross_rate_test(self):
285
- rows = self.rows([[('USD', 24 / rates_cache()["rates"][day]["EUR"], 20)] for day in DAYS[:4]])
286
- descriptor, markdown = self.run_cli(rows)
287
- stream = descriptor["groups"][0]["substreams"][0]
288
- self.assertEqual(stream["stream"], "EUR menu-12 live")
289
- self.assertEqual(stream["presentation"]["pattern_counts"], {"FX-converted top-up pattern": 80})
290
- self.assertEqual(stream["presentation"]["charge_currencies"], {"USD": 80})
291
- self.assertEqual(stream["presentation"]["pricing_characteristics"], [])
292
- cells = self.markdown_row(markdown, stream["stream"])
293
- self.assertIn("FX-converted top-up pattern: 80 [USD: 80]", cells[1])
294
- self.assertEqual(cells[2:4], ["EUR", "USD: 80"])
295
- self.assertEqual(cells[4], "n/a")
296
- self.assertNotIn("frozen FX", cells[1])
297
- self.assertNotIn("floating", cells[1])
298
-
299
- def test_repetition_without_cross_rate_tests_has_no_frozen_qualifier(self):
300
- charge = 24 / rates_cache()["rates"][DAYS[0]]["EUR"]
301
- rows = self.rows([[('USD', charge, 80)]])
302
- descriptor, markdown = self.run_cli(rows)
303
- stream = descriptor["groups"][0]["substreams"][0]
304
- self.assertEqual(stream["stream"], "EUR menu-12 live")
305
- self.assertEqual(stream["rate_consistency"]["tested_attempts"], 0)
306
- self.assertEqual(stream["rate_consistency"]["detail"], [])
307
- self.assertEqual(stream["presentation"]["by_charge_currency"][0]["frozen_fx_attempts"], 0)
308
- cells = self.markdown_row(markdown, stream["stream"])
309
- self.assertIn("FX-converted top-up pattern: 80 [USD: 80]", cells[1])
310
- self.assertEqual(cells[4], "n/a")
311
- self.assertNotIn("frozen", cells[1])
312
- self.assertNotIn("floating", cells[1])
313
-
314
- def test_mixed_sources_show_both_types_and_only_tested_source_is_frozen(self):
315
- cache = {"base": "USD", "rates": {day: {"EUR": 0.8, "GBP": 0.6 + i * 0.0012}
316
- for i, day in enumerate(DAYS[:4])}}
317
- rows = self.rows([[('EUR', 24, 12), ('GBP', 18, 8)]] * 4)
318
- descriptor, markdown = self.run_cli(rows, cache=cache)
319
- stream = descriptor["groups"][0]["substreams"][0]
320
- self.assertEqual(stream["stream"], "EUR menu-12 live")
321
- self.assertEqual(len(descriptor["streams"]), 1)
322
- self.assertEqual(len(descriptor["groups"][0]["substreams"]), 1)
323
- p = stream["presentation"]
324
- self.assertEqual(p["pattern_counts"], {"Direct top-up pattern": 48, "FX-converted top-up pattern": 32})
325
- self.assertEqual(p["charge_currencies"], {"EUR": 48, "GBP": 32})
326
- parts = {part["charge_currency"]: part for part in p["by_charge_currency"]}
327
- self.assertEqual(parts["EUR"]["frozen_fx_attempts"], 0)
328
- self.assertEqual(parts["GBP"]["frozen_fx_attempts"], 32)
329
- self.assertEqual(parts["EUR"]["inferred_top_up_currency"], "EUR")
330
- self.assertEqual(parts["GBP"]["inferred_top_up_currency"], "EUR")
331
- cells = self.markdown_row(markdown, stream["stream"], "United Kingdom")
332
- self.assertIn("Direct top-up pattern: 48 [EUR: 48]", cells[3])
333
- self.assertIn("FX-converted top-up pattern: 32 [GBP: 32 (32 frozen FX tested attempts)]", cells[3])
334
- self.assertEqual(cells[4:6], ["EUR", "EUR: 48, GBP: 32"])
335
- self.assertEqual(descriptor["streams"][0]["presentation"], p)
336
-
337
- def test_summary_aggregates_same_raw_stream_without_splitting_payment_patterns(self):
338
- direct = self.rows([[('EUR', 24, 20)]] * 4, country="Germany")
339
- fx = self.rows([[('USD', 24 / rates_cache()["rates"][day]["EUR"], 20)] for day in DAYS[:4]])
340
- descriptor, markdown = self.run_cli(direct + fx)
341
- self.assertEqual(len(descriptor["groups"]), 2)
342
- self.assertEqual(len(descriptor["streams"]), 1)
343
- stream = descriptor["streams"][0]
344
- self.assertEqual(stream["stream"], "EUR menu-12 live")
345
- self.assertEqual(stream["attempts"], 160)
346
- self.assertEqual(stream["share_of_descriptor"], 1.0)
347
- self.assertEqual(stream["presentation"]["pattern_counts"], {"Direct top-up pattern": 80, "FX-converted top-up pattern": 80})
348
- self.assertEqual(stream["presentation"]["charge_currencies"], {"EUR": 80, "USD": 80})
349
- cells = self.markdown_row(markdown, stream["stream"])
350
- self.assertIn("Direct top-up pattern: 80 [EUR: 80]", cells[1])
351
- self.assertIn("FX-converted top-up pattern: 80 [USD: 80]", cells[1])
352
-
353
- def test_summary_detects_fixed_charges_across_country_dates(self):
354
- germany = self.rows([[('EUR', 24, 80)]], country="Germany")
355
- uk = self.rows([[], [('EUR', 24, 80)]], country="United Kingdom")
356
- descriptor, markdown = self.run_cli(germany + uk, candidates=("EUR",))
357
- self.assertEqual(len(descriptor["streams"]), 1)
358
- summary = descriptor["streams"][0]
359
- self.assertEqual(summary["attempts"], 160)
360
- self.assertEqual(summary["presentation"]["pattern_counts"], {"Direct top-up pattern": 160})
361
- self.assertEqual(summary["presentation"]["pricing_characteristics"], ["fixed amount"])
362
- for group in descriptor["groups"]:
363
- self.assertEqual(group["substreams"][0]["presentation"]["pricing_characteristics"], [])
364
- self.assertEqual(self.markdown_row(markdown, summary["stream"])[4], "fixed amount")
365
-
366
- def test_menu_none_is_other_repeated_amount_not_inferred_top_up(self):
367
- rows = self.rows([[('EUR', 17.37, 20)]] * 4, country="UNKNOWN")
368
- descriptor, markdown = self.run_cli(rows)
369
- stream = descriptor["groups"][0]["substreams"][0]
370
- self.assertEqual(stream["stream"], "EUR menu-none live")
371
- self.assertEqual(stream["wallet"], "EUR")
372
- p = stream["presentation"]
373
- self.assertEqual(p["pattern_counts"], {"Other pattern": 80})
374
- self.assertEqual(p["inferred_top_up_currencies"], [])
375
- self.assertIsNone(p["by_charge_currency"][0]["inferred_top_up_currency"])
376
- cells = self.markdown_row(markdown, stream["stream"], "UNKNOWN")
377
- self.assertIn("Other pattern: 80 [EUR: 80]", cells[3])
378
- self.assertIn("unidentified repeated-amount pattern", cells[3])
379
- self.assertEqual(cells[4:7], ["n/a", "EUR: 80", "fixed amount"])
380
-
381
- def test_off_menu_and_small_groups_render_other_with_real_charge_currencies(self):
382
- for count, extra, raw in ((20, ("--max-substreams", "0"), "off-menu"), (2, (), "small groups")):
383
- with self.subTest(raw=raw):
384
- rows = self.rows([[('EUR', 24, count), ('USD', 30, count)]] * 4)
385
- descriptor, markdown = self.run_cli(rows, extra=extra)
386
- stream = descriptor["streams"][0]
387
- self.assertEqual(stream["stream"], raw)
388
- self.assertEqual(stream["presentation"]["pattern_counts"], {"Other pattern": len(rows)})
389
- self.assertEqual(stream["presentation"]["inferred_top_up_currencies"], [])
390
- self.assertEqual(stream["presentation"]["charge_currencies"], {"EUR": count * 4, "USD": count * 4})
391
- cells = self.markdown_row(markdown, raw)
392
- self.assertIn(f"Other pattern: {len(rows)}", cells[1])
393
- self.assertEqual(cells[2], "n/a")
394
- self.assertEqual(cells[3], f"EUR: {count * 4}, USD: {count * 4}")
395
- self.assertNotIn("top-up pattern", cells[1])
396
-
397
- def test_retail_renders_ecommerce_without_tax_fee_or_wallet_claim(self):
398
- rows = [r for r in synthetic_rows() if r["country"] == "Canada"]
399
- descriptor, markdown = self.run_cli(rows)
400
- stream = descriptor["groups"][0]["substreams"][0]
401
- self.assertTrue(stream["stream"].startswith("retail x"))
402
- self.assertAlmostEqual(stream["retail"]["constant"], 1.06, places=3)
403
- p = stream["presentation"]
404
- self.assertEqual(p["pattern_counts"], {"E-commerce pattern": stream["attempts"]})
405
- self.assertEqual(p["inferred_top_up_currencies"], [])
406
- self.assertEqual(p["charge_currencies"], {"USD": stream["attempts"]})
407
- cells = self.markdown_row(markdown, stream["stream"], "Canada")
408
- self.assertIn(f"E-commerce pattern: {stream['attempts']} [USD: {stream['attempts']}]", cells[3])
409
- self.assertEqual(cells[4], "n/a")
410
- self.assertNotIn("tax", markdown.lower())
411
- self.assertNotIn("fee", markdown.lower())
412
-
413
-
414
- class DetectStreamsTest(unittest.TestCase):
415
- @classmethod
416
- def setUpClass(cls):
417
- cls.tmp = tempfile.TemporaryDirectory()
418
- root = Path(cls.tmp.name)
419
- cls.csv = root / "auth.csv"
420
- with cls.csv.open("w", newline="") as fh:
421
- w = csv.DictWriter(fh, fieldnames=FIELDS)
422
- w.writeheader()
423
- w.writerows(synthetic_rows())
424
- cls.rates = root / "rates.json"
425
- cls.rates.write_text(json.dumps(rates_cache()))
426
- cls.out = root / "streams.json"
427
- cmd = [sys.executable, str(SCRIPT), str(cls.csv), "--rates", str(cls.rates), "--amount-column", "amount", "--currency-column", "currency",
428
- "--date-column", "ts", "--descriptor-column", "descriptor", "--bin-country-column", "country", "--response-column", "response",
429
- "--account-column", "account", "--card-type-column", "card_type", "--avs-column", "avs", "--auth-column", "auth",
430
- "--output", str(cls.out), "--markdown", str(root / "streams.md")]
431
- result = subprocess.run(cmd, capture_output=True, text=True)
432
- assert result.returncode == 0, result.stderr
433
- cls.report = json.loads(cls.out.read_text())
434
- cls.groups = {g["country"]: g for g in cls.report["descriptors"][0]["groups"]}
435
-
436
- @classmethod
437
- def main_sub(cls, country):
438
- return max(cls.groups[country]["substreams"], key=lambda s: s["attempts"])
439
-
440
- @classmethod
441
- def tearDownClass(cls):
442
- cls.tmp.cleanup()
443
-
444
- def test_euro_wallet_inferred_for_germany_with_10_scheme_live(self):
445
- g = self.main_sub("Germany")
446
- self.assertEqual(g["wallet"], "EUR")
447
- self.assertEqual(g["scheme"], "10")
448
- self.assertFalse(g["frozen"])
449
- peaks = [c for c, _ in g["peaks"][:3]]
450
- self.assertTrue(all(any(abs(c - m) <= 0.012 * m for m in (10, 20, 50)) for c in peaks), peaks)
451
-
452
- def test_aud_wallet_inferred_for_australia(self):
453
- g = self.main_sub("Australia")
454
- self.assertEqual(g["wallet"], "AUD")
455
- self.assertTrue(any(abs(c - 30) <= 0.4 for c, _ in g["peaks"]))
456
-
457
- def test_us_cards_on_euro_menu_are_flagged_frozen(self):
458
- g = self.main_sub("United States of America")
459
- self.assertEqual(g["wallet"], "EUR")
460
- self.assertEqual(g["scheme"], "12")
461
- self.assertTrue(g["frozen"], g["rate_consistency"])
462
- self.assertIn("frozen", g["stream"])
463
- self.assertLess(g["behaviour"]["repeat_card_share"], 0.05)
464
-
465
- def test_retail_constant_detected_for_canada(self):
466
- g = self.main_sub("Canada")
467
- self.assertTrue(g["stream"].startswith("retail"), g["stream"])
468
- self.assertAlmostEqual(g["retail"]["constant"], 1.06, places=3)
469
-
470
- def test_japan_is_off_menu(self):
471
- self.assertEqual(self.main_sub("Japan")["stream"], "off-menu")
472
-
473
- def test_streams_summarised_per_descriptor(self):
474
- labels = {s["stream"] for s in self.report["descriptors"][0]["streams"]}
475
- self.assertTrue(any(l.startswith("EUR menu-10 live") for l in labels), labels)
476
- self.assertTrue(any(l.startswith("EUR menu-12 frozen") for l in labels), labels)
477
- self.assertTrue(any(l.startswith("retail") for l in labels), labels)
478
-
479
- def test_same_currency_charges_produce_live_stream_through_cli(self):
480
- rows = [
481
- {"amount": "24.00", "currency": "EUR", "ts": f"{day} 12:00:00",
482
- "descriptor": "EUR-only", "country": "Germany"}
483
- for day in DAYS[:4]
484
- for _ in range(20)
485
- ]
486
- with tempfile.TemporaryDirectory() as tmp:
487
- root = Path(tmp)
488
- csv_path = root / "auth.csv"
489
- with csv_path.open("w", newline="") as fh:
490
- writer = csv.DictWriter(fh, fieldnames=FIELDS)
491
- writer.writeheader()
492
- writer.writerows(rows)
493
- rates_path = root / "rates.json"
494
- rates_path.write_text(json.dumps(rates_cache()))
495
- cmd = [sys.executable, str(SCRIPT), str(csv_path), "--rates", str(rates_path),
496
- "--amount-column", "amount", "--currency-column", "currency", "--date-column", "ts",
497
- "--descriptor-column", "descriptor", "--bin-country-column", "country",
498
- "--candidate-currencies", "EUR"]
499
- result = subprocess.run(cmd, capture_output=True, text=True)
500
- self.assertEqual(result.returncode, 0, result.stderr)
501
- report = json.loads(result.stdout)
502
- descriptor = report["descriptors"][0]
503
- self.assertEqual(descriptor["streams"][0]["stream"], "EUR menu-12 live")
504
- stream = descriptor["groups"][0]["substreams"][0]
505
- self.assertEqual(stream["attempts"], len(rows))
506
- self.assertFalse(stream["frozen"])
507
- self.assertEqual(stream["rate_consistency"]["tested_attempts"], len(rows))
508
- self.assertEqual(stream["rate_consistency"]["frozen_attempts"], 0)
509
- self.assertEqual(stream["rate_consistency"]["detail"][0]["market_move"], 0.0)
510
-
511
- def test_peaks_disappear_under_jitter_control(self):
512
- vals = [10.0] * 50 + [20.0] * 30
513
- cov, _ = MODULE.coverage(vals, 0.012, 0.02, 10)
514
- self.assertGreater(cov, 0.99)
515
- ctrl = MODULE.coverage_against(MODULE.jitter(vals, random.Random(1), 0.02, 0.05), [10.0, 20.0], 0.012)
516
- self.assertLess(ctrl, 0.05)
517
-
518
-
519
- if __name__ == "__main__":
520
- unittest.main()