Commit ·
10ec354
1
Parent(s): 1afad28
fix: QA sweep — 6 issues resolved (sidebar chips, corrected field, input sanitization, punc cap, docs, tests)
Browse filesFix 1: Sidebar cards now use resolveAlternatives() shared helper (empty array fallback)
Fix 2: 'corrected' field rebuilt from original + accepted patches, not ctx.current_text
Fix 3: HTML tags stripped, non-Arabic inputs rejected (DoS prevention)
Fix 4: Aggregate punctuation cap (max 3 per response, keep earliest)
Fix 5: Models don't expose alternatives — Fix 1 fallback is permanent (documented)
Fix 6: applyAllSuggestions reverse-sort documented with CRITICAL comment
Added 12 new regression tests (31 total, all passing)
- src/app.py +46 -1
- src/js/editor.js +10 -3
- src/js/ui.js +12 -1
- tests/test_pipeline_hardening.py +210 -0
src/app.py
CHANGED
|
@@ -775,6 +775,23 @@ def analyze_text():
|
|
| 775 |
if not text:
|
| 776 |
return jsonify({'error': 'Text is required', 'status': 'error'}), 400
|
| 777 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
# Pipeline state — PipelineContext carries all shared state
|
| 779 |
ctx = PipelineContext(text)
|
| 780 |
current_text = text # Local alias (updated alongside ctx.current_text)
|
|
@@ -999,6 +1016,20 @@ def analyze_text():
|
|
| 999 |
'punctuation', d['start'], d['end'],
|
| 1000 |
d['correction'], confidence=0.8
|
| 1001 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1002 |
ctx.mutate_text(corrected_punc, OffsetMapper)
|
| 1003 |
current_text = ctx.current_text
|
| 1004 |
except Exception as e:
|
|
@@ -1015,6 +1046,20 @@ def analyze_text():
|
|
| 1015 |
# One range = one owner. No stacking.
|
| 1016 |
suggestions = ctx.patches.to_list()
|
| 1017 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1018 |
logger.info(f"[ANALYZE] Total: {timing_ms['total_ms']}ms | "
|
| 1019 |
f"Spelling: {timing_ms['spelling_ms']}ms | "
|
| 1020 |
f"Grammar: {timing_ms['grammar_ms']}ms | "
|
|
@@ -1023,7 +1068,7 @@ def analyze_text():
|
|
| 1023 |
|
| 1024 |
return jsonify({
|
| 1025 |
'original': text,
|
| 1026 |
-
'corrected':
|
| 1027 |
'suggestions': suggestions,
|
| 1028 |
'timing_ms': timing_ms,
|
| 1029 |
'status': 'success'
|
|
|
|
| 775 |
if not text:
|
| 776 |
return jsonify({'error': 'Text is required', 'status': 'error'}), 400
|
| 777 |
|
| 778 |
+
# ── Input Sanitization (Fix 3: prevent pathological model inputs) ──
|
| 779 |
+
# Strip HTML tags — prevents AraSpell from doing exhaustive edit-distance
|
| 780 |
+
# on tag characters like <script>, </div>, etc.
|
| 781 |
+
text = re.sub(r'<[^>]*>', '', text).strip()
|
| 782 |
+
if not text:
|
| 783 |
+
return jsonify({'error': 'Text is required', 'status': 'error'}), 400
|
| 784 |
+
|
| 785 |
+
# Reject inputs that are predominantly non-Arabic (code, markup, etc.)
|
| 786 |
+
arabic_chars = len(re.findall(r'[\u0600-\u06FF]', text))
|
| 787 |
+
alpha_chars = len(re.findall(r'[a-zA-Z\u0600-\u06FF]', text))
|
| 788 |
+
if alpha_chars > 0 and arabic_chars / alpha_chars < 0.3:
|
| 789 |
+
return jsonify({
|
| 790 |
+
'original': text, 'corrected': text,
|
| 791 |
+
'suggestions': [], 'timing_ms': {},
|
| 792 |
+
'status': 'success'
|
| 793 |
+
})
|
| 794 |
+
|
| 795 |
# Pipeline state — PipelineContext carries all shared state
|
| 796 |
ctx = PipelineContext(text)
|
| 797 |
current_text = text # Local alias (updated alongside ctx.current_text)
|
|
|
|
| 1016 |
'punctuation', d['start'], d['end'],
|
| 1017 |
d['correction'], confidence=0.8
|
| 1018 |
)
|
| 1019 |
+
|
| 1020 |
+
# ── Aggregate punctuation cap (Fix 4): max 3 punctuation patches per response ──
|
| 1021 |
+
MAX_PUNC_PATCHES_PER_RESPONSE = 3
|
| 1022 |
+
punc_patches = [p for p in ctx.patches.patches if p.stage == 'punctuation']
|
| 1023 |
+
if len(punc_patches) > MAX_PUNC_PATCHES_PER_RESPONSE:
|
| 1024 |
+
# Keep earliest patches (by start_original) — consistent with PatchSet sort
|
| 1025 |
+
punc_patches_sorted = sorted(punc_patches, key=lambda p: p.start_original)
|
| 1026 |
+
to_remove = set(id(p) for p in punc_patches_sorted[MAX_PUNC_PATCHES_PER_RESPONSE:])
|
| 1027 |
+
ctx.patches.patches = [p for p in ctx.patches.patches if id(p) not in to_remove]
|
| 1028 |
+
logger.info(
|
| 1029 |
+
f"[PUNC-CAP] Capped punctuation patches: "
|
| 1030 |
+
f"{len(punc_patches)} → {MAX_PUNC_PATCHES_PER_RESPONSE}"
|
| 1031 |
+
)
|
| 1032 |
+
|
| 1033 |
ctx.mutate_text(corrected_punc, OffsetMapper)
|
| 1034 |
current_text = ctx.current_text
|
| 1035 |
except Exception as e:
|
|
|
|
| 1046 |
# One range = one owner. No stacking.
|
| 1047 |
suggestions = ctx.patches.to_list()
|
| 1048 |
|
| 1049 |
+
# ── Rebuild 'corrected' from original + accepted patches (Fix 2) ──
|
| 1050 |
+
# This ensures 'corrected' exactly matches what you'd get by applying
|
| 1051 |
+
# all suggestions to 'original'. ctx.current_text includes StageLocker-
|
| 1052 |
+
# blocked and safety-rejected mutations and must NOT be used.
|
| 1053 |
+
def _apply_patches_to_original(original_text, suggestion_dicts):
|
| 1054 |
+
"""Apply patches in reverse offset order to produce corrected text."""
|
| 1055 |
+
result = original_text
|
| 1056 |
+
# Sort by start DESC so offset shifts don't invalidate later patches
|
| 1057 |
+
for s in sorted(suggestion_dicts, key=lambda x: -x['start']):
|
| 1058 |
+
result = result[:s['start']] + s['correction'] + result[s['end']:]
|
| 1059 |
+
return result
|
| 1060 |
+
|
| 1061 |
+
corrected = _apply_patches_to_original(text, suggestions)
|
| 1062 |
+
|
| 1063 |
logger.info(f"[ANALYZE] Total: {timing_ms['total_ms']}ms | "
|
| 1064 |
f"Spelling: {timing_ms['spelling_ms']}ms | "
|
| 1065 |
f"Grammar: {timing_ms['grammar_ms']}ms | "
|
|
|
|
| 1068 |
|
| 1069 |
return jsonify({
|
| 1070 |
'original': text,
|
| 1071 |
+
'corrected': corrected,
|
| 1072 |
'suggestions': suggestions,
|
| 1073 |
'timing_ms': timing_ms,
|
| 1074 |
'status': 'success'
|
src/js/editor.js
CHANGED
|
@@ -379,9 +379,12 @@ function showTooltip(element) {
|
|
| 379 |
|
| 380 |
// Render alternatives
|
| 381 |
if (alternativesEl) {
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
| 385 |
let html = '';
|
| 386 |
// Render corrections first (non-keep)
|
| 387 |
alts.forEach((alt, i) => {
|
|
@@ -651,6 +654,10 @@ function applySuggestionById(id) {
|
|
| 651 |
}
|
| 652 |
|
| 653 |
function applyAllSuggestions() {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
const suggestions = [...(window.currentSuggestions || [])].sort((a, b) => b.start - a.start);
|
| 655 |
if (suggestions.length === 0) return;
|
| 656 |
_isApplyingSuggestion = true;
|
|
|
|
| 379 |
|
| 380 |
// Render alternatives
|
| 381 |
if (alternativesEl) {
|
| 382 |
+
// Use shared helper (defined in ui.js, loaded before editor.js)
|
| 383 |
+
const alts = (typeof resolveAlternatives === 'function')
|
| 384 |
+
? resolveAlternatives(suggestion)
|
| 385 |
+
: (suggestion.alternatives && suggestion.alternatives.length > 0)
|
| 386 |
+
? suggestion.alternatives
|
| 387 |
+
: [suggestion.correction, suggestion.original];
|
| 388 |
let html = '';
|
| 389 |
// Render corrections first (non-keep)
|
| 390 |
alts.forEach((alt, i) => {
|
|
|
|
| 654 |
}
|
| 655 |
|
| 656 |
function applyAllSuggestions() {
|
| 657 |
+
// CRITICAL: Sort in REVERSE order (highest start offset first).
|
| 658 |
+
// Applying edits back-to-front preserves the offset validity of all
|
| 659 |
+
// subsequent edits. Front-to-back would invalidate every offset after
|
| 660 |
+
// the first edit due to text length changes. Do NOT change this sort.
|
| 661 |
const suggestions = [...(window.currentSuggestions || [])].sort((a, b) => b.start - a.start);
|
| 662 |
if (suggestions.length === 0) return;
|
| 663 |
_isApplyingSuggestion = true;
|
src/js/ui.js
CHANGED
|
@@ -59,10 +59,21 @@ function updateWritingScore(spelling, grammar, punctuation) {
|
|
| 59 |
/**
|
| 60 |
* Build HTML for a single suggestion card
|
| 61 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
function buildSuggestionCardHTML(suggestion, index) {
|
| 63 |
const badgeClass = `badge-${suggestion.type}`;
|
| 64 |
const label = TYPE_LABELS[suggestion.type] || suggestion.type;
|
| 65 |
-
const alts = suggestion
|
| 66 |
// Pipeline Hardening v3.3: Use suggestion.id (UUID) instead of array index
|
| 67 |
const suggestionId = suggestion.id || index;
|
| 68 |
|
|
|
|
| 59 |
/**
|
| 60 |
* Build HTML for a single suggestion card
|
| 61 |
*/
|
| 62 |
+
/**
|
| 63 |
+
* Resolve alternatives for a suggestion, falling back to [correction, original]
|
| 64 |
+
* when the model doesn't provide alternatives (e.g. grammar, punctuation).
|
| 65 |
+
* Shared logic — must stay in sync with tooltip rendering in editor.js.
|
| 66 |
+
*/
|
| 67 |
+
function resolveAlternatives(suggestion) {
|
| 68 |
+
return (suggestion.alternatives && suggestion.alternatives.length > 0)
|
| 69 |
+
? suggestion.alternatives
|
| 70 |
+
: [suggestion.correction, suggestion.original];
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
function buildSuggestionCardHTML(suggestion, index) {
|
| 74 |
const badgeClass = `badge-${suggestion.type}`;
|
| 75 |
const label = TYPE_LABELS[suggestion.type] || suggestion.type;
|
| 76 |
+
const alts = resolveAlternatives(suggestion);
|
| 77 |
// Pipeline Hardening v3.3: Use suggestion.id (UUID) instead of array index
|
| 78 |
const suggestionId = suggestion.id || index;
|
| 79 |
|
tests/test_pipeline_hardening.py
CHANGED
|
@@ -405,5 +405,215 @@ class TestOffsetMapperMonotonicity(unittest.TestCase):
|
|
| 405 |
f"Monotonicity violated: start={start} > end={end}")
|
| 406 |
|
| 407 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
if __name__ == '__main__':
|
| 409 |
unittest.main()
|
|
|
|
| 405 |
f"Monotonicity violated: start={start} > end={end}")
|
| 406 |
|
| 407 |
|
| 408 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 409 |
+
# QA Fix 1: Sidebar card alternatives fallback
|
| 410 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 411 |
+
class TestSidebarAlternativesFallback(unittest.TestCase):
|
| 412 |
+
def test_ui_has_resolve_alternatives(self):
|
| 413 |
+
"""ui.js must contain resolveAlternatives helper function."""
|
| 414 |
+
ui_path = os.path.join(os.path.dirname(__file__), '..', 'src', 'js', 'ui.js')
|
| 415 |
+
with open(ui_path, 'r', encoding='utf-8') as f:
|
| 416 |
+
content = f.read()
|
| 417 |
+
self.assertIn('function resolveAlternatives', content)
|
| 418 |
+
self.assertIn('alternatives.length > 0', content)
|
| 419 |
+
# buildSuggestionCardHTML must call resolveAlternatives
|
| 420 |
+
self.assertIn('resolveAlternatives(suggestion)', content)
|
| 421 |
+
|
| 422 |
+
def test_editor_uses_resolve_alternatives(self):
|
| 423 |
+
"""editor.js tooltip must use resolveAlternatives or equivalent fallback."""
|
| 424 |
+
editor_path = os.path.join(os.path.dirname(__file__), '..', 'src', 'js', 'editor.js')
|
| 425 |
+
with open(editor_path, 'r', encoding='utf-8') as f:
|
| 426 |
+
content = f.read()
|
| 427 |
+
# Must NOT have the old broken pattern
|
| 428 |
+
self.assertNotIn(
|
| 429 |
+
"suggestion.alternatives || [suggestion.correction",
|
| 430 |
+
content,
|
| 431 |
+
"Old broken fallback pattern still present — empty array [] is truthy"
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 436 |
+
# QA Fix 2: corrected field = apply(original, suggestions)
|
| 437 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 438 |
+
class TestCorrectedFieldConsistency(unittest.TestCase):
|
| 439 |
+
def test_apply_patches_equals_corrected(self):
|
| 440 |
+
"""Applying all patches to original must produce the corrected text."""
|
| 441 |
+
ctx = PipelineContext("المدرسه يعملوا هناك")
|
| 442 |
+
|
| 443 |
+
# Simulate: spelling corrects المدرسه → المدرسة at [0:7]
|
| 444 |
+
ctx.add_patch('spelling', 0, 7, 'المدرسة', confidence=0.9)
|
| 445 |
+
ctx.mutate_text("المدرسة يعملوا هناك", OffsetMapper)
|
| 446 |
+
|
| 447 |
+
suggestions = ctx.patches.to_list()
|
| 448 |
+
|
| 449 |
+
# Rebuild corrected from original + patches (same logic as app.py Fix 2)
|
| 450 |
+
result = ctx.original_text
|
| 451 |
+
for s in sorted(suggestions, key=lambda x: -x['start']):
|
| 452 |
+
result = result[:s['start']] + s['correction'] + result[s['end']:]
|
| 453 |
+
|
| 454 |
+
# Verify consistency: original[start:end] == suggestion['original']
|
| 455 |
+
for s in suggestions:
|
| 456 |
+
actual = ctx.original_text[s['start']:s['end']]
|
| 457 |
+
self.assertEqual(actual, s['original'],
|
| 458 |
+
f"Coordinate mismatch: original[{s['start']}:{s['end']}] = '{actual}' != '{s['original']}'")
|
| 459 |
+
|
| 460 |
+
# Verify corrected text is what we expect
|
| 461 |
+
self.assertEqual(result, "المدرسة يعملوا هناك")
|
| 462 |
+
|
| 463 |
+
def test_multi_stage_corrected_consistency(self):
|
| 464 |
+
"""Multiple stages: corrected = apply(original, all_patches)."""
|
| 465 |
+
ctx = PipelineContext("المدرسه هنا")
|
| 466 |
+
|
| 467 |
+
# Stage 1: Spelling corrects المدرسه → المدرسة (CURRENT coords [0:7])
|
| 468 |
+
ctx.add_patch('spelling', 0, 7, 'المدرسة', confidence=0.9)
|
| 469 |
+
ctx.mutate_text("المدرسة هنا", OffsetMapper)
|
| 470 |
+
|
| 471 |
+
# Stage 2: Punctuation adds ! after هنا
|
| 472 |
+
# CURRENT text = "المدرسة هنا", so هنا is at [8:11] in CURRENT coords
|
| 473 |
+
ctx.add_patch('punctuation', 8, 11, 'هنا!', confidence=0.8)
|
| 474 |
+
|
| 475 |
+
suggestions = ctx.patches.to_list()
|
| 476 |
+
result = "المدرسه هنا" # original
|
| 477 |
+
for s in sorted(suggestions, key=lambda x: -x['start']):
|
| 478 |
+
result = result[:s['start']] + s['correction'] + result[s['end']:]
|
| 479 |
+
|
| 480 |
+
# Should contain both corrections
|
| 481 |
+
self.assertIn('المدرسة', result)
|
| 482 |
+
self.assertIn('هنا!', result)
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 486 |
+
# QA Fix 3: HTML/malformed input sanitization
|
| 487 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 488 |
+
class TestInputSanitization(unittest.TestCase):
|
| 489 |
+
def test_html_tags_stripped(self):
|
| 490 |
+
"""app.py must strip HTML tags from input."""
|
| 491 |
+
import re
|
| 492 |
+
test_input = '<script>alert(1)</script>'
|
| 493 |
+
sanitized = re.sub(r'<[^>]*>', '', test_input).strip()
|
| 494 |
+
self.assertEqual(sanitized, 'alert(1)')
|
| 495 |
+
|
| 496 |
+
def test_pure_html_becomes_empty(self):
|
| 497 |
+
"""Pure HTML tags with no text content should be stripped to empty."""
|
| 498 |
+
import re
|
| 499 |
+
test_input = '<div><p></p></div>'
|
| 500 |
+
sanitized = re.sub(r'<[^>]*>', '', test_input).strip()
|
| 501 |
+
self.assertEqual(sanitized, '')
|
| 502 |
+
|
| 503 |
+
def test_arabic_ratio_check(self):
|
| 504 |
+
"""Non-Arabic inputs should be detected by ratio check."""
|
| 505 |
+
import re
|
| 506 |
+
# Predominantly English/code
|
| 507 |
+
text = 'function hello() { return 42; }'
|
| 508 |
+
arabic_chars = len(re.findall(r'[\u0600-\u06FF]', text))
|
| 509 |
+
alpha_chars = len(re.findall(r'[a-zA-Z\u0600-\u06FF]', text))
|
| 510 |
+
ratio = arabic_chars / alpha_chars if alpha_chars > 0 else 0
|
| 511 |
+
self.assertLess(ratio, 0.3, "Pure English should fail Arabic ratio check")
|
| 512 |
+
|
| 513 |
+
# Predominantly Arabic
|
| 514 |
+
text2 = 'السلام عليكم hello'
|
| 515 |
+
arabic_chars2 = len(re.findall(r'[\u0600-\u06FF]', text2))
|
| 516 |
+
alpha_chars2 = len(re.findall(r'[a-zA-Z\u0600-\u06FF]', text2))
|
| 517 |
+
ratio2 = arabic_chars2 / alpha_chars2 if alpha_chars2 > 0 else 0
|
| 518 |
+
self.assertGreaterEqual(ratio2, 0.3, "Mostly Arabic should pass ratio check")
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 522 |
+
# QA Fix 4: Aggregate punctuation cap (max 3 per response)
|
| 523 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 524 |
+
class TestAggregatePunctuationCap(unittest.TestCase):
|
| 525 |
+
def test_excess_punctuation_patches_capped(self):
|
| 526 |
+
"""More than 3 punctuation patches should be capped to 3."""
|
| 527 |
+
ctx = PipelineContext("كلمة أولى ثم ثانية ثم ثالثة ثم رابعة ثم خامسة")
|
| 528 |
+
|
| 529 |
+
# Add 5 punctuation patches
|
| 530 |
+
for i in range(5):
|
| 531 |
+
ctx.add_patch('punctuation', i * 8, i * 8 + 4,
|
| 532 |
+
f'word{i}!', confidence=0.8)
|
| 533 |
+
|
| 534 |
+
# Simulate the cap logic from app.py
|
| 535 |
+
MAX_PUNC_PATCHES_PER_RESPONSE = 3
|
| 536 |
+
punc_patches = [p for p in ctx.patches.patches if p.stage == 'punctuation']
|
| 537 |
+
if len(punc_patches) > MAX_PUNC_PATCHES_PER_RESPONSE:
|
| 538 |
+
punc_patches_sorted = sorted(punc_patches, key=lambda p: p.start_original)
|
| 539 |
+
to_remove = set(id(p) for p in punc_patches_sorted[MAX_PUNC_PATCHES_PER_RESPONSE:])
|
| 540 |
+
ctx.patches.patches = [p for p in ctx.patches.patches if id(p) not in to_remove]
|
| 541 |
+
|
| 542 |
+
punc_remaining = [p for p in ctx.patches.patches if p.stage == 'punctuation']
|
| 543 |
+
self.assertLessEqual(len(punc_remaining), MAX_PUNC_PATCHES_PER_RESPONSE)
|
| 544 |
+
|
| 545 |
+
def test_cap_preserves_earliest_patches(self):
|
| 546 |
+
"""The cap should keep earliest patches (lowest start_original)."""
|
| 547 |
+
ctx = PipelineContext("a b c d e f g h")
|
| 548 |
+
|
| 549 |
+
# Add 4 patches at positions 0, 10, 20, 30
|
| 550 |
+
positions = [0, 10, 20, 30]
|
| 551 |
+
for pos in positions:
|
| 552 |
+
ctx.add_patch('punctuation', pos, pos + 3, 'fix!', confidence=0.8)
|
| 553 |
+
|
| 554 |
+
MAX_PUNC_PATCHES_PER_RESPONSE = 3
|
| 555 |
+
punc_patches = [p for p in ctx.patches.patches if p.stage == 'punctuation']
|
| 556 |
+
punc_patches_sorted = sorted(punc_patches, key=lambda p: p.start_original)
|
| 557 |
+
to_remove = set(id(p) for p in punc_patches_sorted[MAX_PUNC_PATCHES_PER_RESPONSE:])
|
| 558 |
+
ctx.patches.patches = [p for p in ctx.patches.patches if id(p) not in to_remove]
|
| 559 |
+
|
| 560 |
+
remaining = ctx.patches.patches
|
| 561 |
+
# The patch at position 30 should be dropped (it's the 4th, earliest-last)
|
| 562 |
+
remaining_starts = sorted([p.start_original for p in remaining])
|
| 563 |
+
self.assertEqual(remaining_starts, [0, 10, 20])
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 567 |
+
# QA Fix 6: applyAllSuggestions reverse sort documented + tested
|
| 568 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 569 |
+
class TestApplyAllReverseSort(unittest.TestCase):
|
| 570 |
+
def test_reverse_sort_comment_exists(self):
|
| 571 |
+
"""editor.js must document why reverse sort is required in applyAllSuggestions."""
|
| 572 |
+
editor_path = os.path.join(os.path.dirname(__file__), '..', 'src', 'js', 'editor.js')
|
| 573 |
+
with open(editor_path, 'r', encoding='utf-8') as f:
|
| 574 |
+
content = f.read()
|
| 575 |
+
self.assertIn('CRITICAL: Sort in REVERSE order', content)
|
| 576 |
+
self.assertIn('back-to-front', content)
|
| 577 |
+
|
| 578 |
+
def test_apply_patches_reverse_order_correctness(self):
|
| 579 |
+
"""Applying patches in reverse order must produce correct result."""
|
| 580 |
+
original = "الطلاب ذهب الي المدرسه"
|
| 581 |
+
suggestions = [
|
| 582 |
+
{'start': 0, 'end': 6, 'original': 'الطلاب', 'correction': 'الطلّاب'},
|
| 583 |
+
{'start': 7, 'end': 10, 'original': 'ذهب', 'correction': 'ذهبوا'},
|
| 584 |
+
{'start': 11, 'end': 14, 'original': 'الي', 'correction': 'إلى'},
|
| 585 |
+
{'start': 15, 'end': 22, 'original': 'المدرسه', 'correction': 'المدرسة'},
|
| 586 |
+
]
|
| 587 |
+
|
| 588 |
+
# Apply in reverse order (same as applyAllSuggestions)
|
| 589 |
+
result = original
|
| 590 |
+
for s in sorted(suggestions, key=lambda x: -x['start']):
|
| 591 |
+
result = result[:s['start']] + s['correction'] + result[s['end']:]
|
| 592 |
+
|
| 593 |
+
self.assertEqual(result, "الطلّاب ذهبوا إلى المدرسة")
|
| 594 |
+
|
| 595 |
+
def test_forward_order_would_corrupt(self):
|
| 596 |
+
"""Applying patches in forward order would produce wrong result when lengths differ."""
|
| 597 |
+
original = "ab cd ef"
|
| 598 |
+
suggestions = [
|
| 599 |
+
{'start': 0, 'end': 2, 'original': 'ab', 'correction': 'abc'}, # length change!
|
| 600 |
+
{'start': 3, 'end': 5, 'original': 'cd', 'correction': 'cde'},
|
| 601 |
+
]
|
| 602 |
+
|
| 603 |
+
# Forward (WRONG — offsets shift after first edit)
|
| 604 |
+
forward_result = original
|
| 605 |
+
for s in sorted(suggestions, key=lambda x: x['start']):
|
| 606 |
+
forward_result = forward_result[:s['start']] + s['correction'] + forward_result[s['end']:]
|
| 607 |
+
|
| 608 |
+
# Reverse (CORRECT)
|
| 609 |
+
reverse_result = original
|
| 610 |
+
for s in sorted(suggestions, key=lambda x: -x['start']):
|
| 611 |
+
reverse_result = reverse_result[:s['start']] + s['correction'] + reverse_result[s['end']:]
|
| 612 |
+
|
| 613 |
+
self.assertEqual(reverse_result, "abc cde ef")
|
| 614 |
+
# Forward produces wrong result because offsets shift
|
| 615 |
+
self.assertNotEqual(forward_result, reverse_result)
|
| 616 |
+
|
| 617 |
+
|
| 618 |
if __name__ == '__main__':
|
| 619 |
unittest.main()
|