Option C oc_diagnostics
Loading...
Searching...
No Matches
test_unified_debug.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/test_unified_debug.py
4
5"""
6Regression Tests — unified_debug
7=================================
8
9Purpose
10-------
11Verify the full public and internal API surface of ``oc_diagnostics.unified_debug``,
12covering configuration loading, the ``_dbg`` debug logger, async logging, runtime
13overrides, profiling helpers, production mode, log rotation, and I/O fallback.
14
15Responsibilities
16----------------
17- Test config loading returns the expected 4-tuple structure.
18- Test ``_dbg()`` domain-level filtering and ``override=True`` bypass.
19- Test ``_adbg()`` completes without raising.
20- Test ``init()`` idempotency and the pre-init RuntimeWarning.
21- Test runtime API: ``set_debug_level()``, ``set_global_setting()``.
22- Test profiling helpers: ``dbg_timed()``, ``dbg_scope()``, ``dbg_profile()``.
23- Test JSONL output mode and ``_StreamInterceptor`` interface.
24- Test production mode activation via env var and via config file.
25- Test log-file count-based rotation via ``_prune_old_log_files()``.
26- Test config hot-reload stale-key pruning via ``_reload_config()``.
27- Test I/O degradation fallback on ``OSError`` (FS-25).
28
29Diagnostics
30-----------
31Domain: TEST
32Levels:
33 L2 — lifecycle
34 L3 — semantic details
35 L4 — deep tracing
36
37Contracts
38---------
39- Must not permanently modify oc_diagnostics global state.
40- Must restore all patched globals and mocked internals in finally blocks.
41- Must not start servers, threads, or require network access.
42- Must run deterministically across repeated invocations.
43"""
44
45import asyncio
46import io
47import json
48import os
49import re
50import subprocess
51import sys
52import tempfile
53import time
54import unittest.mock as mock
55import warnings
56
57# ---------------------------------------------------------------------------
58# Ensure oc_diagnostics is importable when run from the project root or the
59# tests/ subdirectory.
60# ---------------------------------------------------------------------------
61_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
62if _REPO_ROOT not in sys.path:
63 sys.path.insert(0, _REPO_ROOT)
64
67 _load_debug_config,
68 _StreamInterceptor,
69 _prune_old_log_files,
70)
71
72
73# ============================================================
74# Shared helper — mirrors run_tests.py for _reload_config tests
75# ============================================================
76
78 """Return a minimal settings dict compatible with _reload_config()."""
79 return {
80 "instrumentation_enabled": _ud.INSTRUMENTATION_ENABLED,
81 "silent_mode": _ud.SILENT_MODE,
82 "structural_id_prefixes": list(_ud.ADDITIONAL_STRUCTURAL_PREFIXES),
83 "structural_id_exact": list(_ud.ADDITIONAL_STRUCTURAL_EXACT),
84 "enable_timestamps": _ud.ENABLE_TIMESTAMPS,
85 "enable_colors": _ud.ENABLE_COLORS,
86 "include_filename": _ud.INCLUDE_FILENAME,
87 "output_mode": _ud.OUTPUT_MODE,
88 "max_log_files": _ud.MAX_LOG_FILES,
89 "max_log_size_mb": (
90 float(_ud.MAX_LOG_SIZE_BYTES) / (1024 * 1024)
91 if _ud.MAX_LOG_SIZE_BYTES else 0.0
92 ),
93 "instrumentation_allow_all": _ud.INSTRUMENTATION_ALL,
94 "ui_event_properties": list(_ud.UI_EVENT_PROPERTIES),
95 "http_redact_headers": list(_ud.HTTP_REDACT_HEADERS),
96 "mode": "",
97 }
98
99
100# ============================================================
101# CONFIG LOADING
102# ============================================================
103
105 """_load_debug_config() returns a 4-tuple with expected types."""
106 result = _load_debug_config()
107 assert isinstance(result, tuple) and len(result) == 4, (
108 f"Expected 4-tuple, got {type(result)} len={len(result) if isinstance(result, tuple) else 'N/A'}"
109 )
110 levels, colors, settings, source = result
111 assert isinstance(levels, dict), f"levels must be dict, got {type(levels)}"
112 assert isinstance(colors, dict), f"colors must be dict, got {type(colors)}"
113 assert isinstance(settings, dict), f"settings must be dict, got {type(settings)}"
114 assert isinstance(source, str) and source, "config_source must be a non-empty string"
115
116
118 """CONFIG_SOURCE is a non-empty string after module load."""
119 assert isinstance(_ud.CONFIG_SOURCE, str) and _ud.CONFIG_SOURCE, (
120 f"Expected non-empty string, got: {_ud.CONFIG_SOURCE!r}"
121 )
122
123
124# ============================================================
125# _dbg CORE BEHAVIOUR
126# ============================================================
127
129 """_dbg() with SILENT_MODE=True must not raise."""
130 old = _ud.SILENT_MODE
131 _ud.SILENT_MODE = True
132 try:
133 _ud._dbg("TEST", "test_dbg_silent", "should not crash", level=2)
134 finally:
135 _ud.SILENT_MODE = old
136
137
139 """_dbg() message is suppressed when the call level exceeds the domain level."""
140 captured = []
141 old_emit = _ud._emit
142 old_silent = _ud.SILENT_MODE
143 old_level = _ud.DEBUG_LEVELS.get("_FILTER_TEST_")
144 old_init = _ud._initialized
145 old_warned = _ud._init_warned
146
147 def _capture(line, level, record=None):
148 captured.append(line)
149
150 _ud._emit = _capture
151 _ud.SILENT_MODE = False
152 _ud.DEBUG_LEVELS["_FILTER_TEST_"] = 3 # L1–L3 allowed
153 _ud._initialized = True # suppress pre-init warning
154 try:
155 # Level 5 > domain level 3 → filtered
156 _ud._dbg("_FILTER_TEST_", "test_fn", "should-not-appear", level=5)
157 assert not captured, (
158 f"Level-5 message should be filtered at domain level 3; got: {captured!r}"
159 )
160
161 # Level 2 ≤ domain level 3 → emitted
162 _ud._dbg("_FILTER_TEST_", "test_fn", "should-appear", level=2)
163 assert any("should-appear" in l for l in captured), (
164 f"Level-2 message should pass at domain level 3; got: {captured!r}"
165 )
166 finally:
167 _ud._emit = old_emit
168 _ud.SILENT_MODE = old_silent
169 _ud._initialized = old_init
170 _ud._init_warned = old_warned
171 if old_level is None:
172 _ud.DEBUG_LEVELS.pop("_FILTER_TEST_", None)
173 else:
174 _ud.DEBUG_LEVELS["_FILTER_TEST_"] = old_level
175
176
178 """_dbg(..., override=True) always emits regardless of domain level."""
179 captured = []
180 old_emit = _ud._emit
181 old_silent = _ud.SILENT_MODE
182 old_level = _ud.DEBUG_LEVELS.get("_OVERRIDE_TEST_")
183 old_init = _ud._initialized
184
185 def _capture(line, level, record=None):
186 captured.append(line)
187
188 _ud._emit = _capture
189 _ud.SILENT_MODE = False
190 _ud.DEBUG_LEVELS["_OVERRIDE_TEST_"] = 0 # nothing allowed normally
191 _ud._initialized = True
192 try:
193 _ud._dbg("_OVERRIDE_TEST_", "test_fn", "override-message", level=9, override=True)
194 assert any("override-message" in l for l in captured), (
195 f"override=True must bypass level filter; got: {captured!r}"
196 )
197 finally:
198 _ud._emit = old_emit
199 _ud.SILENT_MODE = old_silent
200 _ud._initialized = old_init
201 if old_level is None:
202 _ud.DEBUG_LEVELS.pop("_OVERRIDE_TEST_", None)
203 else:
204 _ud.DEBUG_LEVELS["_OVERRIDE_TEST_"] = old_level
205
206
208 """_dbg() before init() emits a one-shot RuntimeWarning; second call does not repeat it."""
209 old_init = _ud._initialized
210 old_warned = _ud._init_warned
211 old_silent = _ud.SILENT_MODE
212 old_emit = _ud._emit
213
214 captured_lines = []
215
216 def _capture(line, level, record=None):
217 captured_lines.append(line)
218
219 _ud._initialized = False
220 _ud._init_warned = False
221 _ud.SILENT_MODE = False
222 _ud._emit = _capture
223 try:
224 with warnings.catch_warnings(record=True) as w:
225 warnings.simplefilter("always")
226 _ud._dbg("GENERAL", "test_fn", "pre-init call", level=2, override=True)
227
228 assert len(w) == 1, (
229 f"Expected exactly 1 RuntimeWarning on first pre-init call, got {len(w)}: "
230 f"{[str(x.message) for x in w]}"
231 )
232 assert issubclass(w[0].category, RuntimeWarning), (
233 f"Expected RuntimeWarning, got {w[0].category}"
234 )
235 assert _ud._init_warned is True, (
236 "_init_warned must be True after the first warning"
237 )
238
239 # Second call: no additional warning emitted
240 with warnings.catch_warnings(record=True) as w2:
241 warnings.simplefilter("always")
242 _ud._dbg("GENERAL", "test_fn", "second call", level=2, override=True)
243
244 assert len(w2) == 0, (
245 f"Expected no second RuntimeWarning, got: {[str(x.message) for x in w2]}"
246 )
247 finally:
248 _ud._initialized = old_init
249 _ud._init_warned = old_warned
250 _ud.SILENT_MODE = old_silent
251 _ud._emit = old_emit
252
253
254# ============================================================
255# ASYNC LOGGING
256# ============================================================
257
259 """_adbg() completes without raising, in silent mode for cleanliness."""
260 old_silent = _ud.SILENT_MODE
261 old_level = _ud.DEBUG_LEVELS.get("_ADBG_TEST_")
262 _ud.SILENT_MODE = True
263 _ud.DEBUG_LEVELS["_ADBG_TEST_"] = 9
264 try:
265 asyncio.run(_ud._adbg("_ADBG_TEST_", "test_fn", "async message", level=2))
266 finally:
267 _ud.SILENT_MODE = old_silent
268 if old_level is None:
269 _ud.DEBUG_LEVELS.pop("_ADBG_TEST_", None)
270 else:
271 _ud.DEBUG_LEVELS["_ADBG_TEST_"] = old_level
272
273
274# ============================================================
275# init() IDEMPOTENCY
276# ============================================================
277
279 """init() can be called multiple times without error or side-effect duplication."""
280 old_init = _ud._initialized
281 old_stdout = sys.stdout
282 old_stderr = sys.stderr
283 old_excepthook = sys.excepthook
284 _ud._initialized = False
285 try:
286 # Both calls must succeed without raising
287 _ud.init(intercept_streams=False, intercept_exceptions=False)
288 _ud.init(intercept_streams=False, intercept_exceptions=False)
289 assert _ud._initialized is True, "init() must set _initialized=True"
290 finally:
291 _ud._initialized = old_init
292 sys.stdout = old_stdout
293 sys.stderr = old_stderr
294 sys.excepthook = old_excepthook
295
296
297# ============================================================
298# _StreamInterceptor INTERFACE
299# ============================================================
300
302 """_StreamInterceptor exposes the full io.TextIOBase-compatible interface."""
303 si = _StreamInterceptor("stdout")
304 assert si.writable() is True, "writable() must return True"
305 assert si.readable() is False, "readable() must return False"
306 assert si.seekable() is False, "seekable() must return False"
307 assert isinstance(si.encoding, str), f"encoding must be str, got {type(si.encoding)}"
308 assert isinstance(si.errors, str), f"errors must be str, got {type(si.errors)}"
309 result = si.isatty()
310 assert isinstance(result, bool), f"isatty() must return bool, got {type(result)}"
311
312
313# ============================================================
314# DEBUG_LEVELS MUTABILITY
315# ============================================================
316
318 """DEBUG_LEVELS is a mutable dict and changes are reflected globally."""
319 _save = _ud.DEBUG_LEVELS.get("_TEST_DOMAIN_")
320 _ud.DEBUG_LEVELS["_TEST_DOMAIN_"] = 7
321 assert _ud.DEBUG_LEVELS["_TEST_DOMAIN_"] == 7, "DEBUG_LEVELS mutation not reflected"
322 if _save is None:
323 del _ud.DEBUG_LEVELS["_TEST_DOMAIN_"]
324 else:
325 _ud.DEBUG_LEVELS["_TEST_DOMAIN_"] = _save
326
327
328# ============================================================
329# RUNTIME API (FS-03)
330# ============================================================
331
333 """set_debug_level() updates DEBUG_LEVELS at runtime."""
334 old = _ud.DEBUG_LEVELS.get("_FS03_TEST_")
335 _ud.set_debug_level("_FS03_TEST_", 5)
336 try:
337 assert _ud.DEBUG_LEVELS.get("_FS03_TEST_") == 5, (
338 f"Expected 5, got {_ud.DEBUG_LEVELS.get('_FS03_TEST_')!r}"
339 )
340 finally:
341 if old is None:
342 _ud.DEBUG_LEVELS.pop("_FS03_TEST_", None)
343 else:
344 _ud.DEBUG_LEVELS["_FS03_TEST_"] = old
345
346
348 """set_debug_level() with an out-of-range value emits UserWarning and does not update."""
349 old = _ud.DEBUG_LEVELS.get("_FS03_TEST2_")
350 with warnings.catch_warnings(record=True) as w:
351 warnings.simplefilter("always")
352 _ud.set_debug_level("_FS03_TEST2_", 99)
353 try:
354 assert len(w) == 1, f"Expected 1 warning, got {len(w)}"
355 assert issubclass(w[0].category, UserWarning), "Expected UserWarning"
356 assert _ud.DEBUG_LEVELS.get("_FS03_TEST2_") is old, (
357 "Invalid level must not update DEBUG_LEVELS"
358 )
359 finally:
360 if "_FS03_TEST2_" in _ud.DEBUG_LEVELS and old is None:
361 del _ud.DEBUG_LEVELS["_FS03_TEST2_"]
362
363
365 """set_global_setting() updates SILENT_MODE at runtime."""
366 old = _ud.SILENT_MODE
367 _ud.set_global_setting("silent_mode", True)
368 try:
369 assert _ud.SILENT_MODE is True, f"Expected True, got {_ud.SILENT_MODE!r}"
370 finally:
371 _ud.SILENT_MODE = old
372
373
375 """set_global_setting() with an unknown key emits UserWarning."""
376 with warnings.catch_warnings(record=True) as w:
377 warnings.simplefilter("always")
378 _ud.set_global_setting("nonexistent_key_xyz", 42)
379 assert len(w) == 1, f"Expected 1 warning, got {len(w)}"
380 assert issubclass(w[0].category, UserWarning), "Expected UserWarning"
381
382
383# ============================================================
384# JSONL OUTPUT MODE (FS-07)
385# ============================================================
386
388 """OUTPUT_MODE='json' writes valid JSONL records to the log file."""
389 import glob as _glob
390 old_mode = _ud.OUTPUT_MODE
391 old_log_dir = _ud.LOG_DIR
392 old_log_file = _ud.LOG_FILE
393 old_header = _ud._header_written
394 old_silent = _ud.SILENT_MODE
395 old_level = _ud.DEBUG_LEVELS.get("_FSJSON_")
396 old_init = _ud._initialized
397 with tempfile.TemporaryDirectory() as tmpdir:
398 _ud.OUTPUT_MODE = "json"
399 _ud.LOG_DIR = tmpdir
400 _ud.LOG_FILE = None
401 _ud._header_written = False
402 _ud.SILENT_MODE = False
403 _ud.DEBUG_LEVELS["_FSJSON_"] = 9
404 _ud._initialized = True # suppress pre-init RuntimeWarning
405 try:
406 _ud._dbg("_FSJSON_", "test_fn", "hello json", level=2, override=True)
407 finally:
408 _ud.OUTPUT_MODE = old_mode
409 _ud.LOG_DIR = old_log_dir
410 _ud.LOG_FILE = old_log_file
411 _ud._header_written = old_header
412 _ud.SILENT_MODE = old_silent
413 _ud._initialized = old_init
414 if old_level is None:
415 _ud.DEBUG_LEVELS.pop("_FSJSON_", None)
416 else:
417 _ud.DEBUG_LEVELS["_FSJSON_"] = old_level
418 files = _glob.glob(os.path.join(tmpdir, "*.txt"))
419 assert files, "Expected at least one log file to be created"
420 with open(files[0], encoding="utf-8") as f:
421 raw_lines = f.readlines()
422 json_lines = []
423 for raw in raw_lines:
424 stripped = raw.strip()
425 if stripped.startswith("{"):
426 try:
427 json_lines.append(json.loads(stripped))
428 except json.JSONDecodeError:
429 pass
430 assert json_lines, (
431 f"Expected at least one valid JSONL record, got lines: {raw_lines!r}"
432 )
433 rec = json_lines[0]
434 assert rec.get("domain") == "_FSJSON_", (
435 f"Expected domain '_FSJSON_', got {rec!r}"
436 )
437 assert rec.get("msg") == "hello json", (
438 f"Expected msg 'hello json', got {rec!r}"
439 )
440
441
442# ============================================================
443# PROFILING HELPERS (FS-12, FS-19)
444# ============================================================
445
447 """dbg_timed() context manager emits ENTER and EXIT lines with elapsed ms."""
448 captured = []
449 old_emit = _ud._emit
450 old_silent = _ud.SILENT_MODE
451 old_level = _ud.DEBUG_LEVELS.get("GENERAL")
452 old_init = _ud._initialized
453
454 def _capture(line, level, record=None):
455 captured.append(line)
456
457 _ud._emit = _capture
458 _ud.SILENT_MODE = False
459 _ud.DEBUG_LEVELS["GENERAL"] = 9
460 _ud._initialized = True # suppress pre-init RuntimeWarning
461 try:
462 with _ud.dbg_timed("GENERAL", "timing_test", label="myop", level=2):
463 pass
464 finally:
465 _ud._emit = old_emit
466 _ud.SILENT_MODE = old_silent
467 _ud._initialized = old_init
468 if old_level is None:
469 _ud.DEBUG_LEVELS.pop("GENERAL", None)
470 else:
471 _ud.DEBUG_LEVELS["GENERAL"] = old_level
472
473 enter_lines = [l for l in captured if "ENTER" in l]
474 exit_lines = [l for l in captured if "EXIT" in l and "ms" in l]
475 assert enter_lines, f"Expected ENTER line in output, got: {captured!r}"
476 assert exit_lines, f"Expected EXIT line with 'ms' in output, got: {captured!r}"
477
478
480 """dbg_scope() emits ENTER and EXIT lines without timing information (FS-19)."""
481 captured = []
482 old_emit = _ud._emit
483 old_silent = _ud.SILENT_MODE
484 old_level = _ud.DEBUG_LEVELS.get("GENERAL")
485 old_init = _ud._initialized
486
487 def _capture(line, level, record=None):
488 captured.append(line)
489
490 _ud._emit = _capture
491 _ud.SILENT_MODE = False
492 _ud.DEBUG_LEVELS["GENERAL"] = 9
493 _ud._initialized = True # suppress pre-init RuntimeWarning
494 try:
495 with _ud.dbg_scope("GENERAL", "scope_test", label="myblock", level=2):
496 pass
497 finally:
498 _ud._emit = old_emit
499 _ud.SILENT_MODE = old_silent
500 _ud._initialized = old_init
501 if old_level is None:
502 _ud.DEBUG_LEVELS.pop("GENERAL", None)
503 else:
504 _ud.DEBUG_LEVELS["GENERAL"] = old_level
505
506 enter_lines = [l for l in captured if "ENTER" in l]
507 exit_lines = [l for l in captured if "EXIT" in l]
508 assert enter_lines, f"Expected ENTER line, got: {captured!r}"
509 assert exit_lines, f"Expected EXIT line, got: {captured!r}"
510 assert not any("ms" in l for l in exit_lines), (
511 f"dbg_scope EXIT must not include timing info, got: {exit_lines!r}"
512 )
513
514
516 """dbg_profile() decorator wraps a function and preserves its return value."""
517 captured = []
518 old_emit = _ud._emit
519 old_silent = _ud.SILENT_MODE
520 old_level = _ud.DEBUG_LEVELS.get("GENERAL")
521 old_init = _ud._initialized
522
523 def _capture(line, level, record=None):
524 captured.append(line)
525
526 _ud._emit = _capture
527 _ud.SILENT_MODE = False
528 _ud.DEBUG_LEVELS["GENERAL"] = 9
529 _ud._initialized = True # suppress pre-init RuntimeWarning
530 try:
531 @_ud.dbg_profile("GENERAL", label="profiled_fn", level=2)
532 def _sample():
533 return 42
534
535 result = _sample()
536 finally:
537 _ud._emit = old_emit
538 _ud.SILENT_MODE = old_silent
539 _ud._initialized = old_init
540 if old_level is None:
541 _ud.DEBUG_LEVELS.pop("GENERAL", None)
542 else:
543 _ud.DEBUG_LEVELS["GENERAL"] = old_level
544
545 assert result == 42, (
546 f"Decorated function return value must be preserved, got {result!r}"
547 )
548 enter_lines = [l for l in captured if "ENTER" in l]
549 exit_lines = [l for l in captured if "EXIT" in l and "ms" in l]
550 assert enter_lines, f"Expected ENTER line in output, got: {captured!r}"
551 assert exit_lines, f"Expected EXIT line with 'ms' in output, got: {captured!r}"
552
553
554# ============================================================
555# PRODUCTION MODE (FS-15)
556# ============================================================
557
559 """OC_DIAGNOSTICS_MODE=prod sets PROD_MODE=True at module load."""
560 result = subprocess.run(
561 [
562 sys.executable, "-c",
563 "import oc_diagnostics.unified_debug as _ud; "
564 "assert _ud.PROD_MODE is True, f'PROD_MODE={_ud.PROD_MODE!r}'"
565 ],
566 env={**os.environ, "OC_DIAGNOSTICS_MODE": "prod"},
567 capture_output=True,
568 text=True,
569 cwd=_REPO_ROOT,
570 )
571 assert result.returncode == 0, (
572 f"PROD_MODE not set True by env var; stderr: {result.stderr.strip()!r}"
573 )
574
575
577 """mode='prod' in debug_config.json global_settings sets PROD_MODE=True."""
578 config = {"global_settings": {"mode": "prod"}}
579 tmp_path = None
580 try:
581 with tempfile.NamedTemporaryFile(
582 mode="w", suffix=".json", delete=False, encoding="utf-8"
583 ) as f:
584 json.dump(config, f)
585 tmp_path = f.name
586
587 env = dict(os.environ)
588 env.pop("OC_DIAGNOSTICS_MODE", None)
589 env["OC_DIAGNOSTICS_CONFIG"] = tmp_path
590
591 result = subprocess.run(
592 [
593 sys.executable, "-c",
594 "import oc_diagnostics.unified_debug as _ud; "
595 "assert _ud.PROD_MODE is True, f'PROD_MODE={_ud.PROD_MODE!r}'"
596 ],
597 env=env,
598 capture_output=True,
599 text=True,
600 cwd=_REPO_ROOT,
601 )
602 assert result.returncode == 0, (
603 f"PROD_MODE not set by config mode='prod'; stderr: {result.stderr.strip()!r}"
604 )
605 finally:
606 if tmp_path and os.path.exists(tmp_path):
607 os.unlink(tmp_path)
608
609
610# ============================================================
611# LOG ROTATION (FS-06)
612# ============================================================
613
615 """_prune_old_log_files() removes the oldest files when count exceeds max."""
616 import glob as _glob
617
618 with tempfile.TemporaryDirectory() as tmpdir:
619 # Create 5 fake log files with slight mtime spacing
620 for i in range(5):
621 path = os.path.join(
622 tmpdir, f"unified_debug_logs_202401{i:02d}_000000.txt"
623 )
624 with open(path, "w") as f:
625 f.write(f"log {i}")
626 # Ensure distinct mtimes (file system granularity)
627 os.utime(path, (time.time() - (5 - i), time.time() - (5 - i)))
628
629 _prune_old_log_files(tmpdir, 3)
630
631 remaining = sorted(
632 _glob.glob(os.path.join(tmpdir, "unified_debug_logs_*.txt"))
633 )
634 assert len(remaining) == 3, (
635 f"Expected 3 files after pruning max_count=3, got {len(remaining)}: {remaining}"
636 )
637
638
639# ============================================================
640# HOT-RELOAD (OI-29)
641# ============================================================
642
644 """_reload_config() prunes stale keys; DEBUG_LEVELS is never empty."""
645 stale_key = "_OI29_TEST_STALE"
646 _ud.DEBUG_LEVELS[stale_key] = 7
647 new_levels = {"DATA": 3, "UI": 2}
648 with mock.patch.object(
649 _ud, "_load_debug_config",
650 return_value=(
651 new_levels, _ud.COLOR_CODES, _build_mock_settings(), _ud.CONFIG_SOURCE
652 ),
653 ):
654 _ud._reload_config()
655 assert stale_key not in _ud.DEBUG_LEVELS, (
656 f"Stale key '{stale_key}' must be removed after _reload_config()"
657 )
658 assert "DATA" in _ud.DEBUG_LEVELS, "New key 'DATA' must be present after reload"
659 assert len(_ud.DEBUG_LEVELS) > 0, "DEBUG_LEVELS must never be empty after reload"
660
661
662# ============================================================
663# I/O DEGRADATION (FS-25)
664# ============================================================
665
667 """FS-25: _write_to_file() OSError triggers one-time warning and mode fallback."""
668 old_mode = _ud.OUTPUT_MODE
669 old_warned = _ud._io_fallback_warned
670 old_stdout = _ud.ORIGINAL_STDOUT
671 stdout_capture = io.StringIO()
672 _ud.OUTPUT_MODE = "file"
673 _ud._io_fallback_warned = False
674 _ud.ORIGINAL_STDOUT = stdout_capture
675 try:
676 with mock.patch("builtins.open", side_effect=OSError("No space left on device")):
677 _ud._write_to_file("test line")
678 assert _ud.OUTPUT_MODE == "terminal", (
679 "OUTPUT_MODE must switch to 'terminal' after OSError"
680 )
681 assert _ud._io_fallback_warned is True, (
682 "_io_fallback_warned must be True after first OSError"
683 )
684 warning_text = stdout_capture.getvalue()
685 assert "WARNING" in warning_text, (
686 f"Expected 'WARNING' in stdout, got: {warning_text!r}"
687 )
688 # Second call: no further warning
689 stdout_capture.truncate(0)
690 stdout_capture.seek(0)
691 _ud._write_to_file("second line")
692 assert stdout_capture.getvalue() == "", (
693 "No second warning should be emitted for subsequent failures"
694 )
695 finally:
696 _ud.OUTPUT_MODE = old_mode
697 _ud._io_fallback_warned = old_warned
698 _ud.ORIGINAL_STDOUT = old_stdout
699
700
701# ============================================================
702# _dbg_assert (D-01)
703# ============================================================
704
706 """_dbg_assert() with truthy condition does not raise or emit."""
707 captured = []
708 old_emit = _ud._emit
709 old_init = _ud._initialized
710 old_prod = _ud.PROD_MODE
711 _ud._emit = lambda line, level, record=None: captured.append(line)
712 _ud._initialized = True
713 _ud.PROD_MODE = False
714 try:
715 _ud._dbg_assert("TEST", True, "should not fire", func="test_fn")
716 assert not captured, f"Truthy condition should emit nothing, got: {captured!r}"
717 finally:
718 _ud._emit = old_emit
719 _ud._initialized = old_init
720 _ud.PROD_MODE = old_prod
721
722
724 """_dbg_assert() with falsy condition emits L1 override + raises AssertionError."""
725 captured = []
726 old_emit = _ud._emit
727 old_init = _ud._initialized
728 old_prod = _ud.PROD_MODE
729 _ud._emit = lambda line, level, record=None: captured.append(line)
730 _ud._initialized = True
731 _ud.PROD_MODE = False
732 try:
733 raised = False
734 try:
735 _ud._dbg_assert("TEST", False, "batch must not be empty", func="process")
736 except AssertionError as e:
737 raised = True
738 assert "batch must not be empty" in str(e)
739 assert raised, "_dbg_assert with False must raise AssertionError"
740 assert any("ASSERT FAILED" in l for l in captured), (
741 f"Expected 'ASSERT FAILED' in output, got: {captured!r}"
742 )
743 finally:
744 _ud._emit = old_emit
745 _ud._initialized = old_init
746 _ud.PROD_MODE = old_prod
747
748
750 """_dbg_assert() in PROD_MODE is a complete no-op — condition not evaluated."""
751 old_prod = _ud.PROD_MODE
752 _ud.PROD_MODE = True
753 try:
754 # Pass a falsy condition — must NOT raise in prod mode
755 _ud._dbg_assert("TEST", False, "should be suppressed", func="test_fn")
756 finally:
757 _ud.PROD_MODE = old_prod
758
759
760# ============================================================
761# dbg_trace DECORATOR (D-02)
762# ============================================================
763
765 """@dbg_trace emits START and END messages."""
766 captured = []
767 old_emit = _ud._emit
768 old_init = _ud._initialized
769 old_level = _ud.DEBUG_LEVELS.get("_TRACE_")
770 _ud._emit = lambda line, level, record=None: captured.append(line)
771 _ud._initialized = True
772 _ud.DEBUG_LEVELS["_TRACE_"] = 9
773 try:
774 @_ud.dbg_trace("_TRACE_", level=2)
775 def my_func():
776 return 42
777
778 result = my_func()
779 assert result == 42, f"Return value must be preserved, got {result!r}"
780 assert any("START" in l for l in captured), f"Expected START, got: {captured!r}"
781 assert any("END" in l for l in captured), f"Expected END, got: {captured!r}"
782 finally:
783 _ud._emit = old_emit
784 _ud._initialized = old_init
785 if old_level is None:
786 _ud.DEBUG_LEVELS.pop("_TRACE_", None)
787 else:
788 _ud.DEBUG_LEVELS["_TRACE_"] = old_level
789
790
792 """@dbg_trace logs exception at L1 and re-raises."""
793 captured = []
794 old_emit = _ud._emit
795 old_init = _ud._initialized
796 old_level = _ud.DEBUG_LEVELS.get("_TRACE_")
797 _ud._emit = lambda line, level, record=None: captured.append(line)
798 _ud._initialized = True
799 _ud.DEBUG_LEVELS["_TRACE_"] = 9
800 try:
801 @_ud.dbg_trace("_TRACE_")
802 def failing_func():
803 raise ValueError("test error")
804
805 raised = False
806 try:
807 failing_func()
808 except ValueError:
809 raised = True
810 assert raised, "Exception must be re-raised"
811 assert any("EXCEPTION" in l and "ValueError" in l for l in captured), (
812 f"Expected EXCEPTION log with ValueError, got: {captured!r}"
813 )
814 finally:
815 _ud._emit = old_emit
816 _ud._initialized = old_init
817 if old_level is None:
818 _ud.DEBUG_LEVELS.pop("_TRACE_", None)
819 else:
820 _ud.DEBUG_LEVELS["_TRACE_"] = old_level
821
822
824 """@dbg_trace preserves __name__ via functools.wraps."""
825 @_ud.dbg_trace("TEST")
826 def named_function():
827 pass
828
829 assert named_function.__name__ == "named_function", (
830 f"Expected 'named_function', got {named_function.__name__!r}"
831 )
832
833
834# ============================================================
835# dbg_metrics DECORATOR (D-03)
836# ============================================================
837
839 """@dbg_metrics emits structured JSON with duration_ms and memory_mb."""
840 captured = []
841 old_emit = _ud._emit
842 old_init = _ud._initialized
843 old_level = _ud.DEBUG_LEVELS.get("_METRICS_")
844 _ud._emit = lambda line, level, record=None: captured.append(line)
845 _ud._initialized = True
846 _ud.DEBUG_LEVELS["_METRICS_"] = 9
847 try:
848 @_ud.dbg_metrics("_METRICS_", level=3)
849 def compute():
850 return sum(range(1000))
851
852 result = compute()
853 assert result == 499500, f"Return value must be preserved, got {result!r}"
854 metrics_lines = [l for l in captured if "METRICS:" in l]
855 assert metrics_lines, f"Expected METRICS line, got: {captured!r}"
856 # Extract the JSON part
857 json_str = metrics_lines[0].split("METRICS: ", 1)[1]
858 # Strip ANSI codes
859 json_str = _ud._ANSI_ESCAPE_RE.sub("", json_str)
860 data = json.loads(json_str)
861 assert "duration_ms" in data, f"Expected duration_ms key, got: {data!r}"
862 assert "memory_mb" in data, f"Expected memory_mb key, got: {data!r}"
863 assert isinstance(data["duration_ms"], (int, float)), "duration_ms must be numeric"
864 finally:
865 _ud._emit = old_emit
866 _ud._initialized = old_init
867 if old_level is None:
868 _ud.DEBUG_LEVELS.pop("_METRICS_", None)
869 else:
870 _ud.DEBUG_LEVELS["_METRICS_"] = old_level
871
872
873# ============================================================
874# AI-TRACE (D-04)
875# ============================================================
876
878 """AI-Trace messages (level='ai_trace') do not reach the console."""
879 console_captured = []
880 old_stdout = _ud.ORIGINAL_STDOUT
881 old_mode = _ud.OUTPUT_MODE
882 old_ai = _ud.AI_TRACE_ENABLED
883 old_silent = _ud.SILENT_MODE
884 old_init = _ud._initialized
885
886 fake_stdout = io.StringIO()
887 _ud.ORIGINAL_STDOUT = fake_stdout
888 _ud.OUTPUT_MODE = "both"
889 _ud.AI_TRACE_ENABLED = True
890 _ud.SILENT_MODE = False
891 _ud._initialized = True
892 try:
893 _ud._dbg("TEST", "test_fn", "ai trace msg", level="ai_trace")
894 console_output = fake_stdout.getvalue()
895 assert "ai trace msg" not in console_output, (
896 f"AI-Trace must not reach console, got: {console_output!r}"
897 )
898 finally:
899 _ud.ORIGINAL_STDOUT = old_stdout
900 _ud.OUTPUT_MODE = old_mode
901 _ud.AI_TRACE_ENABLED = old_ai
902 _ud.SILENT_MODE = old_silent
903 _ud._initialized = old_init
904
905
907 """AI-Trace messages are written to the log file."""
908 old_mode = _ud.OUTPUT_MODE
909 old_ai = _ud.AI_TRACE_ENABLED
910 old_log_dir = _ud.LOG_DIR
911 old_log_file = _ud.LOG_FILE
912 old_header = _ud._header_written
913 old_silent = _ud.SILENT_MODE
914 old_init = _ud._initialized
915
916 with tempfile.TemporaryDirectory() as tmpdir:
917 _ud.OUTPUT_MODE = "both"
918 _ud.AI_TRACE_ENABLED = True
919 _ud.LOG_DIR = tmpdir
920 _ud.LOG_FILE = None
921 _ud._header_written = False
922 _ud.SILENT_MODE = False
923 _ud._initialized = True
924 try:
925 _ud._dbg("TEST", "test_fn", "ai shadow message", level="ai_trace")
926 finally:
927 _ud.OUTPUT_MODE = old_mode
928 _ud.AI_TRACE_ENABLED = old_ai
929 _ud.LOG_DIR = old_log_dir
930 _ud.LOG_FILE = old_log_file
931 _ud._header_written = old_header
932 _ud.SILENT_MODE = old_silent
933 _ud._initialized = old_init
934
935 import glob as _glob
936 files = _glob.glob(os.path.join(tmpdir, "*.txt"))
937 assert files, "Expected at least one log file"
938 content = open(files[0], encoding="utf-8").read()
939 assert "ai shadow message" in content, (
940 f"AI-Trace message must be in log file, got: {content!r}"
941 )
942
943
945 """AI-Trace messages are silently discarded when AI_TRACE_ENABLED is False."""
946 captured = []
947 old_emit = _ud._emit
948 old_ai = _ud.AI_TRACE_ENABLED
949 old_silent = _ud.SILENT_MODE
950 _ud._emit = lambda line, level, record=None: captured.append(line)
951 _ud.AI_TRACE_ENABLED = False
952 _ud.SILENT_MODE = False
953 try:
954 _ud._dbg("TEST", "test_fn", "should vanish", level="ai_trace")
955 assert not captured, f"Disabled AI-Trace must emit nothing, got: {captured!r}"
956 finally:
957 _ud._emit = old_emit
958 _ud.AI_TRACE_ENABLED = old_ai
959 _ud.SILENT_MODE = old_silent
960
961
962# ============================================================
963# VERSION CHECK (D-08)
964# ============================================================
965
967 """oc_diagnostics version must be 2.0.0."""
968 import oc_diagnostics
969 assert oc_diagnostics.__version__ == "2.0.0", (
970 f"Expected version 2.0.0, got {oc_diagnostics.__version__!r}"
971 )
972
973
974# ============================================================
975# CONFIG SCHEMA V2.0 — DEBUG_LEVELS key (D-07)
976# ============================================================
977
979 """V2.0 config with 'DEBUG_LEVELS' key loads correctly."""
980 config = {
981 "_schema_version": "2.0",
982 "DEBUG_LEVELS": {"DATA": {"level": 4, "color": "cyan"}, "UI": 2},
983 "global_settings": {"mode": "dev"},
984 }
985 tmp_path = None
986 try:
987 with tempfile.NamedTemporaryFile(
988 mode="w", suffix=".json", delete=False, encoding="utf-8"
989 ) as f:
990 json.dump(config, f)
991 tmp_path = f.name
992
993 env = dict(os.environ)
994 env["OC_DIAGNOSTICS_CONFIG"] = tmp_path
995 result = subprocess.run(
996 [
997 sys.executable, "-c",
998 "import oc_diagnostics.unified_debug as _ud; "
999 "assert _ud.DEBUG_LEVELS.get('DATA') == 4, f'DATA={_ud.DEBUG_LEVELS.get(\"DATA\")}'; "
1000 "assert _ud.DEBUG_LEVELS.get('UI') == 2, f'UI={_ud.DEBUG_LEVELS.get(\"UI\")}'"
1001 ],
1002 env=env,
1003 capture_output=True,
1004 text=True,
1005 cwd=_REPO_ROOT,
1006 )
1007 assert result.returncode == 0, (
1008 f"DEBUG_LEVELS key not loaded; stderr: {result.stderr.strip()!r}"
1009 )
1010 finally:
1011 if tmp_path and os.path.exists(tmp_path):
1012 os.unlink(tmp_path)
1013
1014
1016 """V2.0 nested log_rotation object is parsed correctly."""
1017 config = {
1018 "DEBUG_LEVELS": {"GENERAL": 2},
1019 "global_settings": {
1020 "log_rotation": {"max_files": 10, "max_size_mb": 2.5},
1021 },
1022 }
1023 tmp_path = None
1024 try:
1025 with tempfile.NamedTemporaryFile(
1026 mode="w", suffix=".json", delete=False, encoding="utf-8"
1027 ) as f:
1028 json.dump(config, f)
1029 tmp_path = f.name
1030
1031 env = dict(os.environ)
1032 env["OC_DIAGNOSTICS_CONFIG"] = tmp_path
1033 result = subprocess.run(
1034 [
1035 sys.executable, "-c",
1036 "import oc_diagnostics.unified_debug as _ud; "
1037 "assert _ud.MAX_LOG_FILES == 10, f'max_files={_ud.MAX_LOG_FILES}'; "
1038 f"expected_bytes = int(2.5 * 1024 * 1024); "
1039 "assert _ud.MAX_LOG_SIZE_BYTES == expected_bytes, "
1040 "f'max_size={_ud.MAX_LOG_SIZE_BYTES}'"
1041 ],
1042 env=env,
1043 capture_output=True,
1044 text=True,
1045 cwd=_REPO_ROOT,
1046 )
1047 assert result.returncode == 0, (
1048 f"Nested log_rotation not parsed; stderr: {result.stderr.strip()!r}"
1049 )
1050 finally:
1051 if tmp_path and os.path.exists(tmp_path):
1052 os.unlink(tmp_path)
1053
1054
1055# ============================================================
1056# ENV VAR OVERRIDES (D-05, D-06)
1057# ============================================================
1058
1060 """OC_DIAGNOSTICS_LEVEL env var overrides all domain levels."""
1061 config = {
1062 "DEBUG_LEVELS": {"DATA": 2, "UI": 3},
1063 "global_settings": {},
1064 }
1065 tmp_path = None
1066 try:
1067 with tempfile.NamedTemporaryFile(
1068 mode="w", suffix=".json", delete=False, encoding="utf-8"
1069 ) as f:
1070 json.dump(config, f)
1071 tmp_path = f.name
1072
1073 env = dict(os.environ)
1074 env["OC_DIAGNOSTICS_CONFIG"] = tmp_path
1075 env["OC_DIAGNOSTICS_LEVEL"] = "7"
1076 result = subprocess.run(
1077 [
1078 sys.executable, "-c",
1079 "import oc_diagnostics.unified_debug as _ud; "
1080 "assert _ud.DEBUG_LEVELS.get('DATA') == 7, f'DATA={_ud.DEBUG_LEVELS.get(\"DATA\")}'; "
1081 "assert _ud.DEBUG_LEVELS.get('UI') == 7, f'UI={_ud.DEBUG_LEVELS.get(\"UI\")}'"
1082 ],
1083 env=env,
1084 capture_output=True,
1085 text=True,
1086 cwd=_REPO_ROOT,
1087 )
1088 assert result.returncode == 0, (
1089 f"OC_DIAGNOSTICS_LEVEL override failed; stderr: {result.stderr.strip()!r}"
1090 )
1091 finally:
1092 if tmp_path and os.path.exists(tmp_path):
1093 os.unlink(tmp_path)
1094
1095
1097 """OC_DIAGNOSTICS_LOG_FILE env var sets LOG_FILE at load time."""
1098 result = subprocess.run(
1099 [
1100 sys.executable, "-c",
1101 "import oc_diagnostics.unified_debug as _ud; "
1102 "assert _ud.LOG_FILE == '/tmp/test_log.txt', f'LOG_FILE={_ud.LOG_FILE!r}'"
1103 ],
1104 env={**os.environ, "OC_DIAGNOSTICS_LOG_FILE": "/tmp/test_log.txt"},
1105 capture_output=True,
1106 text=True,
1107 cwd=_REPO_ROOT,
1108 )
1109 assert result.returncode == 0, (
1110 f"OC_DIAGNOSTICS_LOG_FILE override failed; stderr: {result.stderr.strip()!r}"
1111 )
1112
1113
1114# ============================================================
1115# REDACTION PATTERNS (V2.0)
1116# ============================================================
1117
1119 """Redaction patterns scrub matching key=value pairs in production mode."""
1120 old_prod = _ud.PROD_MODE
1121 old_patterns = _ud.REDACTION_PATTERNS
1122 _ud.PROD_MODE = True
1123 _ud.REDACTION_PATTERNS = [
1124 re.compile(r'(?i)(password)\s*[=:]\s*\S+'),
1125 re.compile(r'(?i)(api_key)\s*[=:]\s*\S+'),
1126 ]
1127 try:
1128 result = _ud._apply_redaction("user login password=secret123 api_key=abc-xyz")
1129 assert "secret123" not in result, f"password value not redacted: {result!r}"
1130 assert "abc-xyz" not in result, f"api_key value not redacted: {result!r}"
1131 assert "[REDACTED]" in result, f"Expected [REDACTED] in output: {result!r}"
1132 finally:
1133 _ud.PROD_MODE = old_prod
1134 _ud.REDACTION_PATTERNS = old_patterns
1135
1136
1138 """Redaction patterns are not applied when PROD_MODE is False."""
1139 old_prod = _ud.PROD_MODE
1140 old_patterns = _ud.REDACTION_PATTERNS
1141 _ud.PROD_MODE = False
1142 _ud.REDACTION_PATTERNS = [
1143 re.compile(r'(?i)(password)\s*[=:]\s*\S+'),
1144 ]
1145 try:
1146 text = "password=secret123"
1147 result = _ud._apply_redaction(text)
1148 assert result == text, f"Dev mode should not redact: {result!r}"
1149 finally:
1150 _ud.PROD_MODE = old_prod
1151 _ud.REDACTION_PATTERNS = old_patterns
1152
1153
1155 """Redaction handles double-quoted multi-word values (OI-401)."""
1156 old_prod = _ud.PROD_MODE
1157 old_patterns = _ud.REDACTION_PATTERNS
1158 _ud.PROD_MODE = True
1159 _ud.REDACTION_PATTERNS = [
1160 re.compile(r'(?i)(password)\s*[=:]\s*(?:"[^"]*"|\'[^\']*\'|\S+)'),
1161 ]
1162 try:
1163 result = _ud._apply_redaction('password: "my secret value"')
1164 assert "my secret value" not in result
1165 assert "[REDACTED]" in result
1166 finally:
1167 _ud.PROD_MODE = old_prod
1168 _ud.REDACTION_PATTERNS = old_patterns
1169
1170
1172 """Redaction handles single-quoted multi-word values (OI-401)."""
1173 old_prod = _ud.PROD_MODE
1174 old_patterns = _ud.REDACTION_PATTERNS
1175 _ud.PROD_MODE = True
1176 _ud.REDACTION_PATTERNS = [
1177 re.compile(r"(?i)(api_key)\s*[=:]\s*(?:\"[^\"]*\"|'[^']*'|\S+)"),
1178 ]
1179 try:
1180 result = _ud._apply_redaction("api_key='my secret key'")
1181 assert "my secret key" not in result
1182 assert "[REDACTED]" in result
1183 finally:
1184 _ud.PROD_MODE = old_prod
1185 _ud.REDACTION_PATTERNS = old_patterns
1186
1187
1189 """Original bare-value redaction still works after regex update (OI-401)."""
1190 old_prod = _ud.PROD_MODE
1191 old_patterns = _ud.REDACTION_PATTERNS
1192 _ud.PROD_MODE = True
1193 _ud.REDACTION_PATTERNS = [
1194 re.compile(r'(?i)(password)\s*[=:]\s*(?:"[^"]*"|\'[^\']*\'|\S+)'),
1195 ]
1196 try:
1197 result = _ud._apply_redaction("password=secret123")
1198 assert "secret123" not in result
1199 assert "[REDACTED]" in result
1200 finally:
1201 _ud.PROD_MODE = old_prod
1202 _ud.REDACTION_PATTERNS = old_patterns
1203
1204
1205# ============================================================
1206# GLOBAL DEBUG LEVEL (V2.0)
1207# ============================================================
1208
1210 """global_debug_level in config overrides all domain levels."""
1211 config = {
1212 "DEBUG_LEVELS": {"DATA": 2, "UI": 3, "NET": 1},
1213 "global_settings": {"global_debug_level": 5},
1214 }
1215 tmp_path = None
1216 try:
1217 with tempfile.NamedTemporaryFile(
1218 mode="w", suffix=".json", delete=False, encoding="utf-8"
1219 ) as f:
1220 json.dump(config, f)
1221 tmp_path = f.name
1222
1223 env = dict(os.environ)
1224 env["OC_DIAGNOSTICS_CONFIG"] = tmp_path
1225 env.pop("OC_DIAGNOSTICS_LEVEL", None)
1226 result = subprocess.run(
1227 [
1228 sys.executable, "-c",
1229 "import oc_diagnostics.unified_debug as _ud; "
1230 "assert all(v == 5 for v in _ud.DEBUG_LEVELS.values()), "
1231 "f'Not all levels are 5: {dict(_ud.DEBUG_LEVELS)}'"
1232 ],
1233 env=env,
1234 capture_output=True,
1235 text=True,
1236 cwd=_REPO_ROOT,
1237 )
1238 assert result.returncode == 0, (
1239 f"global_debug_level override failed; stderr: {result.stderr.strip()!r}"
1240 )
1241 finally:
1242 if tmp_path and os.path.exists(tmp_path):
1243 os.unlink(tmp_path)
1244
1245
1246# ============================================================
1247# V2.0 BUNDLED CONFIG SCHEMA VERSION
1248# ============================================================
1249
1251 """Bundled debug_config.json uses V2.0 schema with DEBUG_LEVELS key."""
1252 config_path = os.path.join(
1253 os.path.dirname(_ud.__file__), "debug_config.json"
1254 )
1255 with open(config_path, encoding="utf-8") as f:
1256 config = json.load(f)
1257 assert config.get("_schema_version") == "2.0", (
1258 f"Expected schema version 2.0, got {config.get('_schema_version')!r}"
1259 )
1260 assert "DEBUG_LEVELS" in config, "Bundled config must use DEBUG_LEVELS key"
1261
1262
1263# ============================================================
1264# OI-407: production-mode _dbg() fail-safe (PROD_MAX_LEVEL)
1265# ============================================================
1266
1268 _ud._prod_downgrade_warned.clear()
1269
1270
1272 """In PROD_MODE, _dbg() calls above PROD_MAX_LEVEL are suppressed (OI-407)."""
1273 captured = []
1274 old_emit = _ud._emit
1275 old_prod = _ud.PROD_MODE
1276 old_max = _ud.PROD_MAX_LEVEL
1277 old_levels = dict(_ud.DEBUG_LEVELS)
1278 _ud._emit = lambda line, level, record=None: captured.append((level, line))
1279 _ud.PROD_MODE = True
1280 _ud.PROD_MAX_LEVEL = 2
1281 _ud.DEBUG_LEVELS["GENERAL"] = 9 # ensure domain gate would allow L3
1283 try:
1284 _ud._dbg("GENERAL", "test_fn", "verbose L3 detail", level=3)
1285 # Only the one-shot warning should be emitted — no user L3 line.
1286 assert len(captured) == 1
1287 assert "suppressed in production mode" in captured[0][1]
1288 assert "L3" in captured[0][1]
1289 finally:
1290 _ud._emit = old_emit
1291 _ud.PROD_MODE = old_prod
1292 _ud.PROD_MAX_LEVEL = old_max
1293 _ud.DEBUG_LEVELS.clear()
1294 _ud.DEBUG_LEVELS.update(old_levels)
1296
1297
1299 """L1/L2 calls in PROD_MODE are still emitted normally."""
1300 captured = []
1301 old_emit = _ud._emit
1302 old_prod = _ud.PROD_MODE
1303 old_max = _ud.PROD_MAX_LEVEL
1304 old_levels = dict(_ud.DEBUG_LEVELS)
1305 _ud._emit = lambda line, level, record=None: captured.append((level, line))
1306 _ud.PROD_MODE = True
1307 _ud.PROD_MAX_LEVEL = 2
1308 _ud.DEBUG_LEVELS["GENERAL"] = 2
1310 try:
1311 _ud._dbg("GENERAL", "test_fn", "normal L2", level=2)
1312 assert len(captured) == 1
1313 assert "normal L2" in captured[0][1]
1314 assert "suppressed" not in captured[0][1]
1315 finally:
1316 _ud._emit = old_emit
1317 _ud.PROD_MODE = old_prod
1318 _ud.PROD_MAX_LEVEL = old_max
1319 _ud.DEBUG_LEVELS.clear()
1320 _ud.DEBUG_LEVELS.update(old_levels)
1322
1323
1325 """Repeated L3 calls in PROD_MODE emit only one warning per (domain, level)."""
1326 captured = []
1327 old_emit = _ud._emit
1328 old_prod = _ud.PROD_MODE
1329 old_max = _ud.PROD_MAX_LEVEL
1330 old_levels = dict(_ud.DEBUG_LEVELS)
1331 _ud._emit = lambda line, level, record=None: captured.append(line)
1332 _ud.PROD_MODE = True
1333 _ud.PROD_MAX_LEVEL = 2
1334 _ud.DEBUG_LEVELS["GENERAL"] = 9
1336 try:
1337 for _ in range(5):
1338 _ud._dbg("GENERAL", "f", "m", level=3)
1339 warn_lines = [c for c in captured if "suppressed" in c]
1340 assert len(warn_lines) == 1, f"expected 1 warning, got {len(warn_lines)}"
1341 finally:
1342 _ud._emit = old_emit
1343 _ud.PROD_MODE = old_prod
1344 _ud.PROD_MAX_LEVEL = old_max
1345 _ud.DEBUG_LEVELS.clear()
1346 _ud.DEBUG_LEVELS.update(old_levels)
1348
1349
1351 """Setting PROD_MAX_LEVEL=3 allows L3 but still blocks L4."""
1352 captured = []
1353 old_emit = _ud._emit
1354 old_prod = _ud.PROD_MODE
1355 old_max = _ud.PROD_MAX_LEVEL
1356 old_levels = dict(_ud.DEBUG_LEVELS)
1357 _ud._emit = lambda line, level, record=None: captured.append((level, line))
1358 _ud.PROD_MODE = True
1359 _ud.PROD_MAX_LEVEL = 3
1360 _ud.DEBUG_LEVELS["GENERAL"] = 9
1362 try:
1363 _ud._dbg("GENERAL", "f", "l3 ok", level=3)
1364 _ud._dbg("GENERAL", "f", "l4 blocked", level=4)
1365 bodies = [c[1] for c in captured]
1366 assert any("l3 ok" in b for b in bodies)
1367 assert not any("l4 blocked" in b for b in bodies)
1368 assert any("suppressed" in b and "L4" in b for b in bodies)
1369 finally:
1370 _ud._emit = old_emit
1371 _ud.PROD_MODE = old_prod
1372 _ud.PROD_MAX_LEVEL = old_max
1373 _ud.DEBUG_LEVELS.clear()
1374 _ud.DEBUG_LEVELS.update(old_levels)
1376
1377
1379 """Outside PROD_MODE, PROD_MAX_LEVEL has no effect."""
1380 captured = []
1381 old_emit = _ud._emit
1382 old_prod = _ud.PROD_MODE
1383 old_max = _ud.PROD_MAX_LEVEL
1384 old_levels = dict(_ud.DEBUG_LEVELS)
1385 _ud._emit = lambda line, level, record=None: captured.append((level, line))
1386 _ud.PROD_MODE = False
1387 _ud.PROD_MAX_LEVEL = 1 # very restrictive
1388 _ud.DEBUG_LEVELS["GENERAL"] = 9
1390 try:
1391 _ud._dbg("GENERAL", "f", "l3 allowed in dev", level=3)
1392 bodies = [c[1] for c in captured]
1393 assert any("l3 allowed in dev" in b for b in bodies)
1394 assert not any("suppressed" in b for b in bodies)
1395 finally:
1396 _ud._emit = old_emit
1397 _ud.PROD_MODE = old_prod
1398 _ud.PROD_MAX_LEVEL = old_max
1399 _ud.DEBUG_LEVELS.clear()
1400 _ud.DEBUG_LEVELS.update(old_levels)