minimal-agent 0.8.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (484) hide show
  1. package/README.md +14 -12
  2. package/package.json +34 -1
  3. package/plugins/cron-runner/.claude-plugin/plugin.json +5 -0
  4. package/plugins/cron-runner/commands/cron.md +26 -0
  5. package/plugins/cron-runner/commands/crons.md +7 -0
  6. package/plugins/cron-runner/plugin.js +34 -0
  7. package/plugins/cron-runner/src/commands.js +348 -0
  8. package/plugins/cron-runner/src/cronTime.js +148 -0
  9. package/plugins/cron-runner/src/lock.js +68 -0
  10. package/plugins/cron-runner/src/runs.js +89 -0
  11. package/plugins/cron-runner/src/scheduleEngine.js +72 -0
  12. package/plugins/cron-runner/src/scheduler.js +355 -0
  13. package/plugins/cron-runner/src/schema.js +140 -0
  14. package/plugins/cron-runner/src/service.js +167 -0
  15. package/plugins/cron-runner/src/store.js +298 -0
  16. package/plugins/cron-runner/src/types.js +1 -0
  17. package/plugins/workflow-runner/commands/workflow.md +4 -4
  18. package/plugins/workflow-runner/commands/workflows.md +1 -1
  19. package/plugins/workflow-runner/plugin.js +3 -1
  20. package/plugins/workflow-runner/src/agentCommon.js +249 -51
  21. package/plugins/workflow-runner/src/dslManifest.js +72 -0
  22. package/plugins/workflow-runner/src/expressions.js +198 -15
  23. package/plugins/workflow-runner/src/index.js +40 -8
  24. package/plugins/workflow-runner/src/jsonPayload.js +1 -1
  25. package/plugins/workflow-runner/src/loader.js +286 -72
  26. package/plugins/workflow-runner/src/multimodal.js +37 -0
  27. package/plugins/workflow-runner/src/recovery.js +49 -39
  28. package/plugins/workflow-runner/src/runner.js +802 -287
  29. package/plugins/workflow-runner/src/stepExecutors/agent.js +157 -39
  30. package/plugins/workflow-runner/src/stepExecutors/assert.js +16 -3
  31. package/plugins/workflow-runner/src/stepExecutors/llm.js +60 -111
  32. package/plugins/workflow-runner/src/stepExecutors/skill.js +47 -47
  33. package/plugins/workflow-runner/src/stepExecutors/tool.js +76 -10
  34. package/plugins/workflow-runner/src/stepExecutors/verify.js +27 -10
  35. package/plugins/workflow-runner/src/taskIdentity.js +296 -0
  36. package/plugins/workflow-runner/src/types.js +19 -9
  37. package/plugins/workflow-runner/src/workflowState.js +516 -33
  38. package/skills/adversarial-review/SKILL.md +0 -1
  39. package/skills/ai-image/SKILL.md +89 -0
  40. package/skills/ai-image/scripts/generate.mjs +497 -0
  41. package/skills/ai-image/scripts/generate_from_plan.mjs +469 -0
  42. package/skills/ai-image/scripts/generate_from_plan_test.mjs +223 -0
  43. package/skills/ai-image/scripts/generate_test.mjs +74 -0
  44. package/skills/browser-automation/SKILL.md +47 -0
  45. package/skills/docx/SKILL.md +25 -3
  46. package/skills/docx/scripts/office/helpers/__pycache__/__init__.cpython-314.pyc +0 -0
  47. package/skills/docx/scripts/office/helpers/__pycache__/merge_runs.cpython-314.pyc +0 -0
  48. package/skills/docx/scripts/office/helpers/__pycache__/simplify_redlines.cpython-314.pyc +0 -0
  49. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-314.pyc +0 -0
  50. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-314.pyc +0 -0
  51. package/skills/docx/scripts/office/validators/__pycache__/docx.cpython-314.pyc +0 -0
  52. package/skills/docx/scripts/office/validators/__pycache__/pptx.cpython-314.pyc +0 -0
  53. package/skills/docx/scripts/office/validators/__pycache__/redlining.cpython-314.pyc +0 -0
  54. package/skills/docx/scripts/opc_check.py +253 -0
  55. package/skills/image-gen-openrouter/SKILL.md +10 -2
  56. package/skills/image-stats/SKILL.md +65 -0
  57. package/skills/image-stats/scripts/__pycache__/image_stats.cpython-314.pyc +0 -0
  58. package/skills/image-stats/scripts/__pycache__/image_stats_test.cpython-314.pyc +0 -0
  59. package/skills/image-stats/scripts/image_stats.py +368 -0
  60. package/skills/image-stats/scripts/image_stats_test.py +181 -0
  61. package/skills/pptx/SKILL.md +123 -95
  62. package/skills/pptx/design.md +82 -0
  63. package/skills/pptx/examples/README.md +29 -0
  64. package/skills/pptx/examples/hybrid_example.mjs +107 -0
  65. package/skills/pptx/examples/hybrid_example.pptx +0 -0
  66. package/skills/pptx/pptxgenjs.md +41 -0
  67. package/skills/pptx/scripts/deck_helpers.mjs +224 -0
  68. package/skills/pptx/scripts/layout_lint.py +293 -0
  69. package/skills/pptx/scripts/layout_lint_test.py +160 -0
  70. package/skills/pptx/scripts/opc_check.py +339 -0
  71. package/skills/pptx/scripts/svg_fallback.mjs +145 -0
  72. package/skills/pptx/svg.md +67 -0
  73. package/skills/tts-zh/SKILL.md +19 -2
  74. package/skills/ui-design/SKILL.md +158 -0
  75. package/skills/ui-design/claude_design.md +309 -0
  76. package/skills/ui-design/evals/evals.json +254 -0
  77. package/skills/ui-design/references/aesthetic-direction.md +83 -0
  78. package/skills/ui-design/references/claude-design-protocol.md +36 -0
  79. package/skills/ui-design/references/design-context.md +53 -0
  80. package/skills/ui-design/references/design-execution-playbook.md +82 -0
  81. package/skills/ui-design/references/export-pptx.md +124 -0
  82. package/skills/ui-design/references/layout-composition.md +654 -0
  83. package/skills/ui-design/references/preview-feedback.md +37 -0
  84. package/skills/ui-design/references/source-image-layout.md +189 -0
  85. package/skills/ui-design/references/starter-components.md +78 -0
  86. package/skills/ui-design/references/vi-aegis-vector.md +144 -0
  87. package/skills/ui-design/references/vi-amber-prism.md +133 -0
  88. package/skills/ui-design/references/vi-aqua-glass.md +200 -0
  89. package/skills/ui-design/references/vi-comfort-vision.md +147 -0
  90. package/skills/ui-design/references/vi-ember-prism.md +140 -0
  91. package/skills/ui-design/references/vi-nebula-console.md +151 -0
  92. package/skills/ui-design/references/vi-obsidian-optics.md +139 -0
  93. package/skills/ui-design/references/vi-obsidian-xui.md +143 -0
  94. package/skills/ui-design/references/vi-precision-horizon-dark.md +117 -0
  95. package/skills/ui-design/references/vi-precision-horizon.md +215 -0
  96. package/skills/ui-design/references/vi-solar-relay.md +130 -0
  97. package/skills/ui-design/references/vi-template-workflow.md +48 -0
  98. package/skills/ui-design/references/vi-verdant-grid.md +141 -0
  99. package/skills/ui-design/references/vi-violet-meridian.md +172 -0
  100. package/skills/ui-design/scripts/__pycache__/assemble_material.cpython-314.pyc +0 -0
  101. package/skills/ui-design/scripts/__pycache__/assemble_material_test.cpython-314.pyc +0 -0
  102. package/skills/ui-design/scripts/__pycache__/chart_sanity_check.cpython-314.pyc +0 -0
  103. package/skills/ui-design/scripts/__pycache__/check_language_policy.cpython-314.pyc +0 -0
  104. package/skills/ui-design/scripts/__pycache__/check_ui_artifact.cpython-314.pyc +0 -0
  105. package/skills/ui-design/scripts/__pycache__/check_ui_artifact_pptx_test.cpython-314.pyc +0 -0
  106. package/skills/ui-design/scripts/__pycache__/deck_pages_check.cpython-314.pyc +0 -0
  107. package/skills/ui-design/scripts/__pycache__/deck_pages_check_test.cpython-314.pyc +0 -0
  108. package/skills/ui-design/scripts/__pycache__/deck_visual_style_check.cpython-314.pyc +0 -0
  109. package/skills/ui-design/scripts/__pycache__/deck_visual_style_check_test.cpython-314.pyc +0 -0
  110. package/skills/ui-design/scripts/__pycache__/design_budget_check.cpython-314.pyc +0 -0
  111. package/skills/ui-design/scripts/__pycache__/design_budget_check_test.cpython-314.pyc +0 -0
  112. package/skills/ui-design/scripts/__pycache__/design_repair_hints.cpython-314.pyc +0 -0
  113. package/skills/ui-design/scripts/__pycache__/design_repair_hints_test.cpython-314.pyc +0 -0
  114. package/skills/ui-design/scripts/__pycache__/design_style_check.cpython-314.pyc +0 -0
  115. package/skills/ui-design/scripts/__pycache__/design_style_check_test.cpython-314.pyc +0 -0
  116. package/skills/ui-design/scripts/__pycache__/draft_coverage_check.cpython-314.pyc +0 -0
  117. package/skills/ui-design/scripts/__pycache__/extract_source_assets.cpython-314.pyc +0 -0
  118. package/skills/ui-design/scripts/__pycache__/extract_source_assets_test.cpython-314.pyc +0 -0
  119. package/skills/ui-design/scripts/__pycache__/generated_image_review_collect.cpython-314.pyc +0 -0
  120. package/skills/ui-design/scripts/__pycache__/generated_image_review_collect_test.cpython-314.pyc +0 -0
  121. package/skills/ui-design/scripts/__pycache__/image_plan_lint.cpython-314.pyc +0 -0
  122. package/skills/ui-design/scripts/__pycache__/image_plan_lint_test.cpython-314.pyc +0 -0
  123. package/skills/ui-design/scripts/__pycache__/image_prompt_rewrite.cpython-314.pyc +0 -0
  124. package/skills/ui-design/scripts/__pycache__/image_prompt_rewrite_test.cpython-314.pyc +0 -0
  125. package/skills/ui-design/scripts/__pycache__/layout_balance.cpython-314.pyc +0 -0
  126. package/skills/ui-design/scripts/__pycache__/layout_balance_test.cpython-314.pyc +0 -0
  127. package/skills/ui-design/scripts/__pycache__/normalize_design_json.cpython-314.pyc +0 -0
  128. package/skills/ui-design/scripts/__pycache__/normalize_design_json_test.cpython-314.pyc +0 -0
  129. package/skills/ui-design/scripts/__pycache__/plan_source_layout.cpython-314.pyc +0 -0
  130. package/skills/ui-design/scripts/__pycache__/plan_source_layout_test.cpython-314.pyc +0 -0
  131. package/skills/ui-design/scripts/__pycache__/resolve_source_hint.cpython-314.pyc +0 -0
  132. package/skills/ui-design/scripts/__pycache__/resolve_source_hint_test.cpython-314.pyc +0 -0
  133. package/skills/ui-design/scripts/__pycache__/resolve_source_input.cpython-314.pyc +0 -0
  134. package/skills/ui-design/scripts/__pycache__/resolve_source_input_test.cpython-314.pyc +0 -0
  135. package/skills/ui-design/scripts/__pycache__/scaffold_ui_artifact.cpython-314.pyc +0 -0
  136. package/skills/ui-design/scripts/__pycache__/source_usage_check.cpython-314.pyc +0 -0
  137. package/skills/ui-design/scripts/__pycache__/source_usage_check_test.cpython-314.pyc +0 -0
  138. package/skills/ui-design/scripts/__pycache__/text_readability_pixel.cpython-314.pyc +0 -0
  139. package/skills/ui-design/scripts/__pycache__/visual_quality_gate.cpython-314.pyc +0 -0
  140. package/skills/ui-design/scripts/__pycache__/visual_quality_gate_test.cpython-314.pyc +0 -0
  141. package/skills/ui-design/scripts/assemble_material.py +217 -0
  142. package/skills/ui-design/scripts/assemble_material_test.py +149 -0
  143. package/skills/ui-design/scripts/browser_check_ui_artifact.mjs +221 -0
  144. package/skills/ui-design/scripts/chart_sanity_check.py +261 -0
  145. package/skills/ui-design/scripts/chart_sanity_check_test.py +165 -0
  146. package/skills/ui-design/scripts/check_language_policy.py +351 -0
  147. package/skills/ui-design/scripts/check_ui_artifact.py +435 -0
  148. package/skills/ui-design/scripts/check_ui_artifact_pptx_test.py +251 -0
  149. package/skills/ui-design/scripts/deck_pages_check.py +158 -0
  150. package/skills/ui-design/scripts/deck_pages_check_test.py +134 -0
  151. package/skills/ui-design/scripts/deck_visual_style_check.py +767 -0
  152. package/skills/ui-design/scripts/deck_visual_style_check_test.py +713 -0
  153. package/skills/ui-design/scripts/design_budget_check.py +271 -0
  154. package/skills/ui-design/scripts/design_budget_check_test.py +183 -0
  155. package/skills/ui-design/scripts/design_repair_hints.py +162 -0
  156. package/skills/ui-design/scripts/design_repair_hints_test.py +54 -0
  157. package/skills/ui-design/scripts/design_style_check.py +397 -0
  158. package/skills/ui-design/scripts/design_style_check_test.py +233 -0
  159. package/skills/ui-design/scripts/draft_coverage_check.py +184 -0
  160. package/skills/ui-design/scripts/draft_coverage_check_test.py +131 -0
  161. package/skills/ui-design/scripts/export_pptx_from_html.mjs +2367 -0
  162. package/skills/ui-design/scripts/extract_source_assets.py +607 -0
  163. package/skills/ui-design/scripts/extract_source_assets_test.py +256 -0
  164. package/skills/ui-design/scripts/generated_image_review_collect.py +206 -0
  165. package/skills/ui-design/scripts/generated_image_review_collect_test.py +91 -0
  166. package/skills/ui-design/scripts/image_plan_lint.py +571 -0
  167. package/skills/ui-design/scripts/image_plan_lint_test.py +515 -0
  168. package/skills/ui-design/scripts/image_prompt_rewrite.py +713 -0
  169. package/skills/ui-design/scripts/image_prompt_rewrite_test.py +966 -0
  170. package/skills/ui-design/scripts/layout_balance.py +881 -0
  171. package/skills/ui-design/scripts/layout_balance_test.py +927 -0
  172. package/skills/ui-design/scripts/normalize_design_json.py +272 -0
  173. package/skills/ui-design/scripts/normalize_design_json_test.py +119 -0
  174. package/skills/ui-design/scripts/plan_source_layout.py +1450 -0
  175. package/skills/ui-design/scripts/plan_source_layout_test.py +1136 -0
  176. package/skills/ui-design/scripts/resolve_source_input.py +270 -0
  177. package/skills/ui-design/scripts/resolve_source_input_test.py +98 -0
  178. package/skills/ui-design/scripts/scaffold_ui_artifact.py +377 -0
  179. package/skills/ui-design/scripts/source_usage_check.py +581 -0
  180. package/skills/ui-design/scripts/source_usage_check_test.py +427 -0
  181. package/skills/ui-design/scripts/text_readability_pixel.py +340 -0
  182. package/skills/ui-design/scripts/text_readability_pixel_test.py +219 -0
  183. package/skills/ui-design/scripts/visual_quality_gate.py +301 -0
  184. package/skills/ui-design/scripts/visual_quality_gate_test.py +233 -0
  185. package/skills/ui-design/tests/e2e/prompts/deck.md +19 -0
  186. package/skills/ui-design/tests/e2e/prompts/webpage.md +17 -0
  187. package/skills/ui-design/tests/e2e/render_check.mjs +101 -0
  188. package/skills/ui-design/tests/e2e/rubric.json +54 -0
  189. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/checks/browser-check.json +49 -0
  190. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/checks/language-check.txt +12 -0
  191. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/checks/static-check.txt +15 -0
  192. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/outputs/index.html +837 -0
  193. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/prompt.md +12 -0
  194. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/review.md +21 -0
  195. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/screenshots/desktop.png +0 -0
  196. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/screenshots/mobile.png +0 -0
  197. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/outputs/homepage.html +373 -0
  198. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/outputs/platform.html +378 -0
  199. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/prompt.md +13 -0
  200. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/review.md +26 -0
  201. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/homepage/browser-check.json +49 -0
  202. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/homepage/desktop.png +0 -0
  203. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/homepage/mobile.png +0 -0
  204. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/platform/browser-check.json +49 -0
  205. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/platform/desktop.png +0 -0
  206. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/platform/mobile.png +0 -0
  207. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-1/checks/static-check.txt +2 -0
  208. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-1/outputs/index.html +656 -0
  209. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-1/prompt.md +12 -0
  210. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-1/render_check.mjs +67 -0
  211. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-2/checks/static-check.txt +2 -0
  212. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-2/outputs/index.html +587 -0
  213. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-2/prompt.md +12 -0
  214. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-2/review.md +36 -0
  215. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/checks/browser-check.json +49 -0
  216. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/checks/language-check.txt +8 -0
  217. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/checks/static-check.txt +16 -0
  218. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/outputs/index.html +739 -0
  219. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/prompt.md +11 -0
  220. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/review.md +22 -0
  221. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/screenshots/desktop.png +0 -0
  222. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/screenshots/mobile.png +0 -0
  223. package/skills/ui-design/tests/e2e/runs/comfort-vision-preview-1/checks/browser-check.json +52 -0
  224. package/skills/ui-design/tests/e2e/runs/comfort-vision-preview-1/outputs/index.html +641 -0
  225. package/skills/ui-design/tests/e2e/runs/comfort-vision-preview-1/screenshots/desktop.png +0 -0
  226. package/skills/ui-design/tests/e2e/runs/comfort-vision-preview-1/screenshots/mobile.png +0 -0
  227. package/skills/ui-design/tests/e2e/runs/ember-prism-red-preview-1/outputs/index.html +467 -0
  228. package/skills/ui-design/tests/e2e/runs/ember-prism-red-preview-1/screenshots/browser-check.json +49 -0
  229. package/skills/ui-design/tests/e2e/runs/ember-prism-red-preview-1/screenshots/desktop.png +0 -0
  230. package/skills/ui-design/tests/e2e/runs/ember-prism-red-preview-1/screenshots/mobile.png +0 -0
  231. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/outputs/homepage.html +373 -0
  232. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/outputs/platform.html +378 -0
  233. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/prompt.md +13 -0
  234. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/review.md +26 -0
  235. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/homepage/browser-check.json +49 -0
  236. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/homepage/desktop.png +0 -0
  237. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/homepage/mobile.png +0 -0
  238. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/platform/browser-check.json +49 -0
  239. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/platform/desktop.png +0 -0
  240. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/platform/mobile.png +0 -0
  241. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/checks/browser-check.json +49 -0
  242. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/checks/language-check.txt +4 -0
  243. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/checks/static-check.txt +2 -0
  244. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/execution_notes.md +10 -0
  245. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/outputs/index.html +587 -0
  246. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/prompt.md +14 -0
  247. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/review.md +13 -0
  248. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/screenshots/desktop.png +0 -0
  249. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/screenshots/mobile.png +0 -0
  250. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/checks/browser-check.json +24 -0
  251. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/checks/static-check.txt +2 -0
  252. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/execution_notes.md +21 -0
  253. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/outputs/deck.html +451 -0
  254. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/screenshots/slide-01.png +0 -0
  255. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/screenshots/slide-02.png +0 -0
  256. package/skills/ui-design/tests/e2e/runs/iteration-1/review.md +79 -0
  257. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/checks/browser-check.json +26 -0
  258. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/checks/static-check.txt +2 -0
  259. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/execution_notes.md +20 -0
  260. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/outputs/index.html +534 -0
  261. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/screenshots/desktop.png +0 -0
  262. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/screenshots/mobile.png +0 -0
  263. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/browser-check.json +64 -0
  264. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/language-check.txt +7 -0
  265. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/quick-validate.txt +1 -0
  266. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/static-console.txt +2 -0
  267. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/static-homepage.txt +2 -0
  268. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/outputs/console.html +600 -0
  269. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/outputs/homepage.html +556 -0
  270. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/prompt.md +29 -0
  271. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/review.md +57 -0
  272. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/screenshots/console-desktop.png +0 -0
  273. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/screenshots/console-mobile.png +0 -0
  274. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/screenshots/homepage-desktop.png +0 -0
  275. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/screenshots/homepage-mobile.png +0 -0
  276. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/checks/browser-check.json +33 -0
  277. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/checks/language-check.txt +6 -0
  278. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/checks/quick-validate.txt +1 -0
  279. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/checks/static-check.txt +2 -0
  280. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/outputs/index.html +682 -0
  281. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/prompt.md +13 -0
  282. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/review.md +59 -0
  283. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/screenshots/desktop.png +0 -0
  284. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/screenshots/mobile.png +0 -0
  285. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/outputs/page-01.html +466 -0
  286. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/outputs/page-02.html +433 -0
  287. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/prompt.md +13 -0
  288. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-01/browser-check.json +49 -0
  289. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-01/desktop.png +0 -0
  290. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-01/mobile.png +0 -0
  291. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-02/browser-check.json +49 -0
  292. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-02/desktop.png +0 -0
  293. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-02/mobile.png +0 -0
  294. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/checks/browser-check.json +49 -0
  295. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/checks/language-check.txt +3 -0
  296. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/checks/static-check.txt +2 -0
  297. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/execution_notes.md +24 -0
  298. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/outputs/index.html +723 -0
  299. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/prompt.md +15 -0
  300. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/review.md +26 -0
  301. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/screenshots/desktop.png +0 -0
  302. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/screenshots/mobile.png +0 -0
  303. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/browser-dark.json +49 -0
  304. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/browser-light.json +49 -0
  305. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/language-check.txt +11 -0
  306. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/static-dark.txt +2 -0
  307. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/static-light.txt +3 -0
  308. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/execution_notes.md +23 -0
  309. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/outputs/dark.html +392 -0
  310. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/outputs/light.html +476 -0
  311. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/prompt.md +23 -0
  312. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/review.md +41 -0
  313. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/screenshots/dark/desktop.png +0 -0
  314. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/screenshots/dark/mobile.png +0 -0
  315. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/screenshots/light/desktop.png +0 -0
  316. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/screenshots/light/mobile.png +0 -0
  317. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/checks/browser-check.json +54 -0
  318. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/checks/language-check.txt +5 -0
  319. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/checks/quick-validate.txt +1 -0
  320. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/checks/static-check.txt +2 -0
  321. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/outputs/index.html +765 -0
  322. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/prompt.md +15 -0
  323. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/review.md +59 -0
  324. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/screenshots/desktop.png +0 -0
  325. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/screenshots/mobile.png +0 -0
  326. package/skills/ui-design/tests/e2e/runs/verdant-grid-preview-1/outputs/index.html +508 -0
  327. package/skills/ui-design/tests/e2e/runs/verdant-grid-preview-1/screenshots/browser-check.json +49 -0
  328. package/skills/ui-design/tests/e2e/runs/verdant-grid-preview-1/screenshots/desktop.png +0 -0
  329. package/skills/ui-design/tests/e2e/runs/verdant-grid-preview-1/screenshots/mobile.png +0 -0
  330. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/checks/browser-check.json +54 -0
  331. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/checks/language-check.txt +6 -0
  332. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/checks/quick-validate.txt +1 -0
  333. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/checks/static-check.txt +2 -0
  334. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/outputs/index.html +788 -0
  335. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/prompt.md +14 -0
  336. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/review.md +60 -0
  337. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/screenshots/desktop.png +0 -0
  338. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/screenshots/mobile.png +0 -0
  339. package/skills/video-compose/SKILL.md +8 -1
  340. package/skills/web-crawler/SKILL.md +70 -0
  341. package/skills/web-crawler/scripts/crawl.mjs +538 -0
  342. package/skills/web-crawler/scripts/crawl.test.mjs +204 -0
  343. package/skills/xlsx/SKILL.md +12 -0
  344. package/skills/xlsx/scripts/opc_check.py +253 -0
  345. package/src/advisorState.js +22 -0
  346. package/src/askBridge.js +97 -3
  347. package/src/bootstrap/workingDir.js +12 -1
  348. package/src/cli/args.js +17 -1
  349. package/src/cli/configMenu.js +236 -0
  350. package/src/cli/configWizard.js +7 -5
  351. package/src/cli/print.js +37 -4
  352. package/src/cli/streamJson.js +77 -24
  353. package/src/cli/wizards/advisorWizard.js +147 -0
  354. package/src/cli/wizards/fieldForm.js +149 -0
  355. package/src/cli/wizards/imageWizard.js +60 -0
  356. package/src/cli/wizards/openRouterWizard.js +23 -0
  357. package/src/cli/wizards/providerPresets.js +95 -0
  358. package/src/cli/wizards/providerWizard.js +219 -0
  359. package/src/cli/wizards/searchWizard.js +23 -0
  360. package/src/cli/wizards/ttsWizard.js +34 -0
  361. package/src/config/configFile.js +441 -83
  362. package/src/config/configService.js +446 -0
  363. package/src/config.js +24 -64
  364. package/src/context/agentHome.js +9 -0
  365. package/src/context/archive.js +7 -2
  366. package/src/context/compact.js +8 -5
  367. package/src/context/contextLock.js +81 -0
  368. package/src/context/persistContext.js +30 -11
  369. package/src/context/reactiveCompact.js +4 -2
  370. package/src/context/recentDirs.js +21 -61
  371. package/src/context/sessionPath.js +97 -23
  372. package/src/context/sessionRegistry.js +303 -24
  373. package/src/context/tokenCounter.js +18 -0
  374. package/src/context/workspaceIndex.js +731 -0
  375. package/src/infra/verification.js +28 -15
  376. package/src/llm/client.js +394 -159
  377. package/src/llm/providers/anthropicFormat.js +241 -0
  378. package/src/llm/providers/openaiFormat.js +190 -0
  379. package/src/loop.js +219 -32
  380. package/src/main.js +61 -11
  381. package/src/messageQueue.js +97 -0
  382. package/src/multimodal.js +203 -0
  383. package/src/plugin-sdk.js +15 -0
  384. package/src/plugins/commandRouter.js +3 -0
  385. package/src/plugins/hookEngine.js +5 -1
  386. package/src/plugins/pluginLifecycle.js +28 -0
  387. package/src/plugins/pluginLoader.js +2 -1
  388. package/src/plugins/pluginRunner.js +49 -21
  389. package/src/plugins/transcript.js +4 -3
  390. package/src/prompts/pluginList.js +5 -5
  391. package/src/prompts/projectInstructions.js +3 -3
  392. package/src/prompts/promptAssembly.js +133 -144
  393. package/src/prompts/skillList.js +12 -22
  394. package/src/prompts/system.js +0 -1
  395. package/src/session/runAgentTurn.js +70 -0
  396. package/src/session/runTurn.js +41 -6
  397. package/src/toolGate.js +17 -0
  398. package/src/tools/advisor/advisor.js +379 -0
  399. package/src/tools/agent/agent.js +555 -0
  400. package/src/tools/agent/agentTypes.js +59 -0
  401. package/src/tools/agent/coordinator.js +93 -0
  402. package/src/tools/agent/worktree.js +270 -0
  403. package/src/tools/ask/ask.js +7 -2
  404. package/src/tools/bash/bash.js +112 -18
  405. package/src/tools/download/download.js +144 -0
  406. package/src/tools/edit/edit.js +1 -0
  407. package/src/tools/edit/multi-edit.js +1 -0
  408. package/src/tools/genimage/genimage.js +242 -0
  409. package/src/tools/genspeech/genspeech.js +67 -0
  410. package/src/tools/glob/glob.js +1 -0
  411. package/src/tools/grep/grep.js +3 -0
  412. package/src/tools/grep/rgPath.js +2 -0
  413. package/src/tools/index.js +25 -2
  414. package/src/tools/read/read.js +1 -0
  415. package/src/tools/shared/killTree.js +52 -0
  416. package/src/tools/toolProgressBridge.js +48 -0
  417. package/src/tools/webbrowser/agentBrowserPath.js +258 -0
  418. package/src/tools/webbrowser/browser.js +59 -42
  419. package/src/tools/webbrowser/browserControl.js +118 -0
  420. package/src/tools/webbrowser/browserDaemon.js +97 -0
  421. package/src/tools/webbrowser/browserService.js +27 -0
  422. package/src/tools/webbrowser/browserStream.js +119 -0
  423. package/src/tools/webbrowser/webbrowser.js +699 -130
  424. package/src/tools/webfetch/webfetch.js +1 -0
  425. package/src/tools/websearch/websearch.js +134 -51
  426. package/src/tools/write/write.js +1 -0
  427. package/src/ui/App.js +249 -22
  428. package/src/ui/AskQuestion.js +29 -10
  429. package/src/ui/InputBox.js +104 -12
  430. package/src/ui/LiveArea.js +112 -19
  431. package/src/ui/MessageList.js +100 -14
  432. package/src/ui/ModelPicker.js +56 -0
  433. package/src/ui/QueuedMessagesBanner.js +14 -0
  434. package/src/ui/Root.js +18 -10
  435. package/src/ui/SessionPicker.js +102 -0
  436. package/src/ui/StatusLine.js +13 -4
  437. package/src/ui/WorkspacePicker.js +55 -0
  438. package/src/ui/figures.js +44 -0
  439. package/src/ui/hooks/askSlot.js +82 -0
  440. package/src/ui/hooks/useChat.js +358 -64
  441. package/src/ui/hooks/usePasteHandler.js +11 -1
  442. package/src/ui/hooks/useTextBuffer.js +1 -3
  443. package/src/ui/liveViewport.js +5 -1
  444. package/src/ui/markdown.js +206 -0
  445. package/src/ui/textBuffer.js +0 -7
  446. package/src/ui/theme.js +32 -0
  447. package/src/utils/debugLog.js +0 -4
  448. package/src/utils/resourcePaths.js +2 -2
  449. package/src/utils/stripThink.js +50 -0
  450. package/src/web/browserBridge.js +447 -0
  451. package/src/web/protocol.js +19 -0
  452. package/workflows/adaptive-research.yaml +13 -5
  453. package/workflows/book-review-short.yaml +36 -10
  454. package/workflows/e2e-write-greet.yaml +6 -2
  455. package/workflows/illustrate.yaml +227 -0
  456. package/workflows/plan.yaml +460 -179
  457. package/workflows/ppt.yaml +1659 -0
  458. package/workflows/ralph-loop.yaml +199 -0
  459. package/workflows/schema.json +81 -23
  460. package/workflows/webppt.yaml +496 -0
  461. package/workflows/word.yaml +710 -0
  462. package/workflows/youtube-shorts.yaml +46 -15
  463. package/MINIMAL-TRACE.md +0 -77
  464. package/plugins/HOW-TO-WRITE-A-PLUGIN.md +0 -186
  465. package/plugins/ralph-wiggum/.claude-plugin/plugin.json +0 -9
  466. package/plugins/ralph-wiggum/README.md +0 -197
  467. package/plugins/ralph-wiggum/commands/cancel-ralph.md +0 -18
  468. package/plugins/ralph-wiggum/commands/help.md +0 -137
  469. package/plugins/ralph-wiggum/commands/ralph-loop.md +0 -85
  470. package/plugins/ralph-wiggum/hooks/hooks.json +0 -15
  471. package/plugins/ralph-wiggum/hooks/stop-hook.sh +0 -191
  472. package/plugins/ralph-wiggum/plugin.js +0 -280
  473. package/plugins/ralph-wiggum/src/goalState.js +0 -258
  474. package/plugins/ralph-wiggum/src/sentinels.js +0 -76
  475. package/plugins/ralph-wiggum/src/stopHookRunner.js +0 -104
  476. package/plugins/ralph-wiggum/src/verificationGate.js +0 -202
  477. package/plugins/workflow-runner/src/miniReAct.js +0 -223
  478. package/plugins/workflow-runner/src/voteHelpers.js +0 -110
  479. package/skills/config/SKILL.md +0 -123
  480. package/skills/frontend-design/LICENSE.txt +0 -177
  481. package/skills/frontend-design/SKILL.md +0 -42
  482. package/src/ui/ProgressPanel.js +0 -54
  483. package/src/ui/StreamingBlock.js +0 -11
  484. package/src/ui/ToolStatus.js +0 -11
@@ -0,0 +1,927 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ layout_balance_test.py —— layout_balance.py 的回归锚(stdlib unittest,仿 layout_lint_test 风格)。
5
+
6
+ 直接手搓最小 export-manifest.json(mode=hybrid + slides[].balance),分别命中
7
+ 重叠 / 过空 / 空半 / 挤角,并验证「有全幅底图(hasBgImage) 时 B/C 降级为 WARNING、但重叠永远 ERROR」
8
+ 以及「均衡页零问题」「flat 模式不闸门」。不依赖导出器实际跑,只测策略与阈值。
9
+
10
+ 运行:python skills/ui-design/scripts/layout_balance_test.py
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ import tempfile
17
+ import unittest
18
+ from contextlib import contextmanager
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
+ import layout_balance # noqa: E402 (为切换 OCCUPANCY_CHECKS_ENABLED 开关)
22
+ from layout_balance import _resolve_manifest_path, check_balance # noqa: E402
23
+
24
+
25
+ @contextmanager
26
+ def occupancy_enabled():
27
+ """临时把占空比检测开关置 True(**现默认即 True**,2026-06-19「加回但宽松」裁决后此 cm 多为冗余明示)。
28
+ 切换的是 layout_balance 模块自身的配置常量(非 mock.module),用完还原。"""
29
+ old = layout_balance.OCCUPANCY_CHECKS_ENABLED
30
+ layout_balance.OCCUPANCY_CHECKS_ENABLED = True
31
+ try:
32
+ yield
33
+ finally:
34
+ layout_balance.OCCUPANCY_CHECKS_ENABLED = old
35
+
36
+
37
+ @contextmanager
38
+ def occupancy_disabled():
39
+ """临时把占空比检测开关置 False(验证「一键全停」仍能关掉占空族,只留重叠/夹带文本/溢出/字号)。"""
40
+ old = layout_balance.OCCUPANCY_CHECKS_ENABLED
41
+ layout_balance.OCCUPANCY_CHECKS_ENABLED = False
42
+ try:
43
+ yield
44
+ finally:
45
+ layout_balance.OCCUPANCY_CHECKS_ENABLED = old
46
+
47
+
48
+ def balance(coverage, top, bottom, left, right, overlaps=None, has_bg=False, boxes=8,
49
+ cards=None, overflow_runs=None, has_fg_visual=False, small_text=None, baked_text=None,
50
+ off_canvas=None, low_contrast=None):
51
+ return {
52
+ "grid": {"cols": 16, "rows": 8},
53
+ "contentBoxes": boxes,
54
+ "hasBgImage": has_bg,
55
+ "hasForegroundVisual": has_fg_visual, # 切出图的 img/svg/图标/大视觉块(「有图不算空」降级依据)
56
+ "coverage": coverage,
57
+ "halfOccupancy": {"top": top, "bottom": bottom, "left": left, "right": right},
58
+ "bbox": {"x": 0, "y": 0, "w": 1920, "h": 1080},
59
+ "centroid": {"x": 960, "y": 540},
60
+ "overlaps": overlaps or [],
61
+ "cards": cards or [], # 卡片/底板填充率(文本+子块面积÷卡面积;填充不足软门)
62
+ "overflowRuns": overflow_runs or [], # 显著裁切的文本(溢出软门)
63
+ "smallText": small_text or [], # 字号低于放映级下限的文本段(字号软门)
64
+ "bakedText": baked_text or [], # 背景态仍可见的 HTML/伪元素文本(截图夹带,硬门)
65
+ "offCanvas": off_canvas or [], # 越出画布的文本/前景块(超画布软门)
66
+ "lowContrast": low_contrast or [], # 文字/背景对比度不足(严重+纯底→硬门)
67
+ }
68
+
69
+
70
+ def lc(ratio, bg_solid=True, text="文字", color="rgb(17,18,26)", bg="rgb(27,26,49)"):
71
+ return {"text": text, "ratio": ratio, "color": color, "bgColor": bg,
72
+ "bgSolid": bg_solid, "rect": {"x": 0.1, "y": 0.1, "w": 0.5, "h": 0.05}}
73
+
74
+
75
+ def card(fill, x=0.05, y=0.15, w=0.44, h=0.40, fg_id="fg-0-0"):
76
+ return {"fgId": fg_id, "fill": fill, "rect": {"x": x, "y": y, "w": w, "h": h}}
77
+
78
+
79
+ def write_manifest(slides, mode="hybrid"):
80
+ fd, path = tempfile.mkstemp(suffix=".json")
81
+ os.close(fd)
82
+ manifest = {"mode": mode, "width": 1920, "height": 1080, "slides": []}
83
+ for i, b in enumerate(slides, start=1):
84
+ manifest["slides"].append({"index": i, "label": f"{i:02d} 测试页", "balance": b})
85
+ with open(path, "w", encoding="utf-8") as f:
86
+ json.dump(manifest, f)
87
+ return path
88
+
89
+
90
+ OVERLAP = [{"a": "标题", "b": "副标题", "aKind": "text", "bKind": "text", "areaIn": 0.6, "fracOfSmaller": 0.4}]
91
+
92
+ # 富化重叠(带归一化坐标 0–1):computeSlideBalance 现给每个重叠加 aRect/bRect/rect。
93
+ OVERLAP_COORDS = [{
94
+ "a": "大标题", "b": "副标题", "aKind": "text", "bKind": "text",
95
+ "areaIn": 0.6, "fracOfSmaller": 0.4,
96
+ "aRect": {"x": 0.05, "y": 0.70, "w": 0.50, "h": 0.12},
97
+ "bRect": {"x": 0.05, "y": 0.78, "w": 0.40, "h": 0.06},
98
+ "rect": {"x": 0.05, "y": 0.78, "w": 0.40, "h": 0.04},
99
+ }]
100
+
101
+
102
+ class LayoutBalanceTest(unittest.TestCase):
103
+ def setUp(self):
104
+ self._paths = []
105
+
106
+ def tearDown(self):
107
+ for p in self._paths:
108
+ try:
109
+ os.remove(p)
110
+ except OSError:
111
+ pass
112
+
113
+ def _run(self, slides, mode="hybrid"):
114
+ path = write_manifest(slides, mode=mode)
115
+ self._paths.append(path)
116
+ return check_balance(path)
117
+
118
+ def _run_occ(self, slides, mode="hybrid"):
119
+ # 占空族逻辑测试:临时启用占空检测(默认停用),验证启用时判定仍正确。
120
+ with occupancy_enabled():
121
+ return self._run(slides, mode=mode)
122
+
123
+ def test_balanced_passes(self):
124
+ # 覆盖率高、四半区都有内容、无重叠 → 零 ERROR。
125
+ problems, _warn, _det = self._run([balance(0.62, 0.5, 0.5, 0.5, 0.5)])
126
+ self.assertEqual(problems, [], f"均衡页不应报缺陷:{problems}")
127
+
128
+ def test_overlap_always_error(self):
129
+ problems, _w, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=OVERLAP)])
130
+ self.assertTrue(any("相互叠加" in p for p in problems), problems)
131
+
132
+ def test_overlap_error_even_with_bg(self):
133
+ # 重叠不可降级:即便有全幅底图也必须 ERROR(封面撞车的克星)。
134
+ problems, _w, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=OVERLAP, has_bg=True)])
135
+ self.assertTrue(any("相互叠加" in p for p in problems), problems)
136
+
137
+ def test_overlap_reports_normalized_coords(self):
138
+ # 用户要求:重叠为主门,报错带 元素 + 重叠范围 + 归一化坐标(0–1)。
139
+ problems, _w, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=OVERLAP_COORDS)])
140
+ p = next(x for x in problems if "相互叠加" in x)
141
+ self.assertIn("归一化", p, f"重叠消息应标明归一化坐标:{p}")
142
+ self.assertIn("重叠区", p, p)
143
+ # 归一化坐标数值出现(aRect.x=0.05 / rect.y=0.78)
144
+ self.assertIn("0.78", p, f"应含归一化坐标数值:{p}")
145
+
146
+ def test_overlap_without_coords_degrades_gracefully(self):
147
+ # 旧 manifest(无 aRect/bRect/rect)仍能报,只退化为元素名对、不抛。
148
+ problems, _w, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=OVERLAP)])
149
+ p = next(x for x in problems if "相互叠加" in x)
150
+ self.assertIn("标题", p, p)
151
+ self.assertNotIn("归一化", p, f"无坐标字段时不应硬塞归一化串:{p}")
152
+
153
+ # ── 占空族逻辑(启用时正确性):默认停用,这些测试经 _run_occ 临时启用占空检测验证逻辑本身。──
154
+ def test_empty_bottom_half_error(self):
155
+ # 上满下空(slide 3 那种)→ ERROR。coverage 设 0.6(远高于过空阈值)隔离「空半」与「过空」。
156
+ problems, _w, _d = self._run_occ([balance(0.6, 0.85, 0.02, 0.45, 0.45)])
157
+ self.assertTrue(any("失衡" in p and "下空上满" in p for p in problems), problems)
158
+
159
+ def test_empty_left_half_error(self):
160
+ # 左空右满(slide 4 那种)→ ERROR。coverage 0.6(远高于过空阈值)隔离「空半」与「过空」。
161
+ problems, _w, _d = self._run_occ([balance(0.6, 0.45, 0.45, 0.02, 0.8)])
162
+ self.assertTrue(any("失衡" in p and "左空右满" in p for p in problems), problems)
163
+
164
+ def test_single_sparse_page_is_warning_not_error(self):
165
+ # 启用占空时:单页过空、无底图 → **仅 WARNING + 记账**,不单页 ERROR。
166
+ problems, warnings, _d = self._run_occ([balance(0.20, 0.25, 0.25, 0.25, 0.25)])
167
+ self.assertFalse(any("过空" in p for p in problems), f"单页过空不应 ERROR:{problems}")
168
+ self.assertTrue(any("过空" in w for w in warnings), warnings)
169
+
170
+ def test_too_sparse_warns_with_bg(self):
171
+ # 过空但有全幅底图铺满 → WARNING(「有图不算空」)。
172
+ problems, warnings, _d = self._run_occ([balance(0.20, 0.25, 0.25, 0.25, 0.25, has_bg=True)])
173
+ self.assertFalse(any("过空" in p for p in problems), problems)
174
+ self.assertTrue(any("过空" in w for w in warnings), warnings)
175
+
176
+ def test_sparse_with_foreground_visual_downgrades(self):
177
+ # 「有图不算空」:过空但有前景视觉块(图/SVG/图标)→ WARNING 标「有底图/视觉元素」,不 ERROR。
178
+ problems, warnings, _d = self._run_occ([balance(0.20, 0.25, 0.25, 0.25, 0.25, has_fg_visual=True)])
179
+ self.assertFalse(any("过空" in p for p in problems), problems)
180
+ self.assertTrue(any("过空" in w and "视觉元素" in w for w in warnings), warnings)
181
+
182
+ def test_majority_empty_pages_warning(self):
183
+ # 「不能页页空」:过半有内容页都过空 → **WARNING(不再整体 ERROR)**(2026-06-19「加回但宽松」)。3 页全 0.20。
184
+ sparse = balance(0.20, 0.25, 0.25, 0.25, 0.25)
185
+ problems, warnings, _d = self._run_occ([sparse, sparse, sparse])
186
+ self.assertFalse(any("过半页面过空" in p for p in problems), f"过半页空应降为 WARNING:{problems}")
187
+ self.assertTrue(any("过半页面过空" in w for w in warnings), warnings)
188
+
189
+ def test_minority_empty_pages_no_overall_warning(self):
190
+ # 仅 1/3 过空(未过半)→ 无「过半页面过空」整体条目(单页那条仍是 WARNING)。
191
+ full = balance(0.55, 0.5, 0.5, 0.5, 0.5)
192
+ sparse = balance(0.20, 0.25, 0.25, 0.25, 0.25)
193
+ problems, warnings, _d = self._run_occ([full, full, sparse])
194
+ self.assertFalse(any("过半页面过空" in p for p in problems), problems)
195
+ self.assertFalse(any("过半页面过空" in w for w in warnings), warnings)
196
+
197
+ def test_coverage_above_min_not_flagged(self):
198
+ # 启用占空时:0.30 ≥ COVER_MIN(0.26) → 既不 ERROR 也不记过空账(给 AI 更大解空间)。
199
+ problems, warnings, _d = self._run_occ([balance(0.30, 0.5, 0.5, 0.5, 0.5)])
200
+ self.assertFalse(any("过空" in p for p in problems), problems)
201
+ self.assertFalse(any("过空" in w for w in warnings), f"0.30 ≥ 0.26 不应记过空:{warnings}")
202
+
203
+ # ── 空心卡片硬门(补 computeSlideBalance 只量外框的盲区,"卡片字小空多"的确定性牙齿)──
204
+ def test_hollow_card_error_with_coords(self):
205
+ # 纯文本卡 fill 0.13(真机 build_bak 11 页实测)→ ERROR,且报卡片归一化坐标 + 填充率%。
206
+ problems, _w, _d = self._run_occ([balance(0.66, 0.6, 0.6, 0.6, 0.6, cards=[card(0.13, x=0.05, y=0.15)])])
207
+ p = next((x for x in problems if "填充不足" in x), None)
208
+ self.assertIsNotNone(p, f"fill 0.13 应判空心卡片:{problems}")
209
+ self.assertIn("13%", p, f"应报实际填充率:{p}")
210
+ self.assertIn("0.15", p, f"应报卡片归一化坐标:{p}")
211
+ self.assertIn("归一化", p, p)
212
+
213
+ def test_filled_card_passes(self):
214
+ # 正常文字卡 fill 0.53(真机 build_bak 07 页实测)→ 不判空心。
215
+ problems, _w, _d = self._run_occ([balance(0.62, 0.6, 0.6, 0.6, 0.6, cards=[card(0.53)])])
216
+ self.assertFalse(any("填充不足" in p for p in problems), problems)
217
+
218
+ def test_hollow_card_downgrades_with_visual(self):
219
+ # 新策略(2026-06-17):空心卡片**可降级**——有底图/前景视觉块时降 WARNING(别逼 AI 反复改装饰卡烧配额)。
220
+ problems, warnings, _d = self._run_occ([balance(0.66, 0.6, 0.6, 0.6, 0.6, cards=[card(0.13)], has_bg=True)])
221
+ self.assertFalse(any("填充不足" in p for p in problems), problems)
222
+ self.assertTrue(any("填充不足" in w for w in warnings), warnings)
223
+
224
+ def test_small_card_exempt_from_hollow(self):
225
+ # 小卡(引文/装饰条/药丸,面积 w×h < CARD_MIN_AREA_FRAC 0.06)豁免——空心门只抓「大卡装一句话」。
226
+ problems, warnings, _d = self._run_occ([balance(0.66, 0.6, 0.6, 0.6, 0.6, cards=[card(0.05, w=0.12, h=0.08)])])
227
+ self.assertFalse(any("填充不足" in p for p in problems), problems)
228
+ self.assertFalse(any("填充不足" in w for w in warnings), warnings)
229
+
230
+ # ── 文本裁切软门(导出会缩字/截断,提示但不拦)──
231
+ def test_text_clip_is_warning_not_error(self):
232
+ runs = [{"text": "思维导图", "overflowPx": 25, "rect": {"x": 0.79, "y": 0.56, "w": 0.05, "h": 0.03}}]
233
+ problems, warnings, _d = self._run([balance(0.62, 0.5, 0.5, 0.5, 0.5, overflow_runs=runs)])
234
+ self.assertFalse(any("裁切" in p for p in problems), f"文本裁切应是 WARNING 不是 ERROR:{problems}")
235
+ self.assertTrue(any("裁切" in w and "思维导图" in w for w in warnings), warnings)
236
+
237
+ def test_corner_cluster_error(self):
238
+ # 上空 + 左空 → 内容挤右下角 → ERROR(含「角」)。coverage 0.6(远高于过空阈值)隔离「挤角」与「过空」。
239
+ problems, _w, _d = self._run_occ([balance(0.6, 0.02, 0.5, 0.02, 0.5)])
240
+ self.assertTrue(any("角" in p for p in problems), problems)
241
+
242
+ def test_flat_mode_not_gated(self):
243
+ problems, warnings, _d = self._run([balance(0.1, 0.1, 0.0, 0.0, 0.1)], mode="flat")
244
+ self.assertEqual(problems, [], "flat 模式不应被构图闸门拦下")
245
+ self.assertTrue(any("flat" in w for w in warnings), warnings)
246
+
247
+ def test_message_is_report_first_then_optional_hint(self):
248
+ # 按用户原则(zod 式):每条消息**先报问题**(【问题类型】… · 位置 · 范围 在最前),
249
+ # **修法降为清晰分隔的可选提示**(「|可参考」前缀、不强制)。用重叠(始终 ERROR)验证。
250
+ problems, _w, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=OVERLAP)])
251
+ p = next((x for x in problems if "相互叠加" in x), None)
252
+ self.assertIsNotNone(p, f"应报重叠 ERROR:{problems}")
253
+ self.assertTrue(p.startswith("【"), f"消息应以【问题类型】开头:{p}")
254
+ self.assertIn("|可参考", p, f"修法应作为清晰分隔的可选提示:{p}")
255
+ self.assertLess(p.index("相互叠加"), p.index("|可参考"), f"问题应在修法之前:{p}")
256
+
257
+ def test_hint_direction_overlap_move_first(self):
258
+ # 用户裁决的修法方向(2026-06-18,锁住、不许回退):重叠/裁切 → **移动/重排 > 精炼文案 > 缩字(≤20%)**
259
+ #(配合全局放大字号要保大字号,移动提到精炼之前、缩字封 20% 顶;占空族已停用,不再有「空/空心卡」子段)。
260
+ # ── 重叠:移动 < 精炼 < 缩小字体,且含 20% ──
261
+ probs, _w, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=OVERLAP)])
262
+ ov = next((x for x in probs if "内容相互叠加" in x), None)
263
+ self.assertIsNotNone(ov, f"应报重叠:{probs}")
264
+ self.assertIn("移动", ov, f"重叠修法应以移动/重排打头:{ov}")
265
+ self.assertIn("20%", ov, f"缩字应封 20% 顶:{ov}")
266
+ self.assertLess(ov.index("移动"), ov.index("精炼"), f"移动应排在精炼之前:{ov}")
267
+ self.assertLess(ov.index("精炼"), ov.index("缩小字体"), f"精炼应排在缩字之前:{ov}")
268
+ # ── 文本裁切(始终保留):加大框/挪位 < 精炼 < 缩小字体,且含 20% ──
269
+ _p, warns, _d = self._run([balance(
270
+ 0.6, 0.5, 0.5, 0.5, 0.5,
271
+ overflow_runs=[{"text": "超长文案", "rect": {"x": 0.1, "y": 0.1, "w": 0.3, "h": 0.05}, "overflowPx": 40}],
272
+ )])
273
+ clip = next((x for x in warns if "文本裁切" in x), None)
274
+ self.assertIsNotNone(clip, f"应报文本裁切:{warns}")
275
+ self.assertIn("20%", clip)
276
+ self.assertLess(clip.index("加大框"), clip.index("精炼"), f"加大框/挪位应排在精炼之前:{clip}")
277
+ self.assertLess(clip.index("精炼"), clip.index("缩小字体"), f"精炼应排在缩字之前:{clip}")
278
+
279
+ # ── 占空族「加回但宽松」锁(默认 OCCUPANCY_CHECKS_ENABLED=True,2026-06-19)──
280
+ def test_occupancy_reenabled_lenient_by_default(self):
281
+ # 默认即启用:空半 / 挤角 等占空缺陷会被检测——但**有视觉元素时降 WARNING(不拦)**。
282
+ # 空半页 + 前景视觉块 → 应是 WARNING,不进 problems。
283
+ problems, warnings, _d = self._run([balance(0.6, 0.85, 0.02, 0.45, 0.45, has_fg_visual=True)])
284
+ self.assertFalse(any("失衡" in p for p in problems), f"有视觉应降 WARNING 不 ERROR:{problems}")
285
+ self.assertTrue(any("失衡" in w for w in warnings), f"占空族默认应启用并提示:{warnings}")
286
+
287
+ def test_occupancy_no_visual_still_error_by_default(self):
288
+ # 默认启用 + 无任何视觉(纯文字空半页)→ 仍 ERROR(「真空白才硬拦」)。
289
+ problems, _w, _d = self._run([balance(0.6, 0.85, 0.02, 0.45, 0.45)])
290
+ self.assertTrue(any("失衡" in p and "下空上满" in p for p in problems), problems)
291
+
292
+ def test_occupancy_can_be_fully_disabled(self):
293
+ # 「一键全停」仍可用:置 False 后占空族整族零 problem/zero warning(重叠/溢出/字号/夹带不受影响)。
294
+ with occupancy_disabled():
295
+ for b in (
296
+ balance(0.05, 0.0, 0.0, 0.0, 0.0), # 极空
297
+ balance(0.6, 0.85, 0.02, 0.45, 0.45), # 空半(无视觉)
298
+ balance(0.6, 0.02, 0.5, 0.02, 0.5), # 挤角
299
+ balance(0.66, 0.6, 0.6, 0.6, 0.6, cards=[card(0.13)]), # 空心卡
300
+ ):
301
+ problems, warnings, _d = self._run([b])
302
+ for kw in ("过空", "失衡", "角", "填充不足", "过半"):
303
+ self.assertFalse(any(kw in p for p in problems), f"停用后不应报 {kw}:{problems}")
304
+ self.assertFalse(any(kw in w for w in warnings), f"停用后不应报 {kw}:{warnings}")
305
+
306
+ # ── 字号过小软门(放映级,WARNING 不拦)──
307
+ def test_small_font_is_warning(self):
308
+ st = [{"text": "图例标签", "fontSize": 18.0, "rect": {"x": 0.8, "y": 0.5, "w": 0.1, "h": 0.02}}]
309
+ problems, warnings, _d = self._run([balance(0.62, 0.5, 0.5, 0.5, 0.5, small_text=st)])
310
+ self.assertFalse(any("字号过小" in p for p in problems), f"字号过小应 WARNING 不 ERROR:{problems}")
311
+ self.assertTrue(any("字号过小" in w and "图例标签" in w and "18" in w for w in warnings), warnings)
312
+
313
+ # ── 截图夹带文本硬门(ERROR,与重叠同级——用户硬要求:底图/切图绝不能带 HTML/SVG 文本)──
314
+ def test_baked_text_dom_is_error(self):
315
+ bt = [{"text": "嫁接式架构", "source": "dom", "rect": {"x": 0.5, "y": 0.4, "w": 0.3, "h": 0.04}}]
316
+ problems, _w, _d = self._run([balance(0.62, 0.5, 0.5, 0.5, 0.5, baked_text=bt)])
317
+ p = next((x for x in problems if "截图夹带文本" in x), None)
318
+ self.assertIsNotNone(p, f"DOM 漏标文本应判截图夹带文本 ERROR:{problems}")
319
+ self.assertIn("嫁接式架构", p, p)
320
+
321
+ def test_baked_text_pseudo_is_error(self):
322
+ bt = [{"text": "新功能", "source": "pseudo", "rect": {"x": 0.1, "y": 0.1, "w": 0.1, "h": 0.03}}]
323
+ problems, _w, _d = self._run([balance(0.62, 0.5, 0.5, 0.5, 0.5, baked_text=bt)])
324
+ p = next((x for x in problems if "截图夹带文本" in x), None)
325
+ self.assertIsNotNone(p, f"::before/::after 文字应判截图夹带文本 ERROR:{problems}")
326
+ self.assertIn("DOM 文本节点", p, f"修法应指向改成真实 DOM 文本:{p}")
327
+
328
+ def test_baked_text_error_even_with_bg_and_disabled_occupancy(self):
329
+ # 截图夹带文本不可降级、不受占空开关影响:有底图 + 占空停用,仍 ERROR。
330
+ bt = [{"text": "权限说明", "source": "dom", "rect": {"x": 0.5, "y": 0.4, "w": 0.3, "h": 0.04}}]
331
+ with occupancy_disabled():
332
+ problems, _w, _d = self._run([balance(0.62, 0.5, 0.5, 0.5, 0.5, has_bg=True, baked_text=bt)])
333
+ self.assertTrue(any("截图夹带文本" in p for p in problems), problems)
334
+
335
+ def test_overlap_and_overflow_always_report(self):
336
+ # 重叠恒 ERROR、文本溢出恒 WARNING(与占空开关无关)。
337
+ probs, _w, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=OVERLAP)])
338
+ self.assertTrue(any("相互叠加" in p for p in probs), probs)
339
+ _p, warns, _d = self._run([balance(
340
+ 0.6, 0.5, 0.5, 0.5, 0.5,
341
+ overflow_runs=[{"text": "超长", "rect": {"x": 0.1, "y": 0.1, "w": 0.3, "h": 0.05}, "overflowPx": 40}],
342
+ )])
343
+ self.assertTrue(any("文本裁切" in w for w in warns), warns)
344
+
345
+ # ── 超出画布软门(WARNING,与占空开关无关)──
346
+ def test_off_canvas_is_warning(self):
347
+ oc = [{"kind": "fg", "name": "fg-0-2", "rect": {"x": 0.55, "y": 0.06, "w": 0.39, "h": 1.17}}] # y+h>1
348
+ problems, warnings, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, off_canvas=oc)])
349
+ self.assertFalse(any("超出画布" in p for p in problems), f"超画布应 WARNING 不 ERROR:{problems}")
350
+ self.assertTrue(any("超出画布" in w and "fg-0-2" in w for w in warnings), warnings)
351
+
352
+ def test_off_canvas_independent_of_occupancy_switch(self):
353
+ # 文本越界也报;且不受占空开关影响(停用占空后仍报)。
354
+ oc = [{"kind": "text", "name": "超长标题", "rect": {"x": 0.05, "y": 0.9, "w": 0.5, "h": 0.2}}]
355
+ with occupancy_disabled():
356
+ _p, warnings, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, off_canvas=oc)])
357
+ self.assertTrue(any("超出画布" in w and "超长标题" in w for w in warnings), warnings)
358
+
359
+ # ── 严重越界(catastrophic):内容实质飞出画布(可见 < 20%,s9 事故)──
360
+ # s9 实测:轨道被大图撑爆,标题/正文全跑到 x≈0.99(可见 ~6%)。
361
+ S9_OFFCANVAS = [{"kind": "text", "name": "一台顶 N 台", "rect": {"x": 0.99, "y": 0.10, "w": 0.176, "h": 0.155}}]
362
+
363
+ def test_severe_offcanvas_default_still_warning(self):
364
+ # 默认(不传 strict):即便严重越界也保持 WARNING —— 逐字节兼容旧行为,不误伤 webppt。
365
+ problems, warnings, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, off_canvas=self.S9_OFFCANVAS)])
366
+ self.assertFalse(any("超出画布" in p for p in problems), f"默认不应升 ERROR:{problems}")
367
+ self.assertTrue(any("超出画布" in w and "可见部分仅" in w for w in warnings), warnings)
368
+
369
+ def test_severe_offcanvas_strict_is_error(self):
370
+ # --strict-offcanvas:可见 < 20% 的越界升 ERROR,并标出可见比例。
371
+ path = write_manifest([balance(0.6, 0.5, 0.5, 0.5, 0.5, off_canvas=self.S9_OFFCANVAS)])
372
+ self._paths.append(path)
373
+ problems, _w, _d = check_balance(path, strict_offcanvas=True)
374
+ p = next((x for x in problems if "超出画布" in x), None)
375
+ self.assertIsNotNone(p, f"严重越界 strict 应 ERROR:{problems}")
376
+ self.assertIn("可见部分仅", p, p)
377
+ self.assertIn("min-width:0", p, f"应提示轨道撑爆的治本修法:{p}")
378
+
379
+ def test_mild_offcanvas_strict_still_warning(self):
380
+ # 轻微越界(可见 > 20%)即便 strict 也只 WARNING —— 只硬拦「实质不可见」。
381
+ oc = [{"kind": "text", "name": "略越界", "rect": {"x": 0.05, "y": 0.9, "w": 0.5, "h": 0.2}}] # 可见 ~50%
382
+ path = write_manifest([balance(0.6, 0.5, 0.5, 0.5, 0.5, off_canvas=oc)])
383
+ self._paths.append(path)
384
+ problems, warnings, _d = check_balance(path, strict_offcanvas=True)
385
+ self.assertFalse(any("超出画布" in p for p in problems), f"轻微越界不应升 ERROR:{problems}")
386
+ self.assertTrue(any("超出画布" in w for w in warnings), warnings)
387
+
388
+ # ── content_boxes==0:无真实视觉 → ERROR;有真实底图/视觉 → WARNING(纯图封面合法)──
389
+ def test_empty_page_no_visual_is_error(self):
390
+ problems, _w, _d = self._run([balance(0.0, 0.0, 0.0, 0.0, 0.0, boxes=0)])
391
+ self.assertTrue(any("未量到内容" in p for p in problems), f"无视觉空页应 ERROR:{problems}")
392
+
393
+ def test_empty_page_with_real_bg_downgrades(self):
394
+ # 纯图封面合法:content_boxes==0 但有真实底图 → WARNING(依赖 Gap A:真背景才 hasBgImage=true)。
395
+ problems, warnings, _d = self._run([balance(0.0, 0.0, 0.0, 0.0, 0.0, boxes=0, has_bg=True)])
396
+ self.assertFalse(any("未量到内容" in p for p in problems), f"纯图封面不应 ERROR:{problems}")
397
+ self.assertTrue(any("未量到内容" in w for w in warnings), warnings)
398
+
399
+ # ── catastrophic_only:只报「内容不可见」,抑制良性重叠/占空噪声(visual_quality_gate 回归安全硬门)──
400
+ def test_catastrophic_only_suppresses_benign_overlap(self):
401
+ # 只有重叠(良性相邻/嵌套的高频误报)→ catastrophic_only 返回空,绝不误拦干净 deck。
402
+ path = write_manifest([balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=OVERLAP)])
403
+ self._paths.append(path)
404
+ problems, _w, _d = check_balance(path, catastrophic_only=True)
405
+ self.assertEqual(problems, [], f"catastrophic_only 不应被良性重叠触发:{problems}")
406
+
407
+ def test_catastrophic_only_catches_severe_offcanvas(self):
408
+ path = write_manifest([balance(0.6, 0.5, 0.5, 0.5, 0.5, off_canvas=self.S9_OFFCANVAS)])
409
+ self._paths.append(path)
410
+ problems, _w, _d = check_balance(path, catastrophic_only=True)
411
+ self.assertTrue(any("超出画布" in p for p in problems), f"严重越界应进 catastrophic:{problems}")
412
+
413
+ def test_catastrophic_only_catches_empty_no_visual(self):
414
+ path = write_manifest([balance(0.0, 0.0, 0.0, 0.0, 0.0, boxes=0)])
415
+ self._paths.append(path)
416
+ problems, _w, _d = check_balance(path, catastrophic_only=True)
417
+ self.assertTrue(any("未量到内容" in p for p in problems), f"无视觉空页应进 catastrophic:{problems}")
418
+
419
+ # ── 文字对比度不足:**一律 WARNING、绝不 ERROR**(硬门会逼弱模型改 HTML 改崩 → halt;治本在上游设计铁律)──
420
+ def test_low_contrast_severe_solid_is_warning_not_error(self):
421
+ # 深紫底黑字 ratio 1.1 + 纯底(最严重)也只 WARNING,不硬拦(防导出步被逼改 HTML 改崩 halt)。
422
+ problems, warnings, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, low_contrast=[lc(1.1, True)])])
423
+ self.assertFalse(any("对比度不足" in p for p in problems), f"对比度绝不 ERROR:{problems}")
424
+ w = next((x for x in warnings if "对比度不足" in x), None)
425
+ self.assertIsNotNone(w, f"应 WARNING 报对比度:{warnings}")
426
+ self.assertIn("1.1", w, w)
427
+
428
+ def test_low_contrast_low_confidence_is_warning(self):
429
+ # 渐变/图底(bgSolid=False)→ WARNING。
430
+ problems, warnings, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, low_contrast=[lc(1.5, False)])])
431
+ self.assertFalse(any("对比度不足" in p for p in problems), problems)
432
+ self.assertTrue(any("对比度不足" in w for w in warnings), warnings)
433
+
434
+ def test_low_contrast_never_error_regardless_of_occupancy(self):
435
+ # 对比度门恒为 WARNING、与占空开关无关:停用占空后严重低对比仍只 WARNING、不 ERROR。
436
+ with occupancy_disabled():
437
+ problems, warnings, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, low_contrast=[lc(1.1, True)])])
438
+ self.assertFalse(any("对比度不足" in p for p in problems), problems)
439
+ self.assertTrue(any("对比度不足" in w for w in warnings), warnings)
440
+
441
+ def test_no_occupancy_argument_path_disables_occupancy_family(self):
442
+ path = write_manifest([balance(0.20, 0.85, 0.02, 0.02, 0.85)])
443
+ self._paths.append(path)
444
+ problems, warnings, _d = check_balance(path, occupancy_checks_enabled=False)
445
+ combined = "\n".join(problems + warnings)
446
+ self.assertNotIn("过空", combined)
447
+ self.assertNotIn("失衡", combined)
448
+
449
+
450
+ class LayoutReportResolveTest(unittest.TestCase):
451
+ """构图闸门前置到 HTML 层后,闸门读的是 export --measure-only 写的 layout-report.json
452
+ (与 export-manifest.json 同 schema)。锁两件事:① 传目录时优先 layout-report.json;
453
+ ② check_balance 在 layout-report.json 上判定与 manifest 完全一致。"""
454
+
455
+ def setUp(self):
456
+ self.dir = tempfile.mkdtemp()
457
+
458
+ def tearDown(self):
459
+ import shutil
460
+
461
+ shutil.rmtree(self.dir, ignore_errors=True)
462
+
463
+ def _write(self, name, mode="hybrid"):
464
+ # 02 页带重叠 → 始终 ERROR(占空族已停用,用重叠这条恒在的缺陷验证 report 被正确读取)。
465
+ report = {
466
+ "mode": mode,
467
+ "width": 1920,
468
+ "height": 1080,
469
+ "slides": [
470
+ {"index": 1, "label": "01 均衡页", "balance": balance(0.62, 0.5, 0.5, 0.5, 0.5)},
471
+ {"index": 2, "label": "02 重叠页", "balance": balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=OVERLAP)},
472
+ ],
473
+ }
474
+ path = os.path.join(self.dir, name)
475
+ with open(path, "w", encoding="utf-8") as f:
476
+ json.dump(report, f)
477
+ return path
478
+
479
+ def test_dir_prefers_layout_report(self):
480
+ # 目录里同时有 layout-report.json 与 export-manifest.json → 优先前者(HTML 层廉价产物)。
481
+ self._write("export-manifest.json")
482
+ report_path = self._write("layout-report.json")
483
+ self.assertEqual(_resolve_manifest_path(self.dir), report_path)
484
+
485
+ def test_dir_falls_back_to_manifest(self):
486
+ # 只有 export-manifest.json(全量导出产物)→ 回落它。
487
+ manifest_path = self._write("export-manifest.json")
488
+ self.assertEqual(_resolve_manifest_path(self.dir), manifest_path)
489
+
490
+ def test_check_balance_on_layout_report(self):
491
+ # layout-report.json 与 manifest 同 schema:02 页重叠应被判 ERROR。
492
+ report_path = self._write("layout-report.json")
493
+ problems, _warn, _det = check_balance(report_path)
494
+ self.assertTrue(any("相互叠加" in p for p in problems), problems)
495
+
496
+
497
+ # ── --ppt-strict(2026-07-02 逐页体检裁决):默认关、webppt 行为不变;ppt 视觉门专用严格档。──
498
+ # 噪声样本全部取自真机 deck 的 layout-report.json(57 条重叠逐条标定),阈值改动会破坏标定需重验。
499
+ def _ov(a_rect, b_rect, rect, frac, a_kind="text", b_kind="text", a="甲", b="乙"):
500
+ return {"a": a, "b": b, "aKind": a_kind, "bKind": b_kind, "areaIn": 0.3,
501
+ "fracOfSmaller": frac, "aRect": a_rect, "bRect": b_rect, "rect": rect}
502
+
503
+
504
+ # R1 行盒贴边:标题/副标题 0.01h 细带(真机 02 目录「目录」×「五章走完…」,frac 14%)
505
+ NOISE_THIN = _ov({"x": 0.06, "y": 0.05, "w": 0.94, "h": 0.09},
506
+ {"x": 0.06, "y": 0.14, "w": 0.94, "h": 0.06},
507
+ {"x": 0.06, "y": 0.14, "w": 0.94, "h": 0.01}, 0.14)
508
+ # R2 膨胀父盒包含:小框 95% 被 3.2× 大框含住(真机 02「行业真问题…」×「失全·失时…」)
509
+ NOISE_CONTAIN = _ov({"x": 0.12, "y": 0.34, "w": 0.26, "h": 0.17},
510
+ {"x": 0.12, "y": 0.43, "w": 0.27, "h": 0.05},
511
+ {"x": 0.12, "y": 0.43, "w": 0.26, "h": 0.05}, 0.95)
512
+ # R3 行盒尾部(纵向):重叠落在文本框底部 33% 条带(真机 03「360°全景…」×fg-2-0,frac 85%)
513
+ NOISE_VTAIL = _ov({"x": 0.06, "y": 0.53, "w": 0.63, "h": 0.21},
514
+ {"x": 0.06, "y": 0.68, "w": 0.56, "h": 0.08},
515
+ {"x": 0.06, "y": 0.68, "w": 0.56, "h": 0.07}, 0.85, b_kind="fg", b="fg-2-0")
516
+ # R4 行盒尾部(横向):短文字宽框右侧空白蹭相邻卡(真机 23「一套系统」×fg-22-1,frac 8%)
517
+ NOISE_HTAIL = _ov({"x": 0.075, "y": 0.715, "w": 0.343, "h": 0.037},
518
+ {"x": 0.39, "y": 0.693, "w": 0.322, "h": 0.123},
519
+ {"x": 0.39, "y": 0.715, "w": 0.028, "h": 0.037}, 0.083, b_kind="fg", b="fg-22-1")
520
+ # 真重叠 A:标题被卡遮挡(真机 09「系统总体架构」×fg-8-0,frac 22%、带高 0.04 不在尾部条带)
521
+ REAL_TITLE_CARD = _ov({"x": 0.06, "y": 0.05, "w": 0.94, "h": 0.09},
522
+ {"x": 0.06, "y": 0.10, "w": 0.37, "h": 0.18},
523
+ {"x": 0.06, "y": 0.10, "w": 0.37, "h": 0.04}, 0.22, b_kind="fg", b="fg-8-0")
524
+ # 真重叠 B:文字堆叠(真机 12「总师办…」×「异地专家…」,frac 88% 但大小框相当,R2 拦不住)
525
+ REAL_STACKED = _ov({"x": 0.26, "y": 0.67, "w": 0.16, "h": 0.15},
526
+ {"x": 0.26, "y": 0.69, "w": 0.15, "h": 0.15},
527
+ {"x": 0.26, "y": 0.69, "w": 0.15, "h": 0.13}, 0.88)
528
+
529
+ ALL_NOISE = [NOISE_THIN, NOISE_CONTAIN, NOISE_VTAIL, NOISE_HTAIL]
530
+
531
+
532
+ class PptStrictTest(unittest.TestCase):
533
+ def setUp(self):
534
+ self._paths = []
535
+
536
+ def tearDown(self):
537
+ for p in self._paths:
538
+ try:
539
+ os.remove(p)
540
+ except OSError:
541
+ pass
542
+
543
+ def _run(self, slides, **kw):
544
+ path = write_manifest(slides)
545
+ self._paths.append(path)
546
+ return check_balance(path, **kw)
547
+
548
+ def test_default_mode_still_reports_noise_overlaps(self):
549
+ # 红线锚:不带 ppt_strict 时四类噪声照报(webppt 依赖默认行为逐字节不变)。
550
+ problems, _w, _d = self._run([balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=ALL_NOISE)])
551
+ self.assertEqual(sum("相互叠加" in p for p in problems), 4, problems)
552
+
553
+ def test_strict_filters_all_four_noise_rules(self):
554
+ problems, _w, _d = self._run(
555
+ [balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=ALL_NOISE)], ppt_strict=True)
556
+ self.assertFalse(any("相互叠加" in p for p in problems), problems)
557
+
558
+ def test_strict_keeps_real_overlaps(self):
559
+ # 真碰撞(标题被卡压 / 文字堆叠)在噪声中幸存,照报 ERROR。
560
+ problems, _w, _d = self._run(
561
+ [balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=ALL_NOISE + [REAL_TITLE_CARD, REAL_STACKED])],
562
+ ppt_strict=True)
563
+ hits = [p for p in problems if "相互叠加" in p]
564
+ self.assertEqual(len(hits), 2, problems)
565
+ self.assertTrue(any("fg-8-0" in p for p in hits), hits)
566
+
567
+ def test_strict_offcanvas_partial_clip_is_error(self):
568
+ # 「内容超出 PPT 下边缘」:可见 35%(>20% 非 catastrophic)默认 WARNING、strict 升 ERROR。
569
+ oc = [{"kind": "text", "name": "质量追溯 · 工艺复盘",
570
+ "rect": {"x": 0.135, "y": 0.97, "w": 0.278, "h": 0.086}}]
571
+ problems_default, warn_default, _ = self._run(
572
+ [balance(0.6, 0.5, 0.5, 0.5, 0.5, off_canvas=oc)])
573
+ self.assertFalse(any("超出画布" in p for p in problems_default), problems_default)
574
+ self.assertTrue(any("超出画布" in w for w in warn_default), warn_default)
575
+ problems_strict, _w, _d = self._run(
576
+ [balance(0.6, 0.5, 0.5, 0.5, 0.5, off_canvas=oc)], ppt_strict=True)
577
+ self.assertTrue(any("超出画布" in p for p in problems_strict), problems_strict)
578
+
579
+ def test_strict_big_empty_card_is_error(self):
580
+ # 大空板(占页 33%、填充 0):默认「有图可接受」WARNING → strict 升【空心大卡】ERROR。
581
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5, cards=[card(0.0, w=0.368, h=0.904)],
582
+ has_fg_visual=True)
583
+ b["mediaRects"] = [] # 有媒体信息、但没有媒体块盖住
584
+ with occupancy_enabled():
585
+ problems, _w, _d = self._run([b], ppt_strict=True)
586
+ self.assertTrue(any("空心大卡" in p for p in problems), problems)
587
+
588
+ def test_strict_media_covered_card_exempt(self):
589
+ # 图卡假空:媒体块盖住卡 ≥70%(slide15 网络立方体图卡)→ strict 下整条豁免(连 WARNING 都不留)。
590
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5, cards=[card(0.0, x=0.68, y=0.30, w=0.26, h=0.65)],
591
+ has_fg_visual=True)
592
+ b["mediaRects"] = [{"x": 0.68, "y": 0.30, "w": 0.26, "h": 0.65}]
593
+ with occupancy_enabled():
594
+ problems, warnings_, _d = self._run([b], ppt_strict=True)
595
+ self.assertFalse(any("空心大卡" in p for p in problems), problems)
596
+ self.assertFalse(any("填充不足" in w for w in warnings_), warnings_)
597
+
598
+ def test_strict_without_media_info_no_escalation(self):
599
+ # 旧 manifest(无 mediaRects 字段):strict 不升级空卡(防误伤图卡),走默认 WARNING。
600
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5, cards=[card(0.0, w=0.368, h=0.904)],
601
+ has_fg_visual=True)
602
+ with occupancy_enabled():
603
+ problems, warnings_, _d = self._run([b], ppt_strict=True)
604
+ self.assertFalse(any("空心大卡" in p for p in problems), problems)
605
+ self.assertTrue(any("填充不足" in w for w in warnings_), warnings_)
606
+
607
+ def test_strict_midrange_fill_stays_warning(self):
608
+ # 填充 10%~28% 之间:strict 也只走默认 WARNING(升级只抓真空板)。
609
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5, cards=[card(0.15, w=0.368, h=0.904)],
610
+ has_fg_visual=True)
611
+ b["mediaRects"] = []
612
+ with occupancy_enabled():
613
+ problems, warnings_, _d = self._run([b], ppt_strict=True)
614
+ self.assertFalse(any("空心大卡" in p for p in problems), problems)
615
+ self.assertTrue(any("填充不足" in w for w in warnings_), warnings_)
616
+
617
+
618
+ # ── 【文字溢出卡片】(仅 ppt_strict;2026-07-02 slide13 CTA 案)──
619
+ # 卡内文字在重叠检查里是豁免项,「字顶出自家卡」此前不可见;本检查读顶层 textRuns(px)×
620
+ # balance.fgRects(归一化)做 containment 判定。卡 fixture 取自真机 fg-12-3(frosted plate)。
621
+ PLATE = {"name": "fg-12-3", "rect": {"x": 0.611, "y": 0.534, "w": 0.314, "h": 0.093}, "hasMedia": False}
622
+
623
+
624
+ def run_px(x, y, w, h, parent="fg-12-3", text="请批准立项 · 沈阳泰科易"):
625
+ return {"text": text, "x": x, "y": y, "w": w, "h": h,
626
+ "fontSize": 24, "color": "rgb(255,255,255)", "bgColor": None,
627
+ "bgSolid": False, "fgParent": parent}
628
+
629
+
630
+ class PptStrictTextEscapeTest(unittest.TestCase):
631
+ def setUp(self):
632
+ self._paths = []
633
+
634
+ def tearDown(self):
635
+ for p in self._paths:
636
+ try:
637
+ os.remove(p)
638
+ except OSError:
639
+ pass
640
+
641
+ def _run_slide(self, text_runs, fg_rects, **kw):
642
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5)
643
+ if fg_rects is not None:
644
+ b["fgRects"] = fg_rects
645
+ fd, path = tempfile.mkstemp(suffix=".json")
646
+ os.close(fd)
647
+ manifest = {"mode": "hybrid", "width": 1920, "height": 1080,
648
+ "slides": [{"index": 13, "label": "13 总结", "balance": b, "textRuns": text_runs}]}
649
+ with open(path, "w", encoding="utf-8") as f:
650
+ json.dump(manifest, f)
651
+ self._paths.append(path)
652
+ return check_balance(path, **kw)
653
+
654
+ def test_text_below_card_bottom_is_error(self):
655
+ # 文字底缘 0.667 超出卡底 0.627 约 43px(真机 CTA 形态)→ strict 下 ERROR + 报「下缘」。
656
+ problems, _w, _d = self._run_slide([run_px(1180, 580, 560, 140)], [PLATE], ppt_strict=True)
657
+ hits = [p for p in problems if "文字溢出卡片" in p]
658
+ self.assertEqual(len(hits), 1, problems)
659
+ self.assertIn("下缘", hits[0])
660
+ self.assertIn("fg-12-3", hits[0])
661
+
662
+ def test_text_inside_card_not_flagged(self):
663
+ problems, _w, _d = self._run_slide([run_px(1180, 585, 560, 85)], [PLATE], ppt_strict=True)
664
+ self.assertFalse(any("文字溢出卡片" in p for p in problems), problems)
665
+
666
+ def test_small_overhang_within_tolerance(self):
667
+ # 溢出 ~12.8px(0.0119 < 0.012 容差)= 行盒舍入噪声,不报。
668
+ problems, _w, _d = self._run_slide([run_px(1180, 585, 560, 105)], [PLATE], ppt_strict=True)
669
+ self.assertFalse(any("文字溢出卡片" in p for p in problems), problems)
670
+
671
+ def test_default_mode_never_reports_escape(self):
672
+ # 红线锚:不带 ppt_strict 时本检查完全不跑(webppt 等默认调用零影响)。
673
+ problems, warnings_, _d = self._run_slide([run_px(1180, 580, 560, 140)], [PLATE])
674
+ self.assertFalse(any("文字溢出卡片" in x for x in problems + warnings_), (problems, warnings_))
675
+
676
+ def test_old_report_without_fgrects_skipped(self):
677
+ # 旧 report(无 fgRects 字段):静默跳过,不误报不崩。
678
+ problems, _w, _d = self._run_slide([run_px(1180, 580, 560, 140)], None, ppt_strict=True)
679
+ self.assertFalse(any("文字溢出卡片" in p for p in problems), problems)
680
+
681
+ def test_media_card_also_checked(self):
682
+ # 媒体卡(hasMedia=true,不在 cards[] 里)也能当父卡查——fgRects 含全部前景块正是为此。
683
+ media = dict(PLATE, hasMedia=True)
684
+ problems, _w, _d = self._run_slide([run_px(1180, 580, 560, 140)], [media], ppt_strict=True)
685
+ self.assertTrue(any("文字溢出卡片" in p for p in problems), problems)
686
+
687
+ def test_broken_link_overlap_gets_card_hint(self):
688
+ # fgParent 断链(wrapper 被当 run)时溢出以「text×fg 高包含重叠」形态暴露——
689
+ # strict 下该重叠消息追加「加大卡片或让文字换行收纳」方向提示。
690
+ ov = _ov({"x": 0.594, "y": 0.519, "w": 0.347, "h": 0.122},
691
+ {"x": 0.611, "y": 0.534, "w": 0.314, "h": 0.093},
692
+ {"x": 0.611, "y": 0.534, "w": 0.314, "h": 0.093}, 1.0,
693
+ a_kind="text", b_kind="fg", a="请批准立项 · 沈阳泰", b="fg-12-3")
694
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=[ov])
695
+ fd, path = tempfile.mkstemp(suffix=".json")
696
+ os.close(fd)
697
+ with open(path, "w", encoding="utf-8") as f:
698
+ json.dump({"mode": "hybrid", "width": 1920, "height": 1080,
699
+ "slides": [{"index": 13, "label": "13 总结", "balance": b}]}, f)
700
+ self._paths.append(path)
701
+ problems, _w, _d = check_balance(path, ppt_strict=True)
702
+ hits = [p for p in problems if "相互叠加" in p]
703
+ self.assertEqual(len(hits), 1, problems)
704
+ self.assertIn("加大卡片或让文字换行收纳", hits[0])
705
+ # 默认档同一数据:照报重叠但不带该提示(行为不变)。
706
+ problems_default, _w2, _d2 = check_balance(path)
707
+ hits_default = [p for p in problems_default if "相互叠加" in p]
708
+ self.assertEqual(len(hits_default), 1, problems_default)
709
+ self.assertNotIn("加大卡片或让文字换行收纳", hits_default[0])
710
+
711
+
712
+ class PptStrictOverlapV2Test(unittest.TestCase):
713
+ """R5 媒体卡内文字降 WARNING + R6 导出加宽框幻影剔除(2026-07-03 真机 03/09 页复盘)。"""
714
+
715
+ # 真机 03 页 caption 形态:「驾驶舱全景」88% 落在图卡 fg-2-0 内(fgParent 断链)。
716
+ CAPTION_TEXT = {"x": 0.07, "y": 0.66, "w": 0.31, "h": 0.04}
717
+ CAPTION_FG = {"x": 0.06, "y": 0.26, "w": 0.29, "h": 0.46}
718
+ # 真机 09 页副标题幻影形态:导出框 h0.202(真实行盒 h0.101 的 2×)压到下方表格卡。
719
+ INFLATED_TEXT = {"x": 0.058, "y": 0.178, "w": 0.70, "h": 0.202}
720
+ TABLE_FG = {"x": 0.058, "y": 0.305, "w": 0.454, "h": 0.442}
721
+
722
+ def setUp(self):
723
+ self._paths = []
724
+
725
+ def tearDown(self):
726
+ for p in self._paths:
727
+ try:
728
+ os.remove(p)
729
+ except OSError:
730
+ pass
731
+
732
+ def _check(self, overlaps, fg_rects=None, text_runs=None, **kw):
733
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5, overlaps=overlaps)
734
+ if fg_rects is not None:
735
+ b["fgRects"] = fg_rects
736
+ slide = {"index": 3, "label": "03 痛点", "balance": b}
737
+ if text_runs is not None:
738
+ slide["textRuns"] = text_runs
739
+ fd, path = tempfile.mkstemp(suffix=".json")
740
+ os.close(fd)
741
+ with open(path, "w", encoding="utf-8") as f:
742
+ json.dump({"mode": "hybrid", "width": 1920, "height": 1080, "slides": [slide]}, f)
743
+ self._paths.append(path)
744
+ return check_balance(path, **kw)
745
+
746
+ def _caption_ov(self):
747
+ return _ov(self.CAPTION_TEXT, self.CAPTION_FG,
748
+ {"x": 0.07, "y": 0.66, "w": 0.28, "h": 0.04}, 0.88,
749
+ a_kind="text", b_kind="fg", a="驾驶舱全景", b="fg-2-0")
750
+
751
+ def test_r5_media_caption_downgraded_to_warning(self):
752
+ fg = [{"name": "fg-2-0", "rect": self.CAPTION_FG, "hasMedia": True}]
753
+ problems, warnings_, _d = self._check([self._caption_ov()], fg, ppt_strict=True)
754
+ self.assertFalse(any("相互叠加" in p for p in problems), problems)
755
+ hits = [w for w in warnings_ if "媒体卡内文字" in w]
756
+ self.assertEqual(len(hits), 1, warnings_)
757
+ self.assertIn("驾驶舱全景", hits[0])
758
+
759
+ def test_r5_bare_img_tag_counts_as_media(self):
760
+ # hasMedia 只查后代——裸 <img> 前景块恒 false(真机 03 页),靠加性 tag 字段识别。
761
+ fg = [{"name": "fg-2-0", "rect": self.CAPTION_FG, "hasMedia": False, "tag": "img"}]
762
+ problems, warnings_, _d = self._check([self._caption_ov()], fg, ppt_strict=True)
763
+ self.assertFalse(any("相互叠加" in p for p in problems), problems)
764
+ self.assertTrue(any("媒体卡内文字" in w for w in warnings_), warnings_)
765
+
766
+ def test_r5_non_media_card_stays_error(self):
767
+ # 不透明白卡盖字是像素门盲区——非媒体卡包含不降级,宁可误报。
768
+ fg = [{"name": "fg-2-0", "rect": self.CAPTION_FG, "hasMedia": False, "tag": "div"}]
769
+ problems, _w, _d = self._check([self._caption_ov()], fg, ppt_strict=True)
770
+ self.assertTrue(any("相互叠加" in p for p in problems), problems)
771
+
772
+ def test_r5_text_swallowing_card_stays_error(self):
773
+ # 反向(text⊃fg,slide13 CTA 真缺陷)不豁免。
774
+ big_text = {"x": 0.05, "y": 0.25, "w": 0.35, "h": 0.50}
775
+ ov = _ov(big_text, self.CAPTION_FG, self.CAPTION_FG, 1.0,
776
+ a_kind="text", b_kind="fg", a="请批准立项", b="fg-2-0")
777
+ fg = [{"name": "fg-2-0", "rect": self.CAPTION_FG, "hasMedia": True}]
778
+ problems, _w, _d = self._check([ov], fg, ppt_strict=True)
779
+ self.assertTrue(any("相互叠加" in p for p in problems), problems)
780
+
781
+ def test_r5_partial_straddle_stays_error(self):
782
+ # 骑边(50% 压卡)是真缺陷,不豁免。
783
+ straddle = {"x": 0.07, "y": 0.70, "w": 0.31, "h": 0.08} # 下半截出卡(卡底 0.72)
784
+ ov = _ov(straddle, self.CAPTION_FG,
785
+ {"x": 0.07, "y": 0.70, "w": 0.28, "h": 0.02}, 0.35,
786
+ a_kind="text", b_kind="fg", a="骑边文字", b="fg-2-0")
787
+ fg = [{"name": "fg-2-0", "rect": self.CAPTION_FG, "hasMedia": True}]
788
+ problems, _w, _d = self._check([ov], fg, ppt_strict=True)
789
+ self.assertTrue(any("相互叠加" in p for p in problems), problems)
790
+
791
+ def _inflated_ov(self):
792
+ return _ov(self.INFLATED_TEXT, self.TABLE_FG,
793
+ {"x": 0.058, "y": 0.305, "w": 0.454, "h": 0.075}, 0.24,
794
+ a_kind="text", b_kind="fg", a="核心投入 ≈ 3 万元,对应的是", b="fg-8-0")
795
+
796
+ def test_r6_inflation_phantom_dropped(self):
797
+ # 原始行盒 y193 h109(norm h0.101,底 0.279)根本够不到卡顶 0.305 → 幻影剔除。
798
+ raw = [{"text": "核心投入 ≈ 3 万元,对应的是单架数亿元飞机的", "x": 111, "y": 192, "w": 1200, "h": 109}]
799
+ problems, warnings_, _d = self._check([self._inflated_ov()], [], raw, ppt_strict=True)
800
+ self.assertFalse(any("相互叠加" in x for x in problems + warnings_), (problems, warnings_))
801
+
802
+ def test_r6_still_overlapping_raw_kept(self):
803
+ # 原始行盒确实压进卡内(h200 → 底 0.363 > 卡顶 0.305)→ 真重叠保留。
804
+ raw = [{"text": "核心投入 ≈ 3 万元,对应的是单架数亿元飞机的", "x": 111, "y": 192, "w": 1200, "h": 200}]
805
+ problems, _w, _d = self._check([self._inflated_ov()], [], raw, ppt_strict=True)
806
+ self.assertTrue(any("相互叠加" in p for p in problems), problems)
807
+
808
+ def test_r6_no_raw_match_behavior_unchanged(self):
809
+ # 找不到同原点原始行盒(旧报告/坐标漂移)→ 不动,照报。
810
+ raw = [{"text": "别的文字", "x": 500, "y": 800, "w": 300, "h": 50}]
811
+ problems, _w, _d = self._check([self._inflated_ov()], [], raw, ppt_strict=True)
812
+ self.assertTrue(any("相互叠加" in p for p in problems), problems)
813
+
814
+ def test_default_mode_unaffected(self):
815
+ # 红线锚:默认档(webppt)R5/R6 完全不参与——caption 重叠照报 ERROR、幻影也照报。
816
+ fg = [{"name": "fg-2-0", "rect": self.CAPTION_FG, "hasMedia": True}]
817
+ raw = [{"text": "核心投入 ≈ 3 万元,对应的是单架数亿元飞机的", "x": 111, "y": 192, "w": 1200, "h": 109}]
818
+ problems, warnings_, _d = self._check(
819
+ [self._caption_ov(), self._inflated_ov()], fg, raw)
820
+ self.assertEqual(sum(1 for p in problems if "相互叠加" in p), 2, problems)
821
+ self.assertFalse(any("媒体卡内文字" in w for w in warnings_), warnings_)
822
+
823
+
824
+ # ── 信息集锦/综述型客户图槽位偏窄(is_dense_info + mediaRects.sourceFile;2026-07-07 p8 连续 3 次
825
+ # 未达标后新增):只在传入 source_layout_plan_path 且有 is_dense_info==true 条目时激活,webppt 无
826
+ # 此文件恒不触发;只反查这类明确标记的图,不改写其它 display_role 既有的 min_canvas_fraction 面积语义。
827
+
828
+ def write_source_layout_plan(images):
829
+ fd, path = tempfile.mkstemp(suffix=".json")
830
+ os.close(fd)
831
+ with open(path, "w", encoding="utf-8") as f:
832
+ json.dump({"has_source": True, "images": images}, f)
833
+ return path
834
+
835
+
836
+ class DenseInfoWidthTest(unittest.TestCase):
837
+ def setUp(self):
838
+ self._paths = []
839
+
840
+ def tearDown(self):
841
+ for p in self._paths:
842
+ try:
843
+ os.remove(p)
844
+ except OSError:
845
+ pass
846
+
847
+ def _plan(self, images):
848
+ path = write_source_layout_plan(images)
849
+ self._paths.append(path)
850
+ return path
851
+
852
+ def _run(self, slides, source_layout_plan_path=None):
853
+ path = write_manifest(slides)
854
+ self._paths.append(path)
855
+ return check_balance(path, source_layout_plan_path=source_layout_plan_path)
856
+
857
+ def test_dense_info_narrow_render_warns(self):
858
+ # 真机 p8 复刻:47%宽×26%高的槽(面积≈31%,压线满足通用 min_canvas_fraction=0.30),
859
+ # 但宽度仅 26% < DENSE_INFO_MIN_WIDTH_FRACTION(0.55) → 报窄槽 WARNING。
860
+ plan = self._plan([{
861
+ "file": "src_8.png", "enhanced_file": "enhanced_src_8.png",
862
+ "is_dense_info": True, "min_canvas_fraction": 0.30,
863
+ }])
864
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5, has_fg_visual=True)
865
+ b["mediaRects"] = [{"x": 0.06, "y": 0.7, "w": 0.26, "h": 0.30, "sourceFile": "enhanced_src_8.png"}]
866
+ _p, warnings_, _d = self._run([b], source_layout_plan_path=plan)
867
+ self.assertTrue(any("信息集锦图槽位偏窄" in w for w in warnings_), warnings_)
868
+ self.assertTrue(any("src_8.png" in w for w in warnings_), warnings_)
869
+
870
+ def test_dense_info_wide_render_passes(self):
871
+ plan = self._plan([{
872
+ "file": "src_8.png", "enhanced_file": "enhanced_src_8.png",
873
+ "is_dense_info": True, "min_canvas_fraction": 0.30,
874
+ }])
875
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5, has_fg_visual=True)
876
+ b["mediaRects"] = [{"x": 0.06, "y": 0.7, "w": 0.62, "h": 0.30, "sourceFile": "enhanced_src_8.png"}]
877
+ _p, warnings_, _d = self._run([b], source_layout_plan_path=plan)
878
+ self.assertFalse(any("信息集锦图槽位偏窄" in w for w in warnings_), warnings_)
879
+
880
+ def test_non_dense_info_image_not_checked(self):
881
+ # min_canvas_fraction 声明了但 is_dense_info 非 true → 不参与本检查(不重写既有面积语义)。
882
+ plan = self._plan([{
883
+ "file": "src_7.png", "enhanced_file": "enhanced_src_7.png",
884
+ "is_dense_info": False, "min_canvas_fraction": 0.30,
885
+ }])
886
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5, has_fg_visual=True)
887
+ b["mediaRects"] = [{"x": 0.06, "y": 0.7, "w": 0.10, "h": 0.10, "sourceFile": "enhanced_src_7.png"}]
888
+ _p, warnings_, _d = self._run([b], source_layout_plan_path=plan)
889
+ self.assertFalse(any("信息集锦图槽位偏窄" in w for w in warnings_), warnings_)
890
+
891
+ def test_no_source_layout_plan_path_no_check(self):
892
+ # 不传路径(webppt 默认调用场景)→ 恒不触发,即便 mediaRects 带 sourceFile。
893
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5, has_fg_visual=True)
894
+ b["mediaRects"] = [{"x": 0.06, "y": 0.7, "w": 0.10, "h": 0.10, "sourceFile": "enhanced_src_8.png"}]
895
+ _p, warnings_, _d = self._run([b], source_layout_plan_path=None)
896
+ self.assertFalse(any("信息集锦图槽位偏窄" in w for w in warnings_), warnings_)
897
+
898
+ def test_missing_plan_file_silently_skips(self):
899
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5, has_fg_visual=True)
900
+ b["mediaRects"] = [{"x": 0.06, "y": 0.7, "w": 0.10, "h": 0.10, "sourceFile": "enhanced_src_8.png"}]
901
+ _p, warnings_, _d = self._run([b], source_layout_plan_path="/nonexistent/path.json")
902
+ self.assertFalse(any("信息集锦图槽位偏窄" in w for w in warnings_), warnings_)
903
+
904
+ def test_media_rect_without_source_file_skipped(self):
905
+ plan = self._plan([{
906
+ "file": "src_8.png", "enhanced_file": "enhanced_src_8.png",
907
+ "is_dense_info": True, "min_canvas_fraction": 0.30,
908
+ }])
909
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5, has_fg_visual=True)
910
+ b["mediaRects"] = [{"x": 0.06, "y": 0.7, "w": 0.10, "h": 0.10}] # 无 sourceFile(旧 manifest)
911
+ _p, warnings_, _d = self._run([b], source_layout_plan_path=plan)
912
+ self.assertFalse(any("信息集锦图槽位偏窄" in w for w in warnings_), warnings_)
913
+
914
+ def test_original_file_stem_also_matches(self):
915
+ # sourceFile 直接是原图文件名(非 enhanced_ 前缀)也能匹配。
916
+ plan = self._plan([{
917
+ "file": "src_8.png", "enhanced_file": "enhanced_src_8.png",
918
+ "is_dense_info": True, "min_canvas_fraction": 0.30,
919
+ }])
920
+ b = balance(0.6, 0.5, 0.5, 0.5, 0.5, has_fg_visual=True)
921
+ b["mediaRects"] = [{"x": 0.06, "y": 0.7, "w": 0.20, "h": 0.30, "sourceFile": "src_8.png"}]
922
+ _p, warnings_, _d = self._run([b], source_layout_plan_path=plan)
923
+ self.assertTrue(any("信息集锦图槽位偏窄" in w for w in warnings_), warnings_)
924
+
925
+
926
+ if __name__ == "__main__":
927
+ unittest.main(verbosity=2)