stackvox 0.10.0__tar.gz → 0.11.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.
Files changed (31) hide show
  1. {stackvox-0.10.0/stackvox.egg-info → stackvox-0.11.0}/PKG-INFO +6 -2
  2. {stackvox-0.10.0 → stackvox-0.11.0}/README.md +5 -1
  3. {stackvox-0.10.0 → stackvox-0.11.0}/pyproject.toml +1 -1
  4. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox/cli.py +20 -1
  5. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox/text.py +89 -4
  6. {stackvox-0.10.0 → stackvox-0.11.0/stackvox.egg-info}/PKG-INFO +6 -2
  7. {stackvox-0.10.0 → stackvox-0.11.0}/tests/test_cli.py +22 -0
  8. {stackvox-0.10.0 → stackvox-0.11.0}/tests/test_text.py +88 -0
  9. {stackvox-0.10.0 → stackvox-0.11.0}/LICENSE +0 -0
  10. {stackvox-0.10.0 → stackvox-0.11.0}/NOTICE +0 -0
  11. {stackvox-0.10.0 → stackvox-0.11.0}/setup.cfg +0 -0
  12. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox/__init__.py +0 -0
  13. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox/__main__.py +0 -0
  14. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox/config.py +0 -0
  15. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox/daemon.py +0 -0
  16. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox/data/stackvox-say +0 -0
  17. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox/engine.py +0 -0
  18. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox/paths.py +0 -0
  19. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox/py.typed +0 -0
  20. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox/updates.py +0 -0
  21. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox.egg-info/SOURCES.txt +0 -0
  22. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox.egg-info/dependency_links.txt +0 -0
  23. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox.egg-info/entry_points.txt +0 -0
  24. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox.egg-info/requires.txt +0 -0
  25. {stackvox-0.10.0 → stackvox-0.11.0}/stackvox.egg-info/top_level.txt +0 -0
  26. {stackvox-0.10.0 → stackvox-0.11.0}/tests/test_config.py +0 -0
  27. {stackvox-0.10.0 → stackvox-0.11.0}/tests/test_daemon.py +0 -0
  28. {stackvox-0.10.0 → stackvox-0.11.0}/tests/test_daemon_protocol.py +0 -0
  29. {stackvox-0.10.0 → stackvox-0.11.0}/tests/test_engine.py +0 -0
  30. {stackvox-0.10.0 → stackvox-0.11.0}/tests/test_paths.py +0 -0
  31. {stackvox-0.10.0 → stackvox-0.11.0}/tests/test_updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: Offline TTS library using Kokoro-82M
5
5
  Author-email: "StackOne Technologies Ltd." <support@stackone.com>
6
6
  License-Expression: Apache-2.0
@@ -103,7 +103,11 @@ stackvox speak --normalize --file post.md # normalize, then synthesize
103
103
  stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
104
104
  ```
105
105
 
106
- `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
106
+ `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
107
+
108
+ **Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `engine.py:42` → "line 42 of engine py", `cli.py:100-118` → "lines 100 to 118 of cli py", `foo.ts:666:10` → "line 666, column 10 of foo ts". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone.
109
+
110
+ Fenced code blocks are dropped by default. `--code-blocks placeholder` instead speaks a short stand-in so a skipped block doesn't sound disjointed (ChatGPT-style) — set the wording with `--code-placeholder` (e.g. `--code-blocks placeholder --code-placeholder "you can see the code in the chat history"`); consecutive blocks collapse to one.
107
111
 
108
112
  Bash completion:
109
113
 
@@ -69,7 +69,11 @@ stackvox speak --normalize --file post.md # normalize, then synthesize
69
69
  stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
70
70
  ```
71
71
 
72
- `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
72
+ `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
73
+
74
+ **Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `engine.py:42` → "line 42 of engine py", `cli.py:100-118` → "lines 100 to 118 of cli py", `foo.ts:666:10` → "line 666, column 10 of foo ts". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone.
75
+
76
+ Fenced code blocks are dropped by default. `--code-blocks placeholder` instead speaks a short stand-in so a skipped block doesn't sound disjointed (ChatGPT-style) — set the wording with `--code-placeholder` (e.g. `--code-blocks placeholder --code-placeholder "you can see the code in the chat history"`); consecutive blocks collapse to one.
73
77
 
74
78
  Bash completion:
75
79
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackvox"
7
- version = "0.10.0"
7
+ version = "0.11.0"
8
8
  description = "Offline TTS library using Kokoro-82M"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -77,13 +77,17 @@ _stackvox_completion() {
77
77
  COMPREPLY=( $(compgen -W "drop csv" -- "$cur") )
78
78
  return 0
79
79
  ;;
80
+ --code-blocks)
81
+ COMPREPLY=( $(compgen -W "drop placeholder" -- "$cur") )
82
+ return 0
83
+ ;;
80
84
  --locale)
81
85
  COMPREPLY=( $(compgen -W "en-GB" -- "$cur") )
82
86
  return 0
83
87
  ;;
84
88
  esac
85
89
 
86
- local norm_flags="--no-markdown --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --strip-emoji --no-terminal-stops --locale"
90
+ local norm_flags="--no-markdown --no-dev-terms --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --code-blocks --code-placeholder --strip-emoji --no-terminal-stops --locale"
87
91
 
88
92
  case "$subcommand" in
89
93
  speak)
@@ -240,6 +244,19 @@ def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) -
240
244
  default="drop",
241
245
  help="How Markdown tables are voiced (default: drop)",
242
246
  )
247
+ parser.add_argument(
248
+ "--code-blocks",
249
+ dest="code_blocks",
250
+ choices=["drop", "placeholder"],
251
+ default="drop",
252
+ help="How fenced code blocks are voiced (default: drop)",
253
+ )
254
+ parser.add_argument(
255
+ "--code-placeholder",
256
+ dest="code_placeholder",
257
+ default="Code block.",
258
+ help='Spoken stand-in for a code block when --code-blocks placeholder (default: "Code block.")',
259
+ )
243
260
  parser.add_argument(
244
261
  "--strip-emoji", dest="strip_emoji", action="store_true", help="Remove emoji before speaking"
245
262
  )
@@ -281,6 +298,8 @@ def _normalize_kwargs(args: argparse.Namespace) -> dict:
281
298
  "expand_numbers": args.expand_numbers,
282
299
  "pauses": args.pauses,
283
300
  "tables": args.tables,
301
+ "code_blocks": args.code_blocks,
302
+ "code_placeholder": args.code_placeholder,
284
303
  "strip_emoji": args.strip_emoji,
285
304
  "terminal_stops": args.terminal_stops,
286
305
  "locale": args.locale,
@@ -20,6 +20,7 @@ __all__ = [
20
20
  "strip_thousands_separators",
21
21
  "versions_to_words",
22
22
  "decimals_to_words",
23
+ "speak_file_refs",
23
24
  "expand_units",
24
25
  "apply_pronunciations",
25
26
  "shape_pauses",
@@ -64,6 +65,7 @@ _UNIT_RULES: dict[str, list[tuple[str, str]]] = {
64
65
  (r"\s*÷\s*", " divided by "),
65
66
  (r"\s*×\s*", " times "),
66
67
  (r"\s*=\s*", " equals "),
68
+ (r"~\s*(?=\d)", "about "), # ~123 -> "about 123" (else espeak says "tilde 123"); leaves ~/path alone
67
69
  ],
68
70
  }
69
71
  DEFAULT_LOCALE = "en-GB"
@@ -110,6 +112,46 @@ def decimals_to_words(text: str) -> str:
110
112
  )
111
113
 
112
114
 
115
+ # --------------------------------------------------------------------------- #
116
+ # File & line references #
117
+ # --------------------------------------------------------------------------- #
118
+ # `engine.py:42` reads as "…dot py colon forty two" — the dots and colon get
119
+ # voiced literally. Spoken aloud the line number is the signal and the filename
120
+ # is noise, so lead with "line N of <file>" (how a person actually says it) and
121
+ # soften the path: drop directories to the basename, and voice the dotted name
122
+ # word by word.
123
+
124
+ _FILE_REF = re.compile(
125
+ r"(?<!\w)"
126
+ r"(?:[\w.-]*/)*" # optional directory segments (dropped — "src/" reads as "slash")
127
+ r"([A-Za-z0-9_-]+(?:\.[A-Za-z][\w-]*)+)" # filename with >=1 letter-initial extension
128
+ r":(\d+)(?:-(\d+))?(?::(\d+))?" # :line, optional -end (range) or :column
129
+ r"(?!\w)"
130
+ )
131
+
132
+
133
+ def speak_file_refs(text: str) -> str:
134
+ """Turn ``path/file.ext:line`` refs into spoken "line N of file ext".
135
+
136
+ ``engine.py:42`` -> "line 42 of engine py"; ``cli.py:100-118`` -> "lines 100
137
+ to 118 of cli py"; ``foo.ts:666:10`` -> "line 666, column 10 of foo ts". The
138
+ ``:line`` suffix is the trigger, so bare times/ratios/verses (``12:30``,
139
+ ``3:1``, ``John 3:16``) and dotted versions (``1.2.3``) are left untouched —
140
+ none of them carry a dotted-filename before the colon.
141
+ """
142
+
143
+ def repl(match: re.Match[str]) -> str:
144
+ basename = match.group(1).replace(".", " ")
145
+ start, end, column = match.group(2), match.group(3), match.group(4)
146
+ if end:
147
+ location = f"lines {start} to {end}"
148
+ else:
149
+ location = f"line {start}" + (f", column {column}" if column else "")
150
+ return f"{location} of {basename}"
151
+
152
+ return _FILE_REF.sub(repl, text)
153
+
154
+
113
155
  # --------------------------------------------------------------------------- #
114
156
  # Pronunciations #
115
157
  # --------------------------------------------------------------------------- #
@@ -142,6 +184,13 @@ _DEV_PRONUNCIATIONS: dict[str, str] = {
142
184
  "postgresql": "postgres",
143
185
  "kubectl": "kube control",
144
186
  "stackone": "stack one", # org name espeak garbles as "stac kone"
187
+ # "dedupe" glued reads as "de-dup" (short u); split + long "dee" gives "dee doop".
188
+ # Inflections listed explicitly — apply_pronunciations matches whole words only.
189
+ "dedupe": "dee dupe",
190
+ "deduped": "dee duped",
191
+ "deduping": "dee duping",
192
+ "dedupes": "dee dupes",
193
+ "dedup": "dee dupe",
145
194
  }
146
195
 
147
196
 
@@ -189,11 +238,24 @@ def _strip_md_inline(line: str) -> str:
189
238
  return line
190
239
 
191
240
 
192
- def markdown_to_paragraphs(text: str, *, tables: str = "drop", strip_emoji_flag: bool = False) -> list[str]:
241
+ def markdown_to_paragraphs(
242
+ text: str,
243
+ *,
244
+ tables: str = "drop",
245
+ strip_emoji_flag: bool = False,
246
+ code_blocks: str = "drop",
247
+ code_placeholder: str = "",
248
+ ) -> list[str]:
193
249
  """Reduce Markdown to a list of speakable paragraphs. Headings and list
194
250
  items become their own paragraphs (so each gets its own pause). Tables are
195
- dropped or rendered comma-separated per ``tables``."""
196
- text = re.sub(r"(?ms)^[ \t]*(```|~~~).*?^[ \t]*\1[ \t]*$", "\n", text) # fenced code
251
+ dropped or rendered comma-separated per ``tables``. Fenced code blocks are
252
+ dropped, or (``code_blocks="placeholder"``) replaced with a spoken
253
+ ``code_placeholder`` so a silently-skipped block doesn't sound disjointed."""
254
+ speak_code = code_blocks == "placeholder" and bool(code_placeholder)
255
+ # A function replacement keeps the placeholder literal (no backref parsing);
256
+ # blank lines around it make it a standalone paragraph.
257
+ fenced = f"\n\n{code_placeholder}\n\n" if speak_code else "\n"
258
+ text = re.sub(r"(?ms)^[ \t]*(```|~~~).*?^[ \t]*\1[ \t]*$", lambda _: fenced, text) # fenced code
197
259
  text = re.sub(r"```+|~~~+", " ", text) # stray fences
198
260
  if strip_emoji_flag:
199
261
  text = strip_emoji(text)
@@ -278,6 +340,14 @@ def markdown_to_paragraphs(text: str, *, tables: str = "drop", strip_emoji_flag:
278
340
  flush()
279
341
 
280
342
  flush()
343
+
344
+ if speak_code: # collapse runs of adjacent code blocks into one placeholder
345
+ collapsed: list[str] = []
346
+ for para in paragraphs:
347
+ if para == code_placeholder and collapsed and collapsed[-1] == code_placeholder:
348
+ continue
349
+ collapsed.append(para)
350
+ return collapsed
281
351
  return paragraphs
282
352
 
283
353
 
@@ -290,11 +360,17 @@ def _shape_paragraph(
290
360
  text: str,
291
361
  *,
292
362
  pronunciations: dict[str, str] | None,
363
+ dev_terms_flag: bool,
293
364
  expand_units_flag: bool,
294
365
  expand_numbers_flag: bool,
295
366
  pauses_flag: bool,
296
367
  locale: str,
297
368
  ) -> str:
369
+ # File refs first: it consumes the ":line" digits (so the number stages see a
370
+ # plain "line 42", not a decimal) and forms the spoken basename before the
371
+ # dev-term dict runs over it.
372
+ if dev_terms_flag:
373
+ text = speak_file_refs(text)
298
374
  if expand_numbers_flag:
299
375
  text = strip_thousands_separators(text)
300
376
  if pauses_flag:
@@ -319,6 +395,8 @@ def normalize_for_speech(
319
395
  expand_numbers: bool = True,
320
396
  pauses: bool = True,
321
397
  tables: str = "drop",
398
+ code_blocks: str = "drop",
399
+ code_placeholder: str = "Code block.",
322
400
  strip_emoji: bool = False,
323
401
  terminal_stops: bool = True,
324
402
  locale: str = DEFAULT_LOCALE,
@@ -333,7 +411,13 @@ def normalize_for_speech(
333
411
  effective_pronunciations[written.lower()] = spoken
334
412
 
335
413
  if markdown:
336
- paragraphs = markdown_to_paragraphs(text, tables=tables, strip_emoji_flag=strip_emoji)
414
+ paragraphs = markdown_to_paragraphs(
415
+ text,
416
+ tables=tables,
417
+ strip_emoji_flag=strip_emoji,
418
+ code_blocks=code_blocks,
419
+ code_placeholder=code_placeholder,
420
+ )
337
421
  else:
338
422
  # `strip_emoji` (the bool kwarg) shadows the module function here, so
339
423
  # reach for the underlying pattern directly.
@@ -345,6 +429,7 @@ def normalize_for_speech(
345
429
  shaped = _shape_paragraph(
346
430
  para,
347
431
  pronunciations=effective_pronunciations,
432
+ dev_terms_flag=dev_terms,
348
433
  expand_units_flag=expand_units_flag,
349
434
  expand_numbers_flag=expand_numbers_flag,
350
435
  pauses_flag=pauses,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: Offline TTS library using Kokoro-82M
5
5
  Author-email: "StackOne Technologies Ltd." <support@stackone.com>
6
6
  License-Expression: Apache-2.0
@@ -103,7 +103,11 @@ stackvox speak --normalize --file post.md # normalize, then synthesize
103
103
  stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
104
104
  ```
105
105
 
106
- `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
106
+ `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
107
+
108
+ **Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `engine.py:42` → "line 42 of engine py", `cli.py:100-118` → "lines 100 to 118 of cli py", `foo.ts:666:10` → "line 666, column 10 of foo ts". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone.
109
+
110
+ Fenced code blocks are dropped by default. `--code-blocks placeholder` instead speaks a short stand-in so a skipped block doesn't sound disjointed (ChatGPT-style) — set the wording with `--code-placeholder` (e.g. `--code-blocks placeholder --code-placeholder "you can see the code in the chat history"`); consecutive blocks collapse to one.
107
111
 
108
112
  Bash completion:
109
113
 
@@ -133,6 +133,8 @@ def _norm_ns(text=None, file=None, **overrides):
133
133
  expand_numbers=True,
134
134
  pauses=True,
135
135
  tables="drop",
136
+ code_blocks="drop",
137
+ code_placeholder="Code block.",
136
138
  strip_emoji=False,
137
139
  terminal_stops=True,
138
140
  locale="en-GB",
@@ -524,6 +526,26 @@ class TestNormalizeParsing:
524
526
  assert cli.main() == 0
525
527
  assert paths_handler.called
526
528
 
529
+ def test_code_block_flags_parse(self, mocker):
530
+ norm = mocker.patch.object(cli, "_cmd_normalize", return_value=0)
531
+ mocker.patch.object(
532
+ cli.sys,
533
+ "argv",
534
+ [
535
+ "stackvox",
536
+ "normalize",
537
+ "--code-blocks",
538
+ "placeholder",
539
+ "--code-placeholder",
540
+ "see the chat",
541
+ "hi",
542
+ ],
543
+ )
544
+ assert cli.main() == 0
545
+ args = norm.call_args.args[0]
546
+ assert args.code_blocks == "placeholder"
547
+ assert args.code_placeholder == "see the chat"
548
+
527
549
 
528
550
  class TestCmdCancel:
529
551
  def test_running_calls_daemon_cancel(self, mocker):
@@ -8,6 +8,7 @@ from stackvox.text import (
8
8
  markdown_to_paragraphs,
9
9
  normalize_for_speech,
10
10
  shape_pauses,
11
+ speak_file_refs,
11
12
  strip_emoji,
12
13
  strip_thousands_separators,
13
14
  versions_to_words,
@@ -54,6 +55,44 @@ def test_semver_normalizes_end_to_end():
54
55
  assert "0 point 7.0" not in out
55
56
 
56
57
 
58
+ # --- file & line references ------------------------------------------------
59
+
60
+
61
+ def test_file_ref_leads_with_line_then_file():
62
+ assert speak_file_refs("unifiedAPIv2.service.ts:666") == "line 666 of unifiedAPIv2 service ts"
63
+
64
+
65
+ def test_file_ref_drops_directories_to_basename():
66
+ assert speak_file_refs("Open /abs/path/to/module.py:7.") == "Open line 7 of module py."
67
+
68
+
69
+ def test_file_ref_line_range():
70
+ assert speak_file_refs("cli.py:100-118") == "lines 100 to 118 of cli py"
71
+
72
+
73
+ def test_file_ref_line_and_column():
74
+ assert speak_file_refs("foo.ts:666:10") == "line 666, column 10 of foo ts"
75
+
76
+
77
+ def test_file_ref_leaves_times_ratios_verses_untouched():
78
+ text = "Meet at 12:30, the ratio was 3:1, see John 3:16."
79
+ assert speak_file_refs(text) == text
80
+
81
+
82
+ def test_file_ref_leaves_dotted_versions_untouched():
83
+ assert speak_file_refs("bump to 1.2.3 and 0.7.0") == "bump to 1.2.3 and 0.7.0"
84
+
85
+
86
+ def test_file_ref_normalizes_end_to_end():
87
+ out = normalize_for_speech("See `engine.py:42` for the fix.", markdown=True)
88
+ assert out == "See line 42 of engine py for the fix."
89
+
90
+
91
+ def test_file_ref_disabled_with_dev_terms():
92
+ out = normalize_for_speech("See engine.py:42.", markdown=False, dev_terms=False)
93
+ assert out == "See engine.py:42."
94
+
95
+
57
96
  # --- units -----------------------------------------------------------------
58
97
 
59
98
 
@@ -74,6 +113,20 @@ def test_math_symbols():
74
113
  assert expand_units("770 ÷ 2 × 3 = 5").strip() == "770 divided by 2 times 3 equals 5"
75
114
 
76
115
 
116
+ def test_tilde_before_number_is_approximately():
117
+ assert expand_units("~123") == "about 123"
118
+ assert expand_units("~ 50 items") == "about 50 items"
119
+
120
+
121
+ def test_tilde_not_before_number_is_left_alone():
122
+ # home dir / approx-equal — not an approximated quantity
123
+ assert expand_units("~/projects") == "~/projects"
124
+
125
+
126
+ def test_tilde_approx_end_to_end_with_decimal():
127
+ assert normalize_for_speech("took ~1.5 hours", markdown=False) == "took about 1 point 5 hours."
128
+
129
+
77
130
  def test_unknown_locale_falls_back_to_en_gb():
78
131
  assert expand_units("£5", locale="xx-YY") == "5 pounds"
79
132
 
@@ -136,6 +189,29 @@ def test_fenced_code_dropped():
136
189
  assert markdown_to_paragraphs(md) == ["Before.", "After."]
137
190
 
138
191
 
192
+ def test_fenced_code_replaced_with_placeholder():
193
+ md = "Before.\n\n```py\nprint('hi')\n```\n\nAfter."
194
+ actual = markdown_to_paragraphs(md, code_blocks="placeholder", code_placeholder="see the code")
195
+ assert actual == ["Before.", "see the code", "After."]
196
+
197
+
198
+ def test_consecutive_code_blocks_collapse_to_one_placeholder():
199
+ md = "```py\na\n```\n\n```py\nb\n```"
200
+ actual = markdown_to_paragraphs(md, code_blocks="placeholder", code_placeholder="see the code")
201
+ assert actual == ["see the code"]
202
+
203
+
204
+ def test_code_placeholder_spoken_end_to_end():
205
+ md = "Here is how:\n\n```py\nx = 1\n```\n\nDone."
206
+ out = normalize_for_speech(md, code_blocks="placeholder", code_placeholder="see the chat history")
207
+ assert out == "Here is how:\nsee the chat history.\nDone."
208
+
209
+
210
+ def test_code_blocks_dropped_by_default_end_to_end():
211
+ md = "Here is how:\n\n```py\nx = 1\n```\n\nDone."
212
+ assert normalize_for_speech(md) == "Here is how:\nDone."
213
+
214
+
139
215
  def test_tables_dropped_by_default():
140
216
  md = "| A | B |\n| --- | --- |\n| 1 | 2 |"
141
217
  assert markdown_to_paragraphs(md) == []
@@ -244,6 +320,18 @@ def test_dev_terms_leave_correctly_voiced_acronyms_alone():
244
320
  assert "API" in out
245
321
 
246
322
 
323
+ def test_dedupe_respelled_for_speech():
324
+ assert normalize_for_speech("dedupe the list", markdown=False) == "dee dupe the list."
325
+ # inflections and case
326
+ assert "dee duped" in normalize_for_speech("we Deduped it", markdown=False)
327
+ assert "dee duping" in normalize_for_speech("deduping now", markdown=False)
328
+
329
+
330
+ def test_dedupe_does_not_touch_other_words():
331
+ # whole-word only — must not rewrite the middle of a longer token
332
+ assert "dee dupe" not in normalize_for_speech("dedupelicated", markdown=False)
333
+
334
+
247
335
  def test_dev_terms_can_be_disabled():
248
336
  assert normalize_for_speech("Use the CLI.", markdown=False, dev_terms=False) == "Use the CLI."
249
337
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes