File size: 26,779 Bytes
241a123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d002eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241a123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
"""报错文案映射服务测试。

覆盖三块:
1. 关键词归一化与规则归一化(脏数据要能安全丢弃 / 去重);
2. translate 的命中优先级(命中即替换、未命中记待办并保留原文);
3. 未配置报错的收集、去重计数、清空与上限淘汰。
"""
import json
import tempfile
import unittest
from pathlib import Path


class KeywordNormalizationTests(unittest.TestCase):
    def test_accepts_list_and_separator_strings(self) -> None:
        from services.error_message_service import _normalize_keywords

        self.assertEqual(_normalize_keywords(["a1", "b2"]), ["a1", "b2"])
        # 换行 / 逗号 / 中文逗号 / 顿号都应被拆开
        self.assertEqual(_normalize_keywords("a1\nb2,c3,d4、e5"), ["a1", "b2", "c3", "d4", "e5"])

    def test_drops_short_and_duplicate_keywords(self) -> None:
        from services.error_message_service import _normalize_keywords

        # 短于 MIN_KEYWORD_CHARS 的关键词会误伤正常报错,必须丢掉;重复项按小写去重
        self.assertEqual(_normalize_keywords(["a", "ab", "AB", "cd", ""]), ["ab", "cd"])

    def test_ignores_non_string_input(self) -> None:
        from services.error_message_service import _normalize_keywords

        self.assertEqual(_normalize_keywords(None), [])
        self.assertEqual(_normalize_keywords(123), [])


class RuleNormalizationTests(unittest.TestCase):
    def test_drops_rules_missing_keywords_or_display(self) -> None:
        from services.error_message_service import normalize_rules

        rules = normalize_rules([
            {"keywords": ["throttled"], "display": "太挤了"},
            {"keywords": [], "display": "没关键词"},
            {"keywords": ["x1"], "display": ""},
            "not-a-dict",
        ])
        self.assertEqual(len(rules), 1)
        self.assertEqual(rules[0]["keywords"], ["throttled"])

    def test_enabled_defaults_and_string_coercion(self) -> None:
        from services.error_message_service import normalize_rules

        rules = normalize_rules([
            {"id": "r1", "keywords": ["aa"], "display": "x"},
            {"id": "r2", "keywords": ["bb"], "display": "y", "enabled": "false"},
            {"id": "r3", "keywords": ["cc"], "display": "z", "enabled": "yes"},
        ])
        self.assertTrue(rules[0]["enabled"])
        self.assertFalse(rules[1]["enabled"])
        self.assertTrue(rules[2]["enabled"])

    def test_duplicate_ids_get_suffixed(self) -> None:
        from services.error_message_service import normalize_rules

        rules = normalize_rules([
            {"id": "dup", "keywords": ["aa"], "display": "x"},
            {"id": "dup", "keywords": ["bb"], "display": "y"},
        ])
        self.assertEqual([rule["id"] for rule in rules], ["dup", "dup-2"])

    def test_missing_id_gets_fallback(self) -> None:
        from services.error_message_service import normalize_rules

        rules = normalize_rules([{"keywords": ["aa"], "display": "x"}])
        self.assertEqual(rules[0]["id"], "rule-1")


class TranslateTests(unittest.TestCase):
    """translate 的命中 / 未命中行为。用临时文件隔离未配置报错的写入。"""

    def setUp(self) -> None:
        self._tmp = tempfile.TemporaryDirectory()
        self._store = Path(self._tmp.name) / "unconfigured.json"

    def tearDown(self) -> None:
        self._tmp.cleanup()

    def _service(self, rules):
        from services.error_message_service import ErrorMessageService

        service = ErrorMessageService(store_path=self._store)
        service._rules = lambda: __import__(
            "services.error_message_service", fromlist=["normalize_rules"]
        ).normalize_rules(rules)
        return service

    def test_hit_returns_display_text(self) -> None:
        service = self._service([{"keywords": ["upload_protection_limit_exceeded"], "display": "上传额度已满"}])
        raw = '/backend-api/files failed: status=429, body={"reason": "upload_protection_limit_exceeded"}'
        self.assertEqual(service.translate(raw), "上传额度已满")

    def test_match_is_case_insensitive(self) -> None:
        service = self._service([{"keywords": ["Throttled"], "display": "太挤了"}])
        self.assertEqual(service.translate("upstream THROTTLED now"), "太挤了")

    def test_disabled_rule_is_skipped(self) -> None:
        service = self._service([{"keywords": ["throttled"], "display": "太挤了", "enabled": False}])
        self.assertEqual(service.translate("throttled"), "throttled")

    def test_miss_keeps_original_and_records_todo(self) -> None:
        service = self._service([{"keywords": ["throttled"], "display": "太挤了"}])
        raw = "upstream connect failed: 由于目标计算机积极拒绝"
        self.assertEqual(service.translate(raw, channel="api"), raw)

        items = service.list_unconfigured()
        self.assertEqual(len(items), 1)
        self.assertEqual(items[0]["channel"], "api")
        self.assertEqual(items[0]["count"], 1)

    def test_empty_message_is_ignored(self) -> None:
        service = self._service([])
        self.assertEqual(service.translate(""), "")
        self.assertEqual(service.translate(None), "")
        self.assertEqual(service.list_unconfigured(), [])

    def test_translate_detail_only_touches_text_fields(self) -> None:
        service = self._service([{"keywords": ["throttled"], "display": "太挤了"}])
        detail = {"code": "throttled", "message": "throttled for you", "type": "throttled"}
        result = service.translate_detail(detail)
        self.assertEqual(result["message"], "太挤了")
        # code / type 是程序判断用的,不能被替换
        self.assertEqual(result["code"], "throttled")
        self.assertEqual(result["type"], "throttled")


class FallbackDisplayTests(unittest.TestCase):
    """未命中规则时的「兜底成通用文案」开关(后台可配,默认关)。

    后台那个勾选框写着「勾选后未命中规则的报错前台统一显示『操作失败,请稍后重试』」,
    所以这里把开关的两个状态都钉住:关 = 原样显示(便于补规则),开 = 通用文案。
    """

    def setUp(self) -> None:
        self._tmp = tempfile.TemporaryDirectory()
        self._store = Path(self._tmp.name) / "unconfigured.json"

    def tearDown(self) -> None:
        self._tmp.cleanup()

    def _service(self, rules, *, fallback: bool):
        from services.error_message_service import ErrorMessageService, normalize_rules

        service = ErrorMessageService(store_path=self._store)
        service._rules = lambda: normalize_rules(rules)
        # 用实例属性覆盖静态方法,避免依赖真实配置(测试进程里 config 的共享设置不可控)。
        service.fallback_enabled = lambda: fallback
        return service

    def test_unmatched_keeps_raw_text_when_disabled(self) -> None:
        service = self._service([], fallback=False)
        raw = "upstream connect failed: 由于目标计算机积极拒绝"
        self.assertEqual(service.translate(raw, channel="image_task"), raw)

    def test_unmatched_uses_generic_copy_when_enabled(self) -> None:
        from services.error_message_service import FALLBACK_DISPLAY

        service = self._service([], fallback=True)
        self.assertEqual(service.translate("upstream boom"), FALLBACK_DISPLAY)
        # ⚠️ 兜底只换前台文案:原文照样进「未配置报错」台账,管理员还能照着补规则。
        items = service.list_unconfigured()
        self.assertEqual(len(items), 1)
        self.assertIn("upstream boom", items[0]["sample"])

    def test_matched_rule_wins_over_fallback(self) -> None:
        service = self._service([{"keywords": ["throttled"], "display": "太挤了"}], fallback=True)
        self.assertEqual(service.translate("upstream THROTTLED now"), "太挤了")

    def test_disabled_rule_under_fallback_falls_back(self) -> None:
        """停用的规则不算命中:开着兜底时不能因为它而漏出技术报错。"""
        from services.error_message_service import FALLBACK_DISPLAY

        service = self._service(
            [{"keywords": ["throttled"], "display": "太挤了", "enabled": False}],
            fallback=True,
        )
        self.assertEqual(service.translate("throttled"), FALLBACK_DISPLAY)

    def test_empty_message_stays_empty(self) -> None:
        service = self._service([], fallback=True)
        self.assertEqual(service.translate(""), "")
        self.assertEqual(service.translate(None), "")

    def test_flag_reads_from_config(self) -> None:
        from unittest.mock import patch

        from services.config import config
        from services.error_message_service import ErrorMessageService

        with patch.object(type(config), "error_message_fallback_enabled", property(lambda self: True)):
            self.assertTrue(ErrorMessageService.fallback_enabled())
        with patch.object(type(config), "error_message_fallback_enabled", property(lambda self: False)):
            self.assertFalse(ErrorMessageService.fallback_enabled())


class UnconfiguredCollectionTests(unittest.TestCase):
    def setUp(self) -> None:
        self._tmp = tempfile.TemporaryDirectory()
        self._store = Path(self._tmp.name) / "unconfigured.json"

    def tearDown(self) -> None:
        self._tmp.cleanup()

    def _service(self):
        from services.error_message_service import ErrorMessageService

        service = ErrorMessageService(store_path=self._store)
        service._rules = lambda: []
        return service

    def test_same_shape_errors_share_one_fingerprint(self) -> None:
        """同一类报错每次 uuid / 数字不同,不应各记一条。"""
        service = self._service()
        service.translate("/api/x failed id=550e8400-e29b-41d4-a716-446655440000 code=429")
        service.translate("/api/x failed id=6ba7b810-9dad-11d1-80b4-00c04fd430c8 code=500")

        items = service.list_unconfigured()
        self.assertEqual(len(items), 1)
        self.assertEqual(items[0]["count"], 2)

    def test_list_sorted_by_count_desc(self) -> None:
        service = self._service()
        service.translate("alpha error occurred")
        service.translate("beta error occurred")
        service.translate("beta error occurred")
        service.translate("beta error occurred")

        items = service.list_unconfigured()
        self.assertEqual(items[0]["count"], 3)
        self.assertEqual(items[1]["count"], 1)

    def test_list_includes_keyword_candidates(self) -> None:
        """待办列表要带上可直接用的候选关键词,省得管理员手抠。"""
        service = self._service()
        service.translate('beta failed: status=429, body={"reason": "some_specific_reason"}')

        items = service.list_unconfigured()
        self.assertEqual(len(items), 1)
        self.assertIn("some_specific_reason", items[0]["keywords"])
        self.assertIn("status=429", items[0]["keywords"])

    def test_persists_across_instances(self) -> None:
        from services.error_message_service import ErrorMessageService

        service = self._service()
        service.translate("some unique error text")

        reloaded = ErrorMessageService(store_path=self._store)
        reloaded._rules = lambda: []
        self.assertEqual(len(reloaded.list_unconfigured()), 1)

    def test_clear_selected_and_all(self) -> None:
        service = self._service()
        service.translate("alpha error occurred")
        service.translate("beta error occurred")
        items = service.list_unconfigured()
        target = items[0]["fingerprint"]

        self.assertEqual(service.clear_unconfigured([target]), 1)
        self.assertEqual(len(service.list_unconfigured()), 1)

        self.assertEqual(service.clear_unconfigured(), 1)
        self.assertEqual(service.list_unconfigured(), [])

    def test_entry_cap_prunes_oldest(self) -> None:
        from services import error_message_service as module

        service = self._service()
        original_cap = module._MAX_UNCONFIGURED_ENTRIES
        module._MAX_UNCONFIGURED_ENTRIES = 10
        try:
            for index in range(30):
                service.translate(f"unique error number {index:04d} text")
            self.assertLessEqual(len(service.list_unconfigured()), 10)
        finally:
            module._MAX_UNCONFIGURED_ENTRIES = original_cap

    def test_corrupt_store_is_tolerated(self) -> None:
        self._store.write_text("{ this is not json", encoding="utf-8")
        service = self._service()
        # 坏文件不能让请求链路崩,且能继续写入
        service.translate("brand new error text")
        self.assertEqual(len(service.list_unconfigured()), 1)

    def test_sample_is_truncated(self) -> None:
        from services import error_message_service as module

        service = self._service()
        service.translate("x" * (module._MAX_SAMPLE_CHARS + 500))
        items = service.list_unconfigured()
        self.assertEqual(len(items[0]["sample"]), module._MAX_SAMPLE_CHARS)


class KeywordCandidateExtractionTests(unittest.TestCase):
    """本地抽取:AI 不可用时的兜底,必须能捞出高辨识度片段。"""

    def test_extracts_from_real_upload_throttle_payload(self) -> None:
        from services.error_message_service import extract_keyword_candidates

        raw = (
            '/backend-api/files failed: status=429, body={"detail": {"code": "throttled", '
            '"error_code": "throttled", "message": "你已达到文件上传上限。请4小时 内重试。", '
            '"type": "throttled", "reason": "upload_protection_limit_exceeded"}}'
        )
        candidates = extract_keyword_candidates(raw)
        # 最精确的标识符排在最前
        self.assertEqual(candidates[0], "upload_protection_limit_exceeded")
        self.assertIn("/backend-api/files", candidates)
        self.assertIn("status=429", candidates)

    def test_skips_json_key_names_and_generic_words(self) -> None:
        from services.error_message_service import extract_keyword_candidates

        raw = '{"error_code": "abc12345", "message": "failed", "reason": "xyz98765"}'
        candidates = extract_keyword_candidates(raw)
        # 字段名本身不参与匹配,否则等于没筛
        for noise in ("error_code", "message", "reason", "failed", "status"):
            self.assertNotIn(noise, candidates)

    def test_extracts_curl_noise(self) -> None:
        from services.error_message_service import extract_keyword_candidates

        self.assertIn("curl: (16)", extract_keyword_candidates("upstream curl: (16) recv failure"))

    def test_deduplicates_and_handles_empty(self) -> None:
        from services.error_message_service import extract_keyword_candidates

        self.assertEqual(extract_keyword_candidates(""), [])
        self.assertEqual(extract_keyword_candidates(None), [])
        repeated = extract_keyword_candidates("status=429 then again status=429")
        self.assertEqual(repeated.count("status=429"), 1)

    def test_no_json_syntax_fragments_in_candidates(self) -> None:
        """候选词里不能出现引号/冒号/花括号 —— 那是报文语法,不是错误标识。"""
        from services.error_message_service import extract_keyword_candidates

        raw = (
            '/backend-api/files failed: status=429, body={"detail": {"code": "throttled", '
            '"reason": "upload_protection_limit_exceeded"}}'
        )
        candidates = extract_keyword_candidates(raw)
        # 注意不能一律禁冒号:`curl: (16)` 这种是合法关键词,只禁 JSON 的 `": "` 片段。
        for candidate in candidates:
            for noise in ('"', "'", "{", "}", '": "'):
                self.assertNotIn(noise, candidate, msg=f"{candidate!r} 里混进了 JSON 语法碎片")


class ReportedUploadErrorTests(unittest.TestCase):
    """用户实际上报的 /uploaded 500 报错:从「粘贴原文」到「规则真能命中」必须闭环。

    这条报错是多模态里最常见的 500(file_id 每次不同),用来守住:
    1. 本地抽取能捞出高辨识度片段;
    2. 模型编造的关键词会被丢弃;
    3. 归纳出来的规则确实能命中原始报错(子串匹配)。
    """

    RAW = (
        "/backend-api/files/file_000000006bcc81f5b6f6f32ff45d7f55/uploaded failed: status=500, "
        'body={"detail": {"code": "internal_error", "error_code": "internal_error"}}'
    )

    def setUp(self) -> None:
        self._tmp = tempfile.TemporaryDirectory()
        self._store = Path(self._tmp.name) / "unconfigured.json"

    def tearDown(self) -> None:
        self._tmp.cleanup()

    def test_extracts_usable_candidates(self) -> None:
        from services.error_message_service import extract_keyword_candidates

        candidates = extract_keyword_candidates(self.RAW)
        self.assertIn("/backend-api/files", candidates)
        self.assertIn("status=500", candidates)
        self.assertIn("internal_error", candidates)

    def test_ai_suggestion_drops_hallucinated_keywords(self) -> None:
        from services.error_message_service import parse_ai_suggestion

        answer = (
            '{"keywords": ["status=500", "internal_error", "definitely_absent", "error_code"], '
            '"display": "文件上传失败,请稍后重试"}'
        )
        result = parse_ai_suggestion(answer, raw=self.RAW)
        self.assertEqual(result["display"], "文件上传失败,请稍后重试")
        self.assertIn("status=500", result["keywords"])
        self.assertIn("internal_error", result["keywords"])
        # 原文里没有的词永远命中不了;JSON 字段名会命中所有报错,两者都要剔。
        self.assertNotIn("definitely_absent", result["keywords"])
        self.assertNotIn("error_code", result["keywords"])

    def test_volatile_path_segments_are_trimmed(self) -> None:
        from services.error_message_service import _strip_volatile_path

        # file_id 每次都变,关键词必须停在它之前,否则规则只对这一次生效。
        self.assertEqual(
            _strip_volatile_path("/backend-api/files/file_000000006bcc81f5b6f6f32ff45d7f55/uploaded"),
            "/backend-api/files",
        )
        self.assertEqual(_strip_volatile_path("/backend-api/files"), "/backend-api/files")
        # 单段路径太泛,直接丢弃。
        self.assertEqual(_strip_volatile_path("/files"), "")

    def test_generated_rule_matches_the_reported_error(self) -> None:
        from services.error_message_service import ErrorMessageService, extract_keyword_candidates

        service = ErrorMessageService(store_path=self._store)
        service._rules = lambda: __import__(
            "services.error_message_service", fromlist=["normalize_rules"]
        ).normalize_rules(
            [
                {
                    "keywords": extract_keyword_candidates(self.RAW),
                    "display": "文件上传失败,请稍后重试",
                }
            ]
        )
        # file_id 每次都不同,命中靠的是路径前缀 + 状态码,不是整条原文。
        other = (
            "/backend-api/files/file_00000000aaaaaaaaaaaaaaaaaaaaaaaa/uploaded failed: status=500, "
            'body={"detail": {"code": "internal_error"}}'
        )
        self.assertEqual(service.translate(self.RAW), "文件上传失败,请稍后重试")
        self.assertEqual(service.translate(other), "文件上传失败,请稍后重试")

    # 429 上传限流:和上面那条 500 报错共用 /backend-api/files 前缀。
    THROTTLE = (
        '/backend-api/files failed: status=429, body={"detail": {"code": "throttled", '
        '"error_code": "throttled", "reason": "upload_protection_limit_exceeded"}}'
    )

    def _service_with(self, rules):
        from services.error_message_service import ErrorMessageService

        service = ErrorMessageService(store_path=self._store)
        service._rules = lambda: __import__(
            "services.error_message_service", fromlist=["normalize_rules"]
        ).normalize_rules(rules)
        return service

    def test_upload_500_rule_does_not_swallow_throttle_error(self) -> None:
        """两条规则必须正交:500 规则不能吞掉 429 限流,反之亦然。

        匹配是「任一关键词命中」,所以关键词选宽一点就会让两条规则互相覆盖。
        这是「结合本项目」的核心:路径必须带上区分步骤的片段。
        """
        service = self._service_with(
            [
                {"keywords": ["internal_error", "/uploaded failed: status=500"], "display": "文件上传失败,请重试"},
                {"keywords": ["upload_protection_limit_exceeded", "你已达到文件上传上限"], "display": "文件上传次数已达上限"},
            ]
        )
        self.assertEqual(service.translate(self.RAW), "文件上传失败,请重试")
        self.assertEqual(service.translate(self.THROTTLE), "文件上传次数已达上限")

    def test_broad_path_prefix_swallows_other_errors(self) -> None:
        """反面用例:只有裸前缀 /backend-api/files 时,500 规则会吞掉 429 限流报错。

        这条用例存在的意义是防止有人把提示词改回「只写接口路径前缀」——
        那样前台会把「配额用满」误报成「服务内部错误」,用户会一直重试而不是等换号。
        """
        service = self._service_with(
            [{"keywords": ["/backend-api/files"], "display": "服务器内部错误,请稍后重试"}]
        )
        self.assertEqual(service.translate(self.RAW), "服务器内部错误,请稍后重试")
        # 这就是误伤:限流报错也被换成了 500 的文案。
        self.assertEqual(service.translate(self.THROTTLE), "服务器内部错误,请稍后重试")


class AiSuggestionParsingTests(unittest.TestCase):
    def test_parses_plain_json(self) -> None:
        from services.error_message_service import parse_ai_suggestion

        result = parse_ai_suggestion('{"keywords": ["aa", "bb"], "display": "出错了"}')
        self.assertEqual(result["keywords"], ["aa", "bb"])
        self.assertEqual(result["display"], "出错了")

    def test_strips_markdown_code_fence(self) -> None:
        from services.error_message_service import parse_ai_suggestion

        result = parse_ai_suggestion('```json\n{"keywords": ["aa"], "display": "出错了"}\n```')
        self.assertEqual(result["keywords"], ["aa"])

    def test_tolerates_surrounding_prose(self) -> None:
        from services.error_message_service import parse_ai_suggestion

        result = parse_ai_suggestion('好的,结果如下:\n{"keywords": ["aa"], "display": "出错了"}\n希望有帮助')
        self.assertEqual(result["display"], "出错了")

    def test_drops_keywords_absent_from_raw(self) -> None:
        """关键在原文里必须真实出现,否则规则永远命中不了。"""
        from services.error_message_service import parse_ai_suggestion

        result = parse_ai_suggestion(
            '{"keywords": ["upload_protection_limit_exceeded", "hallucinated_token"], "display": "太挤了"}',
            raw="/backend-api/files failed: upload_protection_limit_exceeded",
        )
        self.assertEqual(result["keywords"], ["upload_protection_limit_exceeded"])

    def test_bad_output_returns_empty(self) -> None:
        from services.error_message_service import parse_ai_suggestion

        for bad in ("", None, "抱歉我无法处理", "[1,2,3]", "{ not json"):
            self.assertEqual(parse_ai_suggestion(bad, raw="x")["keywords"], [])
            self.assertEqual(parse_ai_suggestion(bad, raw="x")["display"], "")

    def test_prompt_forbids_generic_keywords(self) -> None:
        from services.error_message_service import ERROR_SUMMARY_SYSTEM_PROMPT

        for word in ("error", "failed", "exception"):
            self.assertIn(word, ERROR_SUMMARY_SYSTEM_PROMPT)

    def test_sanitizes_json_fragment_keywords(self) -> None:
        """模型会把 `reason": "upload_xxx` 这种截断片段整条当关键词。

        它虽然能把当前这条报错匹配上,但只要上游 JSON 的字段顺序或空格一变
        就失效;归纳链路必须只保留「值」本身。
        """
        from services.error_message_service import parse_ai_suggestion

        raw = '/backend-api/files failed: status=429, body={"detail": {"code": "throttled", "reason": "upload_protection_limit_exceeded"}}'
        result = parse_ai_suggestion(
            json.dumps({
                "keywords": ['reason": "upload_protection_limit_exceeded', "status=429"],
                "display": "上传频率过高,请稍后再试",
            }),
            raw=raw,
        )
        self.assertEqual(result["keywords"], ["upload_protection_limit_exceeded", "status=429"])

    def test_prompt_carries_project_context(self) -> None:
        """归纳要「结合本项目」:必须告诉模型匹配语义、报文格式和路径步骤要求。

        少了这些,模型会把 /backend-api/files 这种通用前缀当关键词;
        而匹配是「任一命中」,一条规则就会吞掉一大片语义完全不同的报错。
        """
        from services.error_message_service import ERROR_SUMMARY_SYSTEM_PROMPT

        self.assertIn("任意一个", ERROR_SUMMARY_SYSTEM_PROMPT)
        self.assertIn("failed: status=", ERROR_SUMMARY_SYSTEM_PROMPT)
        for step in ("/uploaded", "/download", "/library"):
            self.assertIn(step, ERROR_SUMMARY_SYSTEM_PROMPT)
        self.assertIn("file_id", ERROR_SUMMARY_SYSTEM_PROMPT)


class ConfigIntegrationTests(unittest.TestCase):
    def test_config_exposes_rule_helpers(self) -> None:
        """config 的归一化入口要能安全处理脏数据。"""
        from services.config import _normalize_error_message_rules

        self.assertEqual(_normalize_error_message_rules(None), [])
        self.assertEqual(_normalize_error_message_rules([{"keywords": [], "display": ""}]), [])
        result = _normalize_error_message_rules([{"keywords": ["aa"], "display": "bb"}])
        self.assertEqual(result[0]["keywords"], ["aa"])
        self.assertEqual(result[0]["display"], "bb")

    def test_shared_settings_keys_registered(self) -> None:
        from services.config import SHARED_SETTINGS_KEYS

        self.assertIn("error_message_rules", SHARED_SETTINGS_KEYS)
        self.assertIn("error_message_fallback_enabled", SHARED_SETTINGS_KEYS)


if __name__ == "__main__":
    unittest.main()