Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ exclude = ["src/main.rs", "docs", "BrailleDocs", "PythonScripts"] # should ha


[features]
"rule-coverage" = ["dep:serde_json"]
"include-zip" = []
"enable-logs" = ["android_logger"]
"tts" = [ "natural-tts" ]
Expand All @@ -38,6 +39,7 @@ cfg-if = "1.0.1"
fastrand = { version = "2.3.0" }
clap = { version = "4.5.60", features = ["derive"] }
html-escape = "0.2.13"
serde_json = { version = "1.0", optional = true }
elements_rs = { version = "0.2.8", default-features = false }

# Runtime extracts inner language/braille zips (BZIP2). The package-rules binary also
Expand Down
9 changes: 9 additions & 0 deletions PythonScripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@ If you run from the repo root instead of inside `PythonScripts`, point uv at the
uv sync --project PythonScripts
uv run --project PythonScripts audit-translations de
```

To run rule YAML coverage from the repo root, use the audit tool:

```bash
uv run --project PythonScripts audit-translations --rule-coverage
```

The tool opens `target/rule-coverage/index.html` in a browser when the run finishes. Expand a YAML file to see each active rule's name, tag, hit count, and match status. Hover over a rule to see which tests hit it. Coverage fractions include percentages.
The Markdown report and test output remain at `target/rule-coverage/report.md` and `target/rule-coverage/test.log`.
3 changes: 3 additions & 0 deletions PythonScripts/audit_translations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@ The tool automatically adjusts its matching logic based on the file type:
uv run audit-translations <language> [--file <specific_file>]
uv run audit-translations <language> --source <source-language>
uv run audit-translations --list
uv run audit-translations --rule-coverage

# If running from the repo root, point uv at the project:
uv run --project PythonScripts audit-translations <language>
uv run --project PythonScripts audit-translations <language> --source <source-language>
uv run --project PythonScripts audit-translations --list
uv run --project PythonScripts audit-translations --rule-coverage
```

**Convenience Features:**
Expand All @@ -82,6 +84,7 @@ uv run --project PythonScripts audit-translations --list
* `--rules-dir`: Override the Rules/Languages directory path.
* `--only`: Filter issue types (comma-separated): `missing`, `untranslated`, `extra`, `diffs`, `all`.
* `--verbose`: Show detailed output including source/target snippets for rule differences.
* `--rule-coverage`: Run the full Rust test suite, generate file- and rule-level pattern coverage under `target/rule-coverage/`, and open the HTML report. Unicode mapping and definition files are omitted. Expand a file to inspect matched and unmatched rules by name and tag, their hit counts, and hover over a rule to see the tests that matched it. Coverage fractions include percentages. This runs independently of a language audit.
* **Summary Stats:** Provides a statistical summary after every run.

**Examples:**
Expand Down
13 changes: 11 additions & 2 deletions PythonScripts/audit_translations/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,33 @@
from .auditor import audit_language, list_languages
from .errors import AuditError
from .renderer import console
from .rulecoverage.rule_coverage import run as run_rule_coverage


def main() -> None:
"""Main entry point for the audit tool"""
sys.stdout.reconfigure(encoding="utf-8")

parser = argparse.ArgumentParser(
description="Audit MathCAT translation files against a source language",
description="Audit MathCAT translations or report rule YAML test coverage",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
uv run audit-translations es
uv run audit-translations nb --source sv
uv run audit-translations de --file SharedRules/default.yaml
uv run audit-translations --list
uv run audit-translations --rule-coverage
""",
)

parser.add_argument("language", nargs="?", help="Language code to audit (e.g., 'es', 'de', 'fi')")
parser.add_argument("--source", default="en", help="Source/reference language code (default: 'en')")
parser.add_argument("--file", dest="specific_file", help="Audit only a specific file (e.g., 'SharedRules/default.yaml')")
parser.add_argument("--list", action="store_true", help="List available languages")
parser.add_argument(
"--rule-coverage", action="store_true", help="Run the full Rust tests and open the rule YAML coverage report"
)
parser.add_argument("--rules-dir", help="Override Rules/Languages directory path")
parser.add_argument(
"--only",
Expand All @@ -45,7 +50,11 @@ def main() -> None:

args = parser.parse_args()

if args.list:
if args.rule_coverage:
if args.language or args.list or args.specific_file or args.rules_dir or args.only or args.verbose or args.source != "en":
parser.error("--rule-coverage cannot be combined with translation audit options")
sys.exit(run_rule_coverage())
elif args.list:
list_languages(args.rules_dir)
elif not args.language:
parser.print_help()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Rule coverage collection and report generation."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rule YAML coverage</title>
<style>
body { max-width: 75rem; margin: 2rem auto; padding: 0 1rem; font: 1rem/1.5 system-ui, sans-serif; color: #17212b; }
h1, h2 { line-height: 1.2; }
.summary { display: flex; flex-wrap: wrap; gap: .5rem 2rem; }
.problems { padding: .5rem 1rem; background: #fff0ed; border-left: .25rem solid #b42318; }
.controls { display: flex; flex-wrap: wrap; gap: .75rem; margin: 1.5rem 0; }
input, select { font: inherit; padding: .35rem .5rem; }
input { flex: 1 1 20rem; }
.file { border: 1px solid #cad4dc; border-radius: .4rem; margin: .5rem 0; padding: .6rem 1rem; }
summary { cursor: pointer; }
.count { float: right; color: #52616d; }
.badge { display: inline-block; border-radius: .3rem; padding: .05rem .4rem; font-size: .85rem; font-weight: 600; }
.badge.matched { color: #155c36; background: #dcf5e5; }
.badge.unmatched { color: #8b3d00; background: #fff0cf; }
.rules { list-style: none; padding-left: 1rem; }
.rules li { position: relative; padding: .2rem 0; }
.rule-tooltip { display: none; position: absolute; z-index: 1; top: 100%; left: 0;
max-width: min(35rem, 90vw); max-height: 60vh; overflow: auto; padding: .5rem .7rem;
border: 1px solid #8396a5; border-radius: .3rem; background: #fff; color: #17212b;
box-shadow: 0 .2rem .6rem #0003; white-space: pre-line; overflow-wrap: anywhere; }
.rules li:hover .rule-tooltip, .rules li:focus .rule-tooltip { display: block; }
.rule-name { font-weight: 600; }
.empty { color: #52616d; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<h1>Rule YAML coverage</h1>
<p>Status: <strong>{{ status }}</strong></p>
<div class="summary"><span>{{ loaded_count }} files loaded</span><span>{{ file_coverage }} pattern files matched</span>
<span>{{ rule_coverage }} active rules matched</span></div>
<p>Paths are relative to <code>Rules/</code>. A pattern file is matched when a rule from it
completes its replacement successfully.</p>
<p>Unicode mapping and definition files are omitted because this report measures pattern-rule coverage.</p>
{% if errors %}
<section class="problems"><h2>Problems</h2><ul>
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul></section>
{% endif %}
<div class="controls"><input id="search" type="search" aria-label="Search files and rules"
placeholder="Search paths, rule names, or tags">
<select id="status" aria-label="Filter by coverage"><option value="all">All</option>
<option value="matched">Matched</option><option value="unmatched">No match</option></select></div>
<div id="files">
{% for file in files %}
<details class="file" data-file-search="{{ file.path }}" data-status="{{ 'matched' if file.matched else 'unmatched' }}">
<summary><span class="badge {{ 'matched' if file.matched else 'unmatched' }}">{{ 'Matched' if file.matched else 'No match' }}</span>
<code>{{ file.path }}</code> <span class="count">{{ file.coverage }} rules</span></summary>
{% if file.rules %}
<p>{{ file.coverage }} active rules matched</p><ul class="rules">
{% set file_index = loop.index0 %}
{% for rule in file.rules %}
<li data-rule-search="{{ rule.name }} {{ rule.tag }}" data-status="{{ 'matched' if rule.matched else 'unmatched' }}" tabindex="0" aria-describedby="rule-tooltip-{{ file_index }}-{{ loop.index0 }}">
<span class="badge {{ 'matched' if rule.matched else 'unmatched' }}">{{ 'Matched' if rule.matched else 'No match' }}</span>
<span class="rule-name">{{ rule.name }}</span> <code>{{ rule.tag }}</code> <span class="count">{{ rule.hits }} {{ 'hit' if rule.hits == 1 else 'hits' }}</span>
<span class="rule-tooltip" id="rule-tooltip-{{ file_index }}-{{ loop.index0 }}" role="tooltip">{{ rule.tooltip }}</span></li>
{% endfor %}
</ul>
{% else %}
<p class="empty">No active pattern rules</p>
{% endif %}
</details>
{% endfor %}
</div>
<p id="no-results" hidden>No matching files or rules</p>
<script>
const search = document.getElementById('search');
const status = document.getElementById('status');
const files = [...document.querySelectorAll('.file')];
function filter() {
const query = search.value.trim().toLocaleLowerCase();
let visible = 0;
for (const file of files) {
const pathMatches = file.dataset.fileSearch.toLocaleLowerCase().includes(query);
let visibleRules = 0;
for (const rule of file.querySelectorAll('[data-rule-search]')) {
const show = (status.value === 'all' || rule.dataset.status === status.value)
&& (pathMatches || rule.dataset.ruleSearch.toLocaleLowerCase().includes(query));
rule.hidden = !show;
if (show) visibleRules++;
}
const fileStatusMatches = status.value === 'all' || file.dataset.status === status.value;
const showFile = (pathMatches && fileStatusMatches) || visibleRules > 0;
file.hidden = !showFile;
if (showFile) visible++;
}
document.getElementById('no-results').hidden = visible > 0;
}
search.addEventListener('input', filter);
status.addEventListener('change', filter);
</script>
</body>
</html>
Loading
Loading