scriptorium 0.0.3 → 0.7.2

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 (353) hide show
  1. checksums.yaml +4 -4
  2. data/README.lt3 +324 -0
  3. data/README.md +3155 -1
  4. data/assets/.DS_Store +0 -0
  5. data/assets/README.md +44 -0
  6. data/assets/icons/social/reddit.png +0 -0
  7. data/assets/icons/social/x-logo.png +0 -0
  8. data/assets/icons/ui/.DS_Store +0 -0
  9. data/assets/icons/ui/back.png +0 -0
  10. data/assets/icons/ui/copy.png +0 -0
  11. data/assets/icons/ui/down.png +0 -0
  12. data/assets/icons/ui/end.png +0 -0
  13. data/assets/icons/ui/exit.png +0 -0
  14. data/assets/icons/ui/foo +10 -0
  15. data/assets/icons/ui/home.png +0 -0
  16. data/assets/icons/ui/left.png +0 -0
  17. data/assets/icons/ui/next.png +0 -0
  18. data/assets/icons/ui/right.png +0 -0
  19. data/assets/icons/ui/start.png +0 -0
  20. data/assets/icons/ui/up.png +0 -0
  21. data/assets/imagenotfound.jpg +0 -0
  22. data/assets/samples/placeholder.svg +9 -0
  23. data/assets/themes/standard/favicon.svg +6 -0
  24. data/bin/sblog +84 -5
  25. data/bin/scriptorium +1 -0
  26. data/doc/README.txt +6 -0
  27. data/doc/anti-amnesia/20250727-054000-scriptorium-overview.md +94 -0
  28. data/doc/anti-amnesia/20250727-123000-anti-amnesia-conventions.md +2 -0
  29. data/doc/anti-amnesia/20250727-172600-cursor-rbenv-ruby-version-mystery.md +45 -0
  30. data/doc/anti-amnesia/20250727-172900-ai-cognitive-assessment-capabilities.md +40 -0
  31. data/doc/anti-amnesia/20250728-124243-aaa-syntax-clarification.md +46 -0
  32. data/doc/anti-amnesia/20250729-210000-reddit-autopost-integration-complete.md +158 -0
  33. data/doc/anti-amnesia/20250804-190500-cognitive-loop-bug.md +35 -0
  34. data/doc/anti-amnesia/20250804-190700-anti-amnesia-timestamping-fix.md +27 -0
  35. data/doc/anti-amnesia/20250807-213025.md +116 -0
  36. data/doc/anti-amnesia/20250901-211714-codemirror-integration-and-web-tests.md +172 -0
  37. data/doc/anti-amnesia/20250902-002402-backup-restore-system.md +126 -0
  38. data/doc/anti-amnesia/20250907-203339-backup-metadata-implementation.md +66 -0
  39. data/doc/banner_svg_config.md +114 -0
  40. data/doc/contrib.lt3 +8 -0
  41. data/doc/dependencies.md +281 -0
  42. data/doc/hacker.lt3 +5 -0
  43. data/doc/imported/0001-elixir-conf-2014/metadata.txt +7 -0
  44. data/doc/imported/0001-elixir-conf-2014/post.html +37 -0
  45. data/doc/imported/0001-elixir-conf-2014/source.lt3 +22 -0
  46. data/doc/imported/0002-programmers-and-word-processing/metadata.txt +7 -0
  47. data/doc/imported/0002-programmers-and-word-processing/post.html +192 -0
  48. data/doc/imported/0002-programmers-and-word-processing/source.lt3 +146 -0
  49. data/doc/imported/0003-how-to-turn-your-brain-sideways/metadata.txt +7 -0
  50. data/doc/imported/0003-how-to-turn-your-brain-sideways/post.html +60 -0
  51. data/doc/imported/0003-how-to-turn-your-brain-sideways/source.lt3 +40 -0
  52. data/doc/imported/0004-upcoming-lone-star-ruby-conference/metadata.txt +7 -0
  53. data/doc/imported/0004-upcoming-lone-star-ruby-conference/post.html +42 -0
  54. data/doc/imported/0004-upcoming-lone-star-ruby-conference/source.lt3 +24 -0
  55. data/doc/imported/0005-elixir-conf-2015-announced/metadata.txt +7 -0
  56. data/doc/imported/0005-elixir-conf-2015-announced/post.html +30 -0
  57. data/doc/imported/0005-elixir-conf-2015-announced/source.lt3 +16 -0
  58. data/doc/imported/0006-ruby-for-dinosaurs/metadata.txt +7 -0
  59. data/doc/imported/0006-ruby-for-dinosaurs/post.html +43 -0
  60. data/doc/imported/0006-ruby-for-dinosaurs/source.lt3 +27 -0
  61. data/doc/imported/0007-phoenix-isnt-rails/metadata.txt +7 -0
  62. data/doc/imported/0007-phoenix-isnt-rails/post.html +116 -0
  63. data/doc/imported/0007-phoenix-isnt-rails/source.lt3 +87 -0
  64. data/doc/imported/0008-concerning-the-term-monkeypatching/metadata.txt +7 -0
  65. data/doc/imported/0008-concerning-the-term-monkeypatching/post.html +129 -0
  66. data/doc/imported/0008-concerning-the-term-monkeypatching/source.lt3 +92 -0
  67. data/doc/imported/0009-announcement-coming-soon/metadata.txt +7 -0
  68. data/doc/imported/0009-announcement-coming-soon/post.html +33 -0
  69. data/doc/imported/0009-announcement-coming-soon/source.lt3 +19 -0
  70. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/metadata.txt +7 -0
  71. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/post.html +175 -0
  72. data/doc/imported/0010-immutable-data-ditching-the-wax-tablet/source.lt3 +139 -0
  73. data/doc/imported/0011-computer-science-as-a-lost-art/metadata.txt +7 -0
  74. data/doc/imported/0011-computer-science-as-a-lost-art/post.html +139 -0
  75. data/doc/imported/0011-computer-science-as-a-lost-art/source.lt3 +104 -0
  76. data/doc/imported/0012-ruby-day-in-turin-italy/metadata.txt +7 -0
  77. data/doc/imported/0012-ruby-day-in-turin-italy/post.html +42 -0
  78. data/doc/imported/0012-ruby-day-in-turin-italy/source.lt3 +24 -0
  79. data/doc/imported/0013-rubyday-was-a-success/metadata.txt +7 -0
  80. data/doc/imported/0013-rubyday-was-a-success/post.html +44 -0
  81. data/doc/imported/0013-rubyday-was-a-success/source.lt3 +27 -0
  82. data/doc/imported/0014-working-on-the-blogging-software/metadata.txt +7 -0
  83. data/doc/imported/0014-working-on-the-blogging-software/post.html +63 -0
  84. data/doc/imported/0014-working-on-the-blogging-software/source.lt3 +41 -0
  85. data/doc/imported/0015-ok-its-not-really-a-lost-art/metadata.txt +7 -0
  86. data/doc/imported/0015-ok-its-not-really-a-lost-art/post.html +172 -0
  87. data/doc/imported/0015-ok-its-not-really-a-lost-art/source.lt3 +134 -0
  88. data/doc/imported/0016-an-in-operator-for-ruby/metadata.txt +7 -0
  89. data/doc/imported/0016-an-in-operator-for-ruby/post.html +155 -0
  90. data/doc/imported/0016-an-in-operator-for-ruby/source.lt3 +106 -0
  91. data/doc/imported/0017-the-forgotten-mathematician/metadata.txt +7 -0
  92. data/doc/imported/0017-the-forgotten-mathematician/post.html +161 -0
  93. data/doc/imported/0017-the-forgotten-mathematician/source.lt3 +119 -0
  94. data/doc/imported/0018-ruby-puns/metadata.txt +7 -0
  95. data/doc/imported/0018-ruby-puns/post.html +46 -0
  96. data/doc/imported/0018-ruby-puns/source.lt3 +28 -0
  97. data/doc/imported/0019-custom-exceptions-via-metaprogramming/metadata.txt +7 -0
  98. data/doc/imported/0019-custom-exceptions-via-metaprogramming/post.html +138 -0
  99. data/doc/imported/0019-custom-exceptions-via-metaprogramming/source.lt3 +101 -0
  100. data/doc/imported/0020-fffff/metadata.txt +7 -0
  101. data/doc/imported/0020-fffff/post.html +24 -0
  102. data/doc/imported/0020-fffff/source.lt3 +12 -0
  103. data/doc/imported/0021-trying-ror-yet-again/metadata.txt +7 -0
  104. data/doc/imported/0021-trying-ror-yet-again/post.html +26 -0
  105. data/doc/imported/0021-trying-ror-yet-again/source.lt3 +12 -0
  106. data/doc/imported/0023-doctor-sleep/metadata.txt +7 -0
  107. data/doc/imported/0023-doctor-sleep/post.html +63 -0
  108. data/doc/imported/0023-doctor-sleep/source.lt3 +44 -0
  109. data/doc/imported/0024-just-a-test/metadata.txt +7 -0
  110. data/doc/imported/0024-just-a-test/post.html +24 -0
  111. data/doc/imported/0024-just-a-test/source.lt3 +12 -0
  112. data/doc/imported/import_summary.txt +98 -0
  113. data/doc/livetext-informal-spec.txt +65 -0
  114. data/doc/myuserdoc/ch-0.lt3 +31 -0
  115. data/doc/myuserdoc/ch-1.lt3 +37 -0
  116. data/doc/myuserdoc/ch-10.lt3 +22 -0
  117. data/doc/myuserdoc/ch-2.lt3 +37 -0
  118. data/doc/myuserdoc/ch-3.lt3 +19 -0
  119. data/doc/myuserdoc/ch-4.lt3 +43 -0
  120. data/doc/myuserdoc/ch-5.lt3 +22 -0
  121. data/doc/myuserdoc/ch-6.lt3 +19 -0
  122. data/doc/myuserdoc/ch-7.lt3 +16 -0
  123. data/doc/myuserdoc/ch-8.lt3 +13 -0
  124. data/doc/myuserdoc/ch-9.lt3 +19 -0
  125. data/doc/myuserdoc/tweak.rb +18 -0
  126. data/doc/myuserdoc/userdoc-toc.txt +88 -0
  127. data/doc/old-posts/0001-elixir-conf-2014.lt3 +24 -0
  128. data/doc/old-posts/0002-programmers-and-word-processing.lt3 +150 -0
  129. data/doc/old-posts/0003-how-to-turn-your-brain-sideways.lt3 +43 -0
  130. data/doc/old-posts/0004-upcoming-lone-star-ruby-conference.lt3 +26 -0
  131. data/doc/old-posts/0005-elixir-conf-2015-announced.lt3 +17 -0
  132. data/doc/old-posts/0006-ruby-for-dinosaurs.lt3 +30 -0
  133. data/doc/old-posts/0007-phoenix-isnt-rails.lt3 +90 -0
  134. data/doc/old-posts/0008-concerning-the-term-monkeypatching.lt3 +105 -0
  135. data/doc/old-posts/0009-announcement-coming-soon.lt3 +20 -0
  136. data/doc/old-posts/0010-immutable-data-ditching-the-wax-tablet.lt3 +142 -0
  137. data/doc/old-posts/0011-computer-science-as-a-lost-art.lt3 +117 -0
  138. data/doc/old-posts/0012-ruby-day-in-turin-italy.lt3 +26 -0
  139. data/doc/old-posts/0013-rubyday-was-a-success.lt3 +28 -0
  140. data/doc/old-posts/0014-working-on-the-blogging-software.lt3 +42 -0
  141. data/doc/old-posts/0015-ok-its-not-really-a-lost-art.lt3 +137 -0
  142. data/doc/old-posts/0016-an-in-operator-for-ruby.lt3 +142 -0
  143. data/doc/old-posts/0017-the-forgotten-mathematician.lt3 +129 -0
  144. data/doc/old-posts/0018-ruby-puns.lt3 +31 -0
  145. data/doc/old-posts/0019-custom-exceptions-via-metaprogramming.lt3 +116 -0
  146. data/doc/old-posts/0021-trying-ror-yet-again.lt3 +35 -0
  147. data/doc/old-posts/0023-doctor-sleep.lt3 +43 -0
  148. data/doc/old-posts/0024-just-a-test.lt3 +12 -0
  149. data/doc/old-posts/0025-trying-another-post.lt3 +12 -0
  150. data/doc/old-repo +1 -0
  151. data/doc/reddit_credentials_template.json +8 -0
  152. data/doc/reddit_integration.md +207 -0
  153. data/doc/user.lt3 +35 -0
  154. data/doc/user_guide_section_1.md +137 -0
  155. data/doc/user_guide_section_10.md +515 -0
  156. data/doc/user_guide_section_11.md +708 -0
  157. data/doc/user_guide_section_2.md +233 -0
  158. data/doc/user_guide_section_3.md +5 -0
  159. data/doc/user_guide_section_4.md +221 -0
  160. data/doc/user_guide_section_5.md +243 -0
  161. data/doc/user_guide_section_6.md +147 -0
  162. data/doc/user_guide_section_7.md +311 -0
  163. data/doc/user_guide_section_8.md +224 -0
  164. data/doc/user_guide_section_9.md +375 -0
  165. data/lib/rouge/lexers/livetext.rb +74 -0
  166. data/lib/scriptorium/api.rb +2373 -0
  167. data/lib/scriptorium/banner_svg.rb +729 -0
  168. data/lib/scriptorium/contract.rb +34 -0
  169. data/lib/scriptorium/exceptions.rb +201 -1
  170. data/lib/scriptorium/helpers.rb +675 -0
  171. data/lib/scriptorium/post.rb +259 -0
  172. data/lib/scriptorium/reddit.rb +83 -0
  173. data/lib/scriptorium/repo.rb +938 -0
  174. data/lib/scriptorium/standard_files.rb +149 -0
  175. data/lib/scriptorium/support/bootstrap/css.txt +5 -0
  176. data/lib/scriptorium/support/bootstrap/js.txt +4 -0
  177. data/lib/scriptorium/support/common_js/clipboard.js +35 -0
  178. data/lib/scriptorium/support/common_js/content-loader.js +187 -0
  179. data/lib/scriptorium/support/common_js/navigation.js +52 -0
  180. data/lib/scriptorium/support/common_js/syntax-highlighting.js +27 -0
  181. data/lib/scriptorium/support/config/reddit.txt +10 -0
  182. data/lib/scriptorium/support/config/reddit_template.txt +17 -0
  183. data/lib/scriptorium/support/config/social.txt +8 -0
  184. data/lib/scriptorium/support/highlight/css.txt +2 -0
  185. data/lib/scriptorium/support/highlight/custom.css +119 -0
  186. data/lib/scriptorium/support/highlight/js.txt +1 -0
  187. data/lib/scriptorium/support/post_index/config.txt +15 -0
  188. data/lib/scriptorium/support/post_index/style.css +55 -0
  189. data/lib/scriptorium/support/templates/index_entry.lt3 +16 -0
  190. data/lib/scriptorium/support/templates/initial_post.lt3 +12 -0
  191. data/lib/scriptorium/support/templates/layout.txt +5 -0
  192. data/lib/scriptorium/support/templates/post.lt3 +104 -0
  193. data/lib/scriptorium/support/theme/footer.lt3 +2 -0
  194. data/lib/scriptorium/support/theme/header.lt3 +4 -0
  195. data/lib/scriptorium/support/theme/left.lt3 +3 -0
  196. data/lib/scriptorium/support/theme/main.lt3 +5 -0
  197. data/lib/scriptorium/support/theme/right.lt3 +3 -0
  198. data/lib/scriptorium/theme.rb +192 -0
  199. data/lib/scriptorium/version.rb +1 -1
  200. data/lib/scriptorium/view.rb +1021 -0
  201. data/lib/scriptorium/widgets/featured_posts.rb +149 -0
  202. data/lib/scriptorium/widgets/links.rb +112 -0
  203. data/lib/scriptorium/widgets/pages.rb +133 -0
  204. data/lib/scriptorium/widgets/widget.rb +133 -0
  205. data/lib/scriptorium.rb +38 -34
  206. data/lib/skeleton.rb +10 -1
  207. data/scriptorium.gemspec +17 -5
  208. data/test/README.md +69 -0
  209. data/test/WEB_INTEGRATION_README.md +196 -0
  210. data/test/all +83 -0
  211. data/test/api_demo.rb +99 -0
  212. data/test/assets/imagenotfound.jpg +0 -0
  213. data/test/assets/images/.DS_Store +0 -0
  214. data/test/assets/images/README.md +27 -0
  215. data/test/assets/images/odd_aspect.png +0 -0
  216. data/test/assets/images/perfect.png +0 -0
  217. data/test/assets/images/small.png +0 -0
  218. data/test/assets/images/tall.png +0 -0
  219. data/test/assets/images/very_tall.png +0 -0
  220. data/test/assets/images/very_wide.png +0 -0
  221. data/test/assets/images/wide.png +0 -0
  222. data/test/assets/testbanner.jpg +0 -0
  223. data/test/banner_svg/simple_helpers.rb +13 -0
  224. data/test/banner_svg/unit.rb +1000 -0
  225. data/test/config/deployment.txt +5 -0
  226. data/test/ed_test.rb +204 -0
  227. data/test/integration/cursor_banner_combinations.rb +193 -0
  228. data/test/integration/cursor_banner_features.rb +374 -0
  229. data/test/integration/integration_test.rb +326 -0
  230. data/test/integration/preview_flow_test.rb +94 -0
  231. data/test/livetext_plugin_test.rb +500 -0
  232. data/test/manual/asset_mgmt.rb +67 -0
  233. data/test/manual/banner-tests/index.html +45 -0
  234. data/test/manual/banner-tests/svg.txt +3 -0
  235. data/test/manual/banner-tests/test01.html +122 -0
  236. data/test/manual/banner-tests/test02.html +122 -0
  237. data/test/manual/banner-tests/test03.html +122 -0
  238. data/test/manual/banner-tests/test04.html +129 -0
  239. data/test/manual/banner-tests/test05.html +129 -0
  240. data/test/manual/banner-tests/test06.html +129 -0
  241. data/test/manual/banner-tests/test07.html +129 -0
  242. data/test/manual/banner-tests/test08.html +123 -0
  243. data/test/manual/banner-tests/test09.html +123 -0
  244. data/test/manual/banner-tests/test10.html +123 -0
  245. data/test/manual/banner-tests/test11.html +123 -0
  246. data/test/manual/banner-tests/test12.html +123 -0
  247. data/test/manual/banner-tests/test13.html +123 -0
  248. data/test/manual/banner-tests/test14.html +123 -0
  249. data/test/manual/banner-tests/test15.html +122 -0
  250. data/test/manual/banner-tests/test16.html +122 -0
  251. data/test/manual/banner-tests/test17.html +122 -0
  252. data/test/manual/banner-tests/test18.html +132 -0
  253. data/test/manual/banner-tests/test19.html +132 -0
  254. data/test/manual/banner-tests/test20.html +132 -0
  255. data/test/manual/banner-tests/test21.html +132 -0
  256. data/test/manual/banner-tests/test22.html +132 -0
  257. data/test/manual/banner-tests/test23.html +132 -0
  258. data/test/manual/banner-tests/test24.html +132 -0
  259. data/test/manual/banner-tests/test25.html +131 -0
  260. data/test/manual/banner_environment.rb +205 -0
  261. data/test/manual/codemirror_demo.html +773 -0
  262. data/test/manual/create_posts_for_web.rb +114 -0
  263. data/test/manual/environment.rb +67 -0
  264. data/test/manual/make_banner.rb +153 -0
  265. data/test/manual/preview_manual_test.rb +129 -0
  266. data/test/manual/sample_banner_config.txt +12 -0
  267. data/test/manual/test_advanced_widgets.rb +73 -0
  268. data/test/manual/test_banner_combinations.rb +120 -0
  269. data/test/manual/test_banner_features.rb +306 -0
  270. data/test/manual/test_banner_integration.rb +115 -0
  271. data/test/manual/test_banner_radial.rb +87 -0
  272. data/test/manual/test_basic_posts.rb +47 -0
  273. data/test/manual/test_layout_widgets.rb +40 -0
  274. data/test/manual/test_pagination.rb +24 -0
  275. data/test/manual/test_random_posts.rb +38 -0
  276. data/test/manual/test_syntax_highlighting.rb +167 -0
  277. data/test/rubytext/rubytext_comprehensive_test.rb +307 -0
  278. data/test/rubytext/rubytext_demo_test.rb +42 -0
  279. data/test/rubytext/rubytext_testing_guide.md +277 -0
  280. data/test/run_automated_tests.rb +45 -0
  281. data/test/staging/.DS_Store +0 -0
  282. data/test/support/preview_utils.rb +88 -0
  283. data/test/syntax_highlighting_test.lt3 +124 -0
  284. data/test/test_gem_assets.rb +48 -0
  285. data/test/test_helpers.rb +240 -0
  286. data/test/tui_editor_integration_test.rb +296 -0
  287. data/test/tui_integration_test.rb +883 -0
  288. data/test/unit/api.rb +1776 -0
  289. data/test/unit/asset_management.rb +219 -0
  290. data/test/unit/backup_test.rb +451 -0
  291. data/test/unit/clipboard_test.rb +60 -0
  292. data/test/unit/contract_test.rb +69 -0
  293. data/test/unit/core.rb +1211 -0
  294. data/test/unit/deploy_config_test.rb +248 -0
  295. data/test/unit/deploy_test.rb +478 -0
  296. data/test/unit/edit_post_test.rb +168 -0
  297. data/test/unit/gem_asset_management.rb +183 -0
  298. data/test/unit/livetext_basic.rb +57 -0
  299. data/test/unit/livetext_compatibility.rb +82 -0
  300. data/test/unit/parse_cmd_test.rb +260 -0
  301. data/test/unit/permalink_copy_test.rb +211 -0
  302. data/test/unit/post.rb +309 -0
  303. data/test/unit/post_index_config_test.rb +258 -0
  304. data/test/unit/post_state_helpers_test.rb +137 -0
  305. data/test/unit/read_commented_file_test.rb +278 -0
  306. data/test/unit/reddit_test.rb +235 -0
  307. data/test/unit/repo.rb +569 -0
  308. data/test/unit/social_test.rb +366 -0
  309. data/test/unit/syntax_highlighting.rb +70 -0
  310. data/test/unit/theme_management_test.rb +91 -0
  311. data/test/unit/view.rb +498 -0
  312. data/test/unit/widgets.rb +669 -0
  313. data/test/web_integration_test.rb +231 -0
  314. data/test/web_test_helper.rb +218 -0
  315. data/test/web_workflow_test.rb +527 -0
  316. data/test/wizard_test.rb +123 -0
  317. data/ui/README.md +67 -0
  318. data/ui/common/lib/ui_common.rb +8 -0
  319. data/ui/rubytext/README.md +191 -0
  320. data/ui/rubytext/bin/scriptorium-rubytext +402 -0
  321. data/ui/rubytext/lib/rubytext_ui.rb +300 -0
  322. data/ui/tui/bin/scriptorium +1890 -0
  323. data/ui/tui/test/tui_test.rb +23 -0
  324. data/ui/web/app/app.rb +2600 -0
  325. data/ui/web/app/assets/livetext_mode.js +244 -0
  326. data/ui/web/app/error_helpers.rb +150 -0
  327. data/ui/web/app/views/advanced_config.erb +196 -0
  328. data/ui/web/app/views/asset_management.erb +645 -0
  329. data/ui/web/app/views/backup_management.erb +238 -0
  330. data/ui/web/app/views/banner_config.erb +200 -0
  331. data/ui/web/app/views/config_widget.erb +232 -0
  332. data/ui/web/app/views/configure_view.erb +401 -0
  333. data/ui/web/app/views/dashboard.erb +154 -0
  334. data/ui/web/app/views/deploy_config.erb +149 -0
  335. data/ui/web/app/views/edit_pages.erb +363 -0
  336. data/ui/web/app/views/edit_post.erb +175 -0
  337. data/ui/web/app/views/edit_theme.erb +73 -0
  338. data/ui/web/app/views/edit_theme_file.erb +74 -0
  339. data/ui/web/app/views/error_page.erb +29 -0
  340. data/ui/web/app/views/header_config.erb +155 -0
  341. data/ui/web/app/views/layout_config.erb +147 -0
  342. data/ui/web/app/views/navbar_config.erb +411 -0
  343. data/ui/web/app/views/theme_management.erb +130 -0
  344. data/ui/web/app/views/view_dashboard.erb +779 -0
  345. data/ui/web/app/views/widgets.erb +249 -0
  346. data/ui/web/bin/scriptorium-web +164 -0
  347. data/ui/web/test/web_basic_test.rb +38 -0
  348. data/ui/web/test_navbar.txt +7 -0
  349. data/ui/web/tmp/timing.log +17 -0
  350. data/ui/web/tmp/web_server.log +0 -0
  351. metadata +434 -8
  352. data/lib/scriptorium/engine.rb +0 -22
  353. data/test/engine/unit.rb +0 -44
data/test/unit/api.rb ADDED
@@ -0,0 +1,1776 @@
1
+ # test/unit/api.rb
2
+
3
+
4
+ require 'minitest/autorun'
5
+ require 'open3'
6
+ require_relative '../../lib/scriptorium'
7
+ require_relative '../test_helpers'
8
+
9
+ class TestScriptoriumAPI < Minitest::Test
10
+ include Scriptorium::Exceptions
11
+ include Scriptorium::Helpers
12
+
13
+
14
+
15
+ def teardown
16
+ FileUtils.rm_rf(@test_dir) if Dir.exist?(@test_dir)
17
+ @api = nil
18
+ end
19
+
20
+ def setup
21
+ @test_dir = "test/scriptorium-TEST"
22
+ # Clean up any existing test directory first
23
+ FileUtils.rm_rf(@test_dir) if Dir.exist?(@test_dir)
24
+ @api = Scriptorium::API.new(testmode: true)
25
+ @api.create_repo(@test_dir)
26
+ end
27
+
28
+ # Basic API functionality tests
29
+
30
+ def test_001_api_initialization
31
+ assert_instance_of Scriptorium::API, @api
32
+ assert_instance_of Scriptorium::Repo, @api.repo
33
+ refute_nil @api.current_view
34
+ assert_equal "sample", @api.current_view.name
35
+ end
36
+
37
+ def test_002_create_view
38
+ @api.create_view("test_view", "Test View", "A test view")
39
+
40
+ assert_equal "test_view", @api.current_view.name
41
+ assert_equal "Test View", @api.current_view.title
42
+ assert_equal "A test view", @api.current_view.subtitle
43
+ end
44
+
45
+ def test_003_create_post
46
+ @api.create_view("test_view", "Test View")
47
+ post = @api.create_post("Test Post", "Test body", tags: ["test"])
48
+
49
+ assert_instance_of Scriptorium::Post, post
50
+ assert_equal "Test Post", post.title
51
+ assert_equal "test", post.tags
52
+ end
53
+
54
+ def test_004_create_page
55
+ @api.create_view("test_view", "Test View")
56
+
57
+ page_name = @api.create_page("test_view", "about", "About Us", "This is our about page content.")
58
+
59
+ assert_equal "about", page_name
60
+
61
+ # Check that the page file was created
62
+ page_file = "test/scriptorium-TEST/views/test_view/pages/about.lt3"
63
+ assert File.exist?(page_file), "Page file should exist"
64
+
65
+ # Check the content
66
+ content = read_file(page_file)
67
+ assert_includes content, ".title About Us"
68
+ assert_includes content, "This is our about page content."
69
+ end
70
+
71
+ def test_005_posts
72
+ @api.create_view("test_view", "Test View")
73
+ @api.create_post("Post 1", "Body 1")
74
+ @api.create_post("Post 2", "Body 2")
75
+
76
+ posts = @api.posts
77
+ assert_equal 2, posts.length
78
+ # Posts might not be in creation order, so check both exist
79
+ titles = posts.map(&:title)
80
+ assert_includes titles, "Post 1"
81
+ assert_includes titles, "Post 2"
82
+ end
83
+
84
+ def test_005_post
85
+ @api.create_view("test_view", "Test View")
86
+ created_post = @api.create_post("Test Post", "Test body")
87
+
88
+ retrieved_post = @api.post(created_post.id)
89
+ assert_equal created_post.id, retrieved_post.id
90
+ assert_equal "Test Post", retrieved_post.title
91
+ end
92
+
93
+ # New API methods tests
94
+ def test_006_views
95
+ @api.create_view("view1", "View 1")
96
+ @api.create_view("view2", "View 2")
97
+
98
+ views = @api.views.map(&:name)
99
+ assert_includes views, "view1"
100
+ assert_includes views, "view2"
101
+ end
102
+
103
+ def test_007_post_attrs
104
+ @api.create_view("test_view", "Test View")
105
+ post = @api.create_post("Test Post", "Test body", tags: ["test", "demo"])
106
+
107
+ attrs = @api.post_attrs(post.id, :title, :tags)
108
+ assert_equal ["Test Post", "test, demo"], attrs
109
+ end
110
+
111
+ def test_008_post_attrs_with_post_object
112
+ @api.create_view("test_view", "Test View")
113
+ post = @api.create_post("Test Post", "Test body", tags: ["test"])
114
+
115
+ attrs = @api.post_attrs(post, :title, :tags)
116
+ assert_equal ["Test Post", "test"], attrs
117
+ end
118
+
119
+ def test_009_views_for
120
+ @api.create_view("test_view", "Test View")
121
+ post = @api.create_post("Test Post", "Test body")
122
+
123
+ views = @api.views_for(post)
124
+ assert_equal ["test_view"], views
125
+ end
126
+
127
+ def test_010_views_for_with_id
128
+ @api.create_view("test_view", "Test View")
129
+ post = @api.create_post("Test Post", "Test body")
130
+
131
+ views = @api.views_for(post.id)
132
+ assert_equal ["test_view"], views
133
+ end
134
+
135
+ def test_011_apply_theme
136
+ @api.create_view("test_view", "Test View")
137
+
138
+ # Should not raise an error
139
+ @api.apply_theme("standard")
140
+ assert_equal "standard", @api.current_view.theme
141
+ end
142
+
143
+
144
+
145
+ # Empty methods tests (should not raise errors)
146
+ def test_012_empty_methods
147
+ assert_equal [], @api.drafts
148
+ # Widgets are now available from widgets.txt
149
+ widgets = @api.widgets_available
150
+ assert_includes widgets, "links"
151
+ assert_includes widgets, "pages"
152
+
153
+ # These should not raise errors
154
+ # @api.delete_draft("some_path") # This would fail since it's not a valid draft path
155
+ # @api.delete_post(1) # This would fail since post doesn't exist
156
+ # @api.generate_widget("some_widget") # This would fail since no current view set
157
+ @api.select_posts { |p| true }
158
+ @api.search_posts(title: "query")
159
+ # @api.unlink_post(nil, nil) # This would fail since no current view and invalid post
160
+ # @api.generate_all # This would fail since no current view set
161
+ end
162
+
163
+ def test_013_drafts
164
+ drafts = @api.drafts
165
+ assert_instance_of Array, drafts
166
+ # Should return empty array if no drafts directory exists
167
+ end
168
+
169
+ def test_014_themes_available
170
+ themes = @api.themes_available
171
+ assert_instance_of Array, themes
172
+
173
+ # Should have the standard theme
174
+ assert_includes themes, "standard"
175
+
176
+ # Check system vs user themes
177
+ system_themes = @api.system_themes
178
+ user_themes = @api.user_themes
179
+
180
+ assert_includes system_themes, "standard"
181
+ assert_empty user_themes # No user themes yet
182
+ end
183
+
184
+ def test_015_clone_theme
185
+ # Clone the standard theme
186
+ result = @api.clone_theme("standard", "my-custom")
187
+ assert_equal "my-custom", result
188
+
189
+ # Check that the new theme exists
190
+ themes = @api.themes_available
191
+ assert_includes themes, "my-custom"
192
+
193
+ # Check that it's now a user theme
194
+ user_themes = @api.user_themes
195
+ assert_includes user_themes, "my-custom"
196
+
197
+ # Check that standard is still a system theme
198
+ system_themes = @api.system_themes
199
+ assert_includes system_themes, "standard"
200
+ end
201
+
202
+ def test_016_clone_theme_validation
203
+ # Try to clone to existing theme name
204
+ assert_raises(ThemeAlreadyExists) do
205
+ @api.clone_theme("standard", "standard")
206
+ end
207
+
208
+ # Try to clone from non-existent theme
209
+ assert_raises(ThemeNotFound) do
210
+ @api.clone_theme("nonexistent", "new-theme")
211
+ end
212
+
213
+ # Try to clone with invalid name
214
+ assert_raises(ThemeNameInvalid) do
215
+ @api.clone_theme("standard", "invalid name with spaces")
216
+ end
217
+ end
218
+
219
+ def test_017_widgets_available
220
+ widgets = @api.widgets_available
221
+ assert_instance_of Array, widgets
222
+ # Should return available widgets from widgets.txt
223
+ assert_includes widgets, "links"
224
+ assert_includes widgets, "pages"
225
+ end
226
+
227
+ def test_016_generate_view
228
+ @api.create_view("test_view", "Test View")
229
+
230
+ # Should not raise an error
231
+ @api.generate_view
232
+ end
233
+
234
+ def test_017_generate_view_with_specific_view
235
+ @api.create_view("view1", "View 1")
236
+ @api.create_view("view2", "View 2")
237
+
238
+ # Should not raise an error
239
+ @api.generate_view("view1")
240
+ end
241
+
242
+
243
+
244
+ # Error handling tests
245
+ def test_018_create_post_without_view
246
+ # Clear the current view directly
247
+ @api.repo.instance_variable_set(:@current_view, nil)
248
+
249
+ assert_raises(ViewTargetNil) do
250
+ @api.create_post("Test Post", "Test body")
251
+ end
252
+ end
253
+
254
+ def test_019_safe_delete_post
255
+ @api.create_view("test_view", "Test View")
256
+ post = @api.create_post("Test Post", "Test body")
257
+
258
+ # Initially visible
259
+ posts = @api.posts
260
+ assert_equal 1, posts.length
261
+
262
+ # Delete the post
263
+ @api.delete_post(post.id)
264
+
265
+ # Should not appear in posts list
266
+ posts = @api.posts
267
+ assert_equal 0, posts.length
268
+
269
+ # But post object still exists and can be retrieved
270
+ retrieved_post = @api.post(post.id)
271
+ assert_equal "Test Post", retrieved_post.title
272
+ end
273
+
274
+ def test_020_undelete_post
275
+ @api.create_view("test_view", "Test View")
276
+ post = @api.create_post("Test Post", "Test body")
277
+
278
+ # Delete the post
279
+ @api.delete_post(post.id)
280
+ assert_equal 0, @api.posts.length
281
+
282
+ # Undelete the post
283
+ @api.undelete_post(post.id)
284
+
285
+ # Should appear in posts list again
286
+ posts = @api.posts
287
+ assert_equal 1, posts.length
288
+ assert_equal "Test Post", posts[0].title
289
+ end
290
+
291
+ def test_021_update_post
292
+ @api.create_view("test_view", "Test View")
293
+ @api.create_view("other_view", "Other View")
294
+ post = @api.create_post("Test Post", "Test body", views: ["test_view", "other_view"])
295
+
296
+ # Update the views field
297
+ result = @api.update_post(post.id, {views: ["test_view"]})
298
+ assert result
299
+
300
+ # Check that the source file was updated
301
+ source_file = post.dir/"source.lt3"
302
+ content = read_file(source_file)
303
+ assert_includes content, ".views test_view"
304
+ assert_includes content, "# updated views"
305
+ end
306
+
307
+ def test_022_update_post_preserves_comments
308
+ @api.create_view("test_view", "Test View")
309
+ post = @api.create_post("Test Post", "Test body", views: ["test_view"])
310
+
311
+ # Manually add a comment to the source file
312
+ source_file = post.dir/"source.lt3"
313
+ lines = read_file(source_file, lines: true, chomp: false)
314
+ lines.map! do |line|
315
+ if line.strip.start_with?('.views')
316
+ ".views test_view # original comment\n"
317
+ else
318
+ line
319
+ end
320
+ end
321
+ write_file(source_file, lines.join)
322
+
323
+ # Update the views field
324
+ result = @api.update_post(post.id, {views: ["new_view"]})
325
+ assert result
326
+
327
+ # Check that original comment is preserved
328
+ content = read_file(source_file)
329
+ assert_includes content, "# original comment"
330
+ assert_includes content, "# updated views"
331
+ end
332
+
333
+ def test_023_update_post_multiple_fields
334
+ @api.create_view("test_view", "Test View")
335
+ post = @api.create_post("Test Post", "Test body", views: ["test_view"])
336
+
337
+ # Update multiple fields at once
338
+ result = @api.update_post(post.id, {
339
+ title: "Updated Title",
340
+ tags: ["new", "tags"]
341
+ })
342
+ assert result
343
+
344
+ # Check that both fields were updated
345
+ source_file = post.dir/"source.lt3"
346
+ content = read_file(source_file)
347
+ assert_includes content, ".title Updated Title"
348
+ assert_includes content, ".tags new, tags"
349
+ assert_includes content, "# updated title"
350
+ assert_includes content, "# updated tags"
351
+ end
352
+
353
+
354
+
355
+ def test_024_unlink_post
356
+ @api.create_view("test_view", "Test View")
357
+ @api.create_view("other_view", "Other View")
358
+ post = @api.create_post("Test Post", "Test body", views: ["test_view", "other_view"])
359
+
360
+ # Initially post should be in both views
361
+ assert_includes post.views, "test_view"
362
+ assert_includes post.views, "other_view"
363
+
364
+ # Unlink from current view
365
+ result = @api.unlink_post(post.id)
366
+ assert result
367
+
368
+ # Post should now only be in test_view (since we unlinked from other_view)
369
+ updated_post = @api.post(post.id)
370
+ updated_views = updated_post.views.strip.split(/\s+/)
371
+ assert_includes updated_views, "test_view"
372
+ refute_includes updated_views, "other_view"
373
+ end
374
+
375
+ def test_025_link_post
376
+ @api.create_view("test_view", "Test View")
377
+ @api.create_view("other_view", "Other View")
378
+ post = @api.create_post("Test Post", "Test body", views: ["test_view"])
379
+
380
+ # Initially post should only be in test_view
381
+ assert_includes post.views, "test_view"
382
+ refute_includes post.views, "other_view"
383
+
384
+ # Link to other_view
385
+ result = @api.link_post(post.id, "other_view")
386
+ assert result
387
+
388
+ # Post should now be in both views
389
+ updated_post = @api.post(post.id)
390
+ updated_views = updated_post.views.strip.split(/\s+/)
391
+ assert_includes updated_views, "test_view"
392
+ assert_includes updated_views, "other_view"
393
+ end
394
+
395
+ def test_026_link_post_current_view
396
+ @api.create_view("test_view", "Test View")
397
+ @api.create_view("other_view", "Other View")
398
+ post = @api.create_post("Test Post", "Test body", views: ["other_view"])
399
+
400
+ # Initially post should only be in other_view
401
+ assert_includes post.views, "other_view"
402
+ refute_includes post.views, "test_view"
403
+
404
+ # Set current view to test_view
405
+ @api.view("test_view")
406
+
407
+ # Link to current view (test_view)
408
+ result = @api.link_post(post.id)
409
+ assert result
410
+
411
+ # Post should now be in both views
412
+ updated_post = @api.post(post.id)
413
+ updated_views = updated_post.views.strip.split(/\s+/)
414
+ assert_includes updated_views, "test_view"
415
+ assert_includes updated_views, "other_view"
416
+ end
417
+
418
+ def test_027_link_post_duplicate
419
+ @api.create_view("test_view", "Test View")
420
+ post = @api.create_post("Test Post", "Test body", views: ["test_view"])
421
+
422
+ # Initially post should be in test_view
423
+ assert_includes post.views, "test_view"
424
+
425
+ # Try to link to the same view (should not add duplicate)
426
+ result = @api.link_post(post.id, "test_view")
427
+ assert result
428
+
429
+ # Post should still only be in test_view (no duplicates)
430
+ updated_post = @api.post(post.id)
431
+ updated_views = updated_post.views.strip.split(/\s+/)
432
+ assert_equal ["test_view"], updated_views
433
+ end
434
+
435
+ def test_028_post_add_view
436
+ @api.create_view("test_view", "Test View")
437
+ @api.create_view("other_view", "Other View")
438
+ post = @api.create_post("Test Post", "Test body", views: ["test_view"])
439
+
440
+ # Initially post should only be in test_view
441
+ assert_includes post.views, "test_view"
442
+ refute_includes post.views, "other_view"
443
+
444
+ # Add other_view to the post
445
+ result = @api.post_add_view(post.id, "other_view")
446
+ assert result
447
+
448
+ # Post should now be in both views
449
+ updated_post = @api.post(post.id)
450
+ updated_views = updated_post.views.strip.split(/\s+/)
451
+ assert_includes updated_views, "test_view"
452
+ assert_includes updated_views, "other_view"
453
+ end
454
+
455
+ def test_029_post_add_view_with_view_object
456
+ @api.create_view("test_view", "Test View")
457
+ @api.create_view("other_view", "Other View")
458
+ other_view = @api.view("other_view") # Get the View object
459
+ post = @api.create_post("Test Post", "Test body", views: ["test_view"])
460
+
461
+ # Add other_view to the post using View object
462
+ result = @api.post_add_view(post.id, other_view)
463
+ assert result
464
+
465
+ # Post should now be in both views
466
+ updated_post = @api.post(post.id)
467
+ updated_views = updated_post.views.strip.split(/\s+/)
468
+ assert_includes updated_views, "test_view"
469
+ assert_includes updated_views, "other_view"
470
+ end
471
+
472
+ def test_030_post_remove_view
473
+ @api.create_view("test_view", "Test View")
474
+ @api.create_view("other_view", "Other View")
475
+ post = @api.create_post("Test Post", "Test body", views: ["test_view", "other_view"])
476
+
477
+ # Initially post should be in both views
478
+ assert_includes post.views, "test_view"
479
+ assert_includes post.views, "other_view"
480
+
481
+ # Remove other_view from the post
482
+ result = @api.post_remove_view(post.id, "other_view")
483
+ assert result
484
+
485
+ # Post should now only be in test_view
486
+ updated_post = @api.post(post.id)
487
+ updated_views = updated_post.views.strip.split(/\s+/)
488
+ assert_includes updated_views, "test_view"
489
+ refute_includes updated_views, "other_view"
490
+ end
491
+
492
+ def test_031_post_remove_view_with_view_object
493
+ @api.create_view("test_view", "Test View")
494
+ @api.create_view("other_view", "Other View")
495
+ other_view = @api.view("other_view") # Get the View object
496
+ post = @api.create_post("Test Post", "Test body", views: ["test_view", "other_view"])
497
+
498
+ # Remove other_view from the post using View object
499
+ result = @api.post_remove_view(post.id, other_view)
500
+ assert result
501
+
502
+ # Post should now only be in test_view
503
+ updated_post = @api.post(post.id)
504
+ updated_views = updated_post.views.strip.split(/\s+/)
505
+ assert_includes updated_views, "test_view"
506
+ refute_includes updated_views, "other_view"
507
+ end
508
+
509
+ def test_032_update_post_blurb
510
+ @api.create_view("test_view", "Test View")
511
+ post = @api.create_post("Test Post", "Test body")
512
+
513
+ # Manually add a blurb line to the source file
514
+ source_file = post.dir/"source.lt3"
515
+ lines = read_file(source_file, lines: true, chomp: false)
516
+ lines.insert(-2, ".blurb This is just a short intro to this post.\n") # Insert before the body
517
+ write_file(source_file, lines.join)
518
+
519
+ # Update the blurb
520
+ result = @api.update_post(post.id, {blurb: "Updated blurb for this post"})
521
+ assert result
522
+
523
+ # Check that the blurb was updated
524
+ content = read_file(source_file)
525
+ assert_includes content, ".blurb Updated blurb for this post"
526
+ assert_includes content, "# updated blurb"
527
+ end
528
+
529
+ def test_033_delete_draft
530
+ @api.create_view("test_view", "Test View")
531
+
532
+ # Create a draft
533
+ draft_path = @api.draft(title: "Test Draft", body: "Test body")
534
+
535
+ # Verify draft exists
536
+ drafts = @api.drafts
537
+ assert_equal 1, drafts.length
538
+ assert_equal draft_path, drafts.first[:path]
539
+
540
+ # Delete the draft
541
+ result = @api.delete_draft(draft_path)
542
+ assert result
543
+
544
+ # Verify draft is gone
545
+ drafts = @api.drafts
546
+ assert_equal 0, drafts.length
547
+ end
548
+
549
+ def test_034_delete_draft_invalid_path
550
+ @api.create_view("test_view", "Test View")
551
+
552
+ # Test with non-draft file
553
+ assert_raises(DraftFileInvalid) do
554
+ @api.delete_draft("not-a-draft.txt")
555
+ end
556
+
557
+ # Test with non-existent file
558
+ assert_raises(DraftFileNotFound) do
559
+ @api.delete_draft("nonexistent-draft.lt3")
560
+ end
561
+ end
562
+
563
+ def test_035_generate_view
564
+ @api.create_view("test_view", "Test View")
565
+ @api.create_post("Test Post", "Test body")
566
+
567
+ # Should not raise an error
568
+ result = @api.generate_view
569
+ assert result
570
+ end
571
+
572
+
573
+
574
+ def test_036_generate_widget
575
+ @api.create_view("test_view", "Test View")
576
+
577
+ # Create the widget directory and sample data
578
+ widget_dir = @api.repo.root/:views/"test_view"/:widgets/"links"
579
+ make_dir(widget_dir)
580
+ write_file(widget_dir/"list.txt", "https://example.com, Example Link")
581
+
582
+ # Should not raise an error for a valid widget
583
+ result = @api.generate_widget("links")
584
+ assert result
585
+
586
+ # Verify the widget files were created
587
+ assert File.exist?(widget_dir/"links-card.html")
588
+ end
589
+
590
+
591
+
592
+ def test_037_generate_widget_invalid_name
593
+ @api.create_view("test_view", "Test View")
594
+
595
+ # Test with invalid widget name
596
+ assert_raises(WidgetNameInvalid) do
597
+ @api.generate_widget("invalid-widget")
598
+ end
599
+
600
+ # Test with nil
601
+ assert_raises(WidgetNameNil) do
602
+ @api.generate_widget(nil)
603
+ end
604
+
605
+ # Test with empty string
606
+ assert_raises(WidgetsArgEmpty) do
607
+ @api.generate_widget("")
608
+ end
609
+ end
610
+
611
+ def test_038_generate_widget_nonexistent
612
+ @api.create_view("test_view", "Test View")
613
+
614
+ # Test with non-existent widget class
615
+ assert_raises(CannotBuildWidget) do
616
+ @api.generate_widget("nonexistent")
617
+ end
618
+ end
619
+
620
+ def test_039_select_posts
621
+ @api.create_view("test_view", "Test View")
622
+ @api.create_view("other_view", "Other View")
623
+
624
+ # Create posts in different views
625
+ post1 = @api.create_post("Post 1", "Body 1", views: ["test_view"])
626
+ post2 = @api.create_post("Post 2", "Body 2", views: ["other_view"])
627
+ post3 = @api.create_post("Post 3", "Body 3", views: ["test_view", "other_view"])
628
+
629
+ # Test filtering by view
630
+ test_view_posts = @api.select_posts { |post| post.views.include?("test_view") }
631
+ assert_equal 2, test_view_posts.length
632
+ assert_includes test_view_posts.map(&:title), "Post 1"
633
+ assert_includes test_view_posts.map(&:title), "Post 3"
634
+
635
+ # Test filtering by title
636
+ title_posts = @api.select_posts { |post| post.title.include?("Post 2") }
637
+ assert_equal 1, title_posts.length
638
+ assert_equal "Post 2", title_posts.first.title
639
+ end
640
+
641
+ def test_040_search_posts
642
+ @api.create_view("test_view", "Test View")
643
+
644
+ # Create posts with different content
645
+ post1 = @api.create_post("Ruby Programming", "Learn Ruby basics", tags: "ruby, programming")
646
+ post2 = @api.create_post("Python Guide", "Python vs Ruby", tags: "python, comparison")
647
+ post3 = @api.create_post("Scriptorium API", "API documentation", tags: "api, scriptorium")
648
+
649
+ # Test title search with regex
650
+ ruby_posts = @api.search_posts(title: /Ruby/)
651
+ assert_equal 1, ruby_posts.length
652
+ assert_equal "Ruby Programming", ruby_posts.first.title
653
+
654
+ # Test body search with string
655
+ body_posts = @api.search_posts(body: "Ruby")
656
+ assert_equal 2, body_posts.length
657
+ assert_includes body_posts.map(&:title), "Ruby Programming"
658
+ assert_includes body_posts.map(&:title), "Python Guide"
659
+
660
+ # Test tags search
661
+ api_posts = @api.search_posts(tags: "api")
662
+ assert_equal 1, api_posts.length
663
+ assert_equal "Scriptorium API", api_posts.first.title
664
+
665
+ # Test multiple criteria (AND)
666
+ ruby_api_posts = @api.search_posts(title: /Ruby/, body: "basics")
667
+ assert_equal 1, ruby_api_posts.length
668
+ assert_equal "Ruby Programming", ruby_api_posts.first.title
669
+ end
670
+
671
+ def test_041_search_posts_with_blurb
672
+ @api.create_view("test_view", "Test View")
673
+ post = @api.create_post("Test Post", "Test body", blurb: "This is a test blurb for searching.")
674
+
675
+ # Test blurb search
676
+ blurb_posts = @api.search_posts(blurb: "test blurb")
677
+ assert_equal 1, blurb_posts.length
678
+ assert_equal "Test Post", blurb_posts.first.title
679
+
680
+ # Test blurb search with regex
681
+ regex_posts = @api.search_posts(blurb: /blurb.*search/)
682
+ assert_equal 1, regex_posts.length
683
+ assert_equal "Test Post", regex_posts.first.title
684
+ end
685
+
686
+ def test_042_search_posts_unknown_field
687
+ @api.create_view("test_view", "Test View")
688
+
689
+ # Create a post so the search actually processes something
690
+ @api.create_post("Test Post", "Test body")
691
+
692
+ assert_raises(UnknownSearchField) do
693
+ @api.search_posts(unknown_field: "value")
694
+ end
695
+ end
696
+
697
+ def test_043_unlink_post_specific_view
698
+ @api.create_view("test_view", "Test View")
699
+ @api.create_view("other_view", "Other View")
700
+ post = @api.create_post("Test Post", "Test body", views: ["test_view", "other_view"])
701
+
702
+ # Unlink from specific view
703
+ result = @api.unlink_post(post.id, "other_view")
704
+ assert result
705
+
706
+ # Post should now only be in test_view
707
+ updated_post = @api.post(post.id)
708
+ assert_includes updated_post.views, "test_view"
709
+ refute_includes updated_post.views, "other_view"
710
+ end
711
+
712
+ def test_044_post_add_tag
713
+ @api.create_view("test_view", "Test View")
714
+ post = @api.create_post("Test Post", "Test body", tags: ["ruby"])
715
+
716
+ # Initially post should only have ruby tag
717
+ assert_includes post.tags, "ruby"
718
+ refute_includes post.tags, "scriptorium"
719
+
720
+ # Add scriptorium tag to the post
721
+ result = @api.post_add_tag(post.id, "scriptorium")
722
+ assert result
723
+
724
+ # Post should now have both tags
725
+ updated_post = @api.post(post.id)
726
+ updated_tags = updated_post.tags.strip.split(/,\s*/)
727
+ assert_includes updated_tags, "ruby"
728
+ assert_includes updated_tags, "scriptorium"
729
+ end
730
+
731
+ def test_045_post_add_tag_duplicate
732
+ @api.create_view("test_view", "Test View")
733
+ post = @api.create_post("Test Post", "Test body", tags: ["ruby"])
734
+
735
+ # Initially post should have ruby tag
736
+ assert_includes post.tags, "ruby"
737
+
738
+ # Try to add the same tag (should not add duplicate)
739
+ result = @api.post_add_tag(post.id, "ruby")
740
+ assert result
741
+
742
+ # Post should still only have ruby tag (no duplicates)
743
+ updated_post = @api.post(post.id)
744
+ updated_tags = updated_post.tags.strip.split(/,\s*/)
745
+ assert_equal ["ruby"], updated_tags
746
+ end
747
+
748
+ def test_046_post_remove_tag
749
+ @api.create_view("test_view", "Test View")
750
+ post = @api.create_post("Test Post", "Test body", tags: ["ruby", "scriptorium"])
751
+
752
+ # Initially post should have both tags
753
+ assert_includes post.tags, "ruby"
754
+ assert_includes post.tags, "scriptorium"
755
+
756
+ # Remove scriptorium tag from the post
757
+ result = @api.post_remove_tag(post.id, "scriptorium")
758
+ assert result
759
+
760
+ # Post should now only have ruby tag
761
+ updated_post = @api.post(post.id)
762
+ updated_tags = updated_post.tags.strip.split(/,\s*/)
763
+ assert_includes updated_tags, "ruby"
764
+ refute_includes updated_tags, "scriptorium"
765
+ end
766
+
767
+ def test_047_post_remove_tag_nonexistent
768
+ @api.create_view("test_view", "Test View")
769
+ post = @api.create_post("Test Post", "Test body", tags: ["ruby"])
770
+
771
+ # Initially post should have ruby tag
772
+ assert_includes post.tags, "ruby"
773
+
774
+ # Try to remove a tag that doesn't exist
775
+ result = @api.post_remove_tag(post.id, "nonexistent")
776
+ assert result # Should succeed even if tag doesn't exist
777
+
778
+ # Post should still have ruby tag
779
+ updated_post = @api.post(post.id)
780
+ updated_tags = updated_post.tags.strip.split(/,\s*/)
781
+ assert_includes updated_tags, "ruby"
782
+ refute_includes updated_tags, "nonexistent"
783
+ end
784
+
785
+ # edit_file tests
786
+ def test_048_edit_file_validation_nil_path
787
+ assert_raises(EditFilePathNil) do
788
+ @api.edit_file(nil)
789
+ end
790
+ end
791
+
792
+ def test_049_edit_file_validation_empty_path
793
+ assert_raises(EditFilePathEmpty) do
794
+ @api.edit_file("")
795
+ end
796
+ end
797
+
798
+ def test_050_edit_file_validation_whitespace_path
799
+ assert_raises(EditFilePathEmpty) do
800
+ @api.edit_file(" ")
801
+ end
802
+ end
803
+
804
+ def test_051_edit_file_uses_editor_from_env
805
+ # Mock ENV to return a specific editor
806
+ ENV.stub :[], "nano" do
807
+ # Mock system! to verify it's called with the right editor
808
+ mock_system = Minitest::Mock.new
809
+ mock_system.expect :call, true, ["nano", "/path/to/file"]
810
+
811
+ @api.stub :system!, mock_system do
812
+ @api.edit_file("/path/to/file")
813
+ end
814
+
815
+ mock_system.verify
816
+ end
817
+ end
818
+
819
+ def test_052_edit_file_uses_vim_fallback
820
+ # Mock ENV to return nil (no EDITOR set)
821
+ ENV.stub :[], nil do
822
+ # Mock system to return true (vim available)
823
+ @api.stub :system, true do
824
+ # Mock Open3.popen3 to return a mock process
825
+ mock_process = Minitest::Mock.new
826
+ mock_process.expect :pid, 123
827
+ mock_process.expect :wait, nil
828
+
829
+ Open3.stub :popen3, mock_process do
830
+ @api.edit_file("test.txt")
831
+ end
832
+ end
833
+ end
834
+ end
835
+
836
+ # Convenience file editing method tests
837
+
838
+ def test_053_edit_layout
839
+ @api.create_view("test_view", "Test View")
840
+
841
+ # Mock edit_file to track calls
842
+ called_path = nil
843
+ @api.stub :edit_file, ->(path) { called_path = path } do
844
+ @api.edit_layout
845
+ assert_equal "views/test_view/layout.txt", called_path
846
+ end
847
+ end
848
+
849
+ def test_054_edit_layout_with_specific_view
850
+ @api.create_view("test_view", "Test View")
851
+ @api.create_view("other_view", "Other View")
852
+
853
+ # Mock edit_file to track calls
854
+ called_path = nil
855
+ @api.stub :edit_file, ->(path) { called_path = path } do
856
+ @api.edit_layout("other_view")
857
+ assert_equal "views/other_view/layout.txt", called_path
858
+ end
859
+ end
860
+
861
+ def test_055_edit_layout_no_view
862
+ # Clear the current view
863
+ @api.repo.instance_variable_set(:@current_view, nil)
864
+
865
+ assert_raises(ViewTargetNil, "No view specified and no current view set") do
866
+ @api.edit_layout
867
+ end
868
+ end
869
+
870
+ def test_065_edit_config
871
+ @api.create_view("test_view", "Test View")
872
+
873
+ # Mock edit_file to track calls
874
+ called_path = nil
875
+ @api.stub :edit_file, ->(path) { called_path = path } do
876
+ @api.edit_config
877
+ assert_equal "views/test_view/config.txt", called_path
878
+ end
879
+ end
880
+
881
+ def test_066_edit_config_with_specific_view
882
+ @api.create_view("test_view", "Test View")
883
+ @api.create_view("other_view", "Other View")
884
+
885
+ # Mock edit_file to track calls
886
+ called_path = nil
887
+ @api.stub :edit_file, ->(path) { called_path = path } do
888
+ @api.edit_config("other_view")
889
+ assert_equal "views/other_view/config.txt", called_path
890
+ end
891
+ end
892
+
893
+ def test_067_edit_config_no_view
894
+ # Clear the current view
895
+ @api.repo.instance_variable_set(:@current_view, nil)
896
+
897
+ assert_raises(ViewTargetNil, "No view specified and no current view set") do
898
+ @api.edit_config
899
+ end
900
+ end
901
+
902
+ def test_056_edit_widget_data
903
+ @api.create_view("test_view", "Test View")
904
+
905
+ # Mock edit_file to track calls
906
+ called_path = nil
907
+ @api.stub :edit_file, ->(path) { called_path = path } do
908
+ @api.edit_widget_data(nil, "links")
909
+ assert_equal "views/test_view/widgets/links/list.txt", called_path
910
+ end
911
+ end
912
+
913
+ def test_057_edit_widget_data_with_specific_view
914
+ @api.create_view("test_view", "Test View")
915
+ @api.create_view("other_view", "Other View")
916
+
917
+ # Mock edit_file to track calls
918
+ called_path = nil
919
+ @api.stub :edit_file, ->(path) { called_path = path } do
920
+ @api.edit_widget_data("other_view", "news")
921
+ assert_equal "views/other_view/widgets/news/list.txt", called_path
922
+ end
923
+ end
924
+
925
+ def test_058_edit_widget_data_nil_widget
926
+ @api.create_view("test_view", "Test View")
927
+
928
+ assert_raises(WidgetNameNil, "Widget name cannot be nil") do
929
+ @api.edit_widget_data(nil, nil)
930
+ end
931
+ end
932
+
933
+ def test_060_edit_repo_config
934
+ # Mock edit_file to track calls
935
+ called_path = nil
936
+ @api.stub :edit_file, ->(path) { called_path = path } do
937
+ @api.edit_repo_config
938
+ assert_equal "config/repo.txt", called_path
939
+ end
940
+ end
941
+
942
+ def test_061_edit_deploy_config
943
+ # Mock edit_file to track calls
944
+ called_path = nil
945
+ @api.stub :edit_file, ->(path) { called_path = path } do
946
+ @api.edit_deploy_config
947
+ assert_equal "config/deploy.txt", called_path
948
+ end
949
+ end
950
+
951
+ def test_062_edit_post_with_source
952
+ @api.create_view("test_view", "Test View")
953
+ post = @api.create_post("Test Post", "Test body")
954
+
955
+ # Create source.lt3 file to test smart selection
956
+ source_path = "#{@test_dir}/posts/#{post.num}/source.lt3"
957
+ write_file(source_path, "Test source content")
958
+
959
+ # Mock edit_file to track calls
960
+ called_path = nil
961
+ @api.stub :edit_file, ->(path) { called_path = path } do
962
+ @api.edit_post(post.id)
963
+ assert_equal source_path, called_path
964
+ end
965
+ end
966
+
967
+ def test_063_edit_post_without_source
968
+ @api.create_view("test_view", "Test View")
969
+ post = @api.create_post("Test Post", "Test body")
970
+
971
+ # Ensure source.lt3 doesn't exist
972
+ source_path = "#{@test_dir}/posts/#{post.num}/source.lt3"
973
+ File.delete(source_path) if File.exist?(source_path)
974
+
975
+ # Should raise error since source.lt3 is required
976
+ assert_raises(RuntimeError) do
977
+ @api.edit_post(post.id)
978
+ end
979
+ end
980
+
981
+ def test_064_edit_post_nonexistent
982
+ assert_raises(CannotGetPost) do
983
+ @api.edit_post(999)
984
+ end
985
+ end
986
+
987
+ # Publication system tests
988
+
989
+
990
+ def test_068_publish_post
991
+ @api.create_view("test_view", "Test View")
992
+ post = @api.create_post("Test Post", "Test body")
993
+
994
+ # Initially unpublished
995
+ refute @api.post_published?(post.id)
996
+
997
+ # Publish the post
998
+ published_post = @api.publish_post(post.id)
999
+
1000
+ # Should now be published
1001
+ assert @api.post_published?(post.id)
1002
+ assert_equal "Test Post", published_post.title
1003
+
1004
+ # Should have generated the post
1005
+ assert File.exist?("#{@test_dir}/posts/#{post.num}/body.html")
1006
+ end
1007
+
1008
+ def test_069_publish_post_already_published
1009
+ @api.create_view("test_view", "Test View")
1010
+
1011
+ # Set test_view as current view
1012
+ @api.repo.instance_variable_set(:@current_view, @api.repo.lookup_view("test_view"))
1013
+
1014
+ post = @api.create_post("Test Post", "Test body")
1015
+
1016
+ # Publish once
1017
+ @api.publish_post(post.id)
1018
+
1019
+ # Try to publish again
1020
+ assert_raises(PostAlreadyPublished, "Post #{post.id} is already published") do
1021
+ @api.publish_post(post.id)
1022
+ end
1023
+ end
1024
+
1025
+ def test_070_publish_post_nonexistent
1026
+ assert_raises(CannotGetPost) do
1027
+ @api.publish_post(999)
1028
+ end
1029
+ end
1030
+
1031
+ def test_070_5_unpublish_post
1032
+ @api.create_view("test_view", "Test View")
1033
+ post = @api.create_post("Test Post", "Test body")
1034
+
1035
+ # Publish the post
1036
+ @api.publish_post(post.id)
1037
+ assert @api.post_published?(post.id)
1038
+
1039
+ # Unpublish the post
1040
+ @api.unpublish_post(post.id)
1041
+ refute @api.post_published?(post.id)
1042
+ end
1043
+
1044
+ def test_071_post_published_status
1045
+ @api.create_view("test_view", "Test View")
1046
+ post = @api.create_post("Test Post", "Test body")
1047
+
1048
+ # Initially unpublished
1049
+ refute @api.post_published?(post.id)
1050
+
1051
+ # Publish
1052
+ @api.publish_post(post.id)
1053
+
1054
+ # Now published
1055
+ assert @api.post_published?(post.id)
1056
+ end
1057
+
1058
+ def test_072_posts_with_published_parameter
1059
+ @api.create_view("test_view", "Test View")
1060
+
1061
+ # Create multiple posts
1062
+ post1 = @api.create_post("Post 1", "Body 1")
1063
+ post2 = @api.create_post("Post 2", "Body 2")
1064
+ post3 = @api.create_post("Post 3", "Body 3")
1065
+
1066
+ # Initially no published posts
1067
+ published_posts = @api.posts(published: true)
1068
+ assert_equal 0, published_posts.length
1069
+
1070
+ # Publish two posts
1071
+ @api.publish_post(post1.id)
1072
+ @api.publish_post(post3.id)
1073
+
1074
+ # Should have 2 published posts
1075
+ published_posts = @api.posts(published: true)
1076
+ assert_equal 2, published_posts.length
1077
+ assert_includes published_posts.map(&:id), post1.id
1078
+ assert_includes published_posts.map(&:id), post3.id
1079
+ refute_includes published_posts.map(&:id), post2.id
1080
+ end
1081
+
1082
+ def test_073_posts_with_published_parameter_and_view
1083
+ @api.create_view("test_view", "Test View")
1084
+ @api.create_view("other_view", "Other View")
1085
+
1086
+ # Set test_view as current view
1087
+ @api.repo.instance_variable_set(:@current_view, @api.repo.lookup_view("test_view"))
1088
+
1089
+ # Create posts in different views
1090
+ post1 = @api.create_post("Post 1", "Body 1", views: "test_view")
1091
+ post2 = @api.create_post("Post 2", "Body 2", views: "other_view")
1092
+
1093
+ # Publish post1 in test_view (current view)
1094
+ @api.publish_post(post1.id)
1095
+
1096
+ # Get published posts for specific view
1097
+ test_view_posts = @api.posts("test_view", published: true)
1098
+ assert_equal 1, test_view_posts.length
1099
+ assert_equal post1.id, test_view_posts.first.id
1100
+
1101
+ other_view_posts = @api.posts("other_view", published: true)
1102
+ assert_equal 0, other_view_posts.length # post2 not published
1103
+ end
1104
+
1105
+ def test_073_5_view_specific_publishing
1106
+ @api.create_view("test_view", "Test View")
1107
+ @api.create_view("other_view", "Other View")
1108
+
1109
+ # Set test_view as current view
1110
+ @api.repo.instance_variable_set(:@current_view, @api.repo.lookup_view("test_view"))
1111
+
1112
+ # Create a post that belongs to both views
1113
+ post = @api.create_post("Test Post", "Test body", views: "test_view other_view")
1114
+
1115
+ # Initially unpublished in both views
1116
+ refute @api.post_published?(post.id, "test_view")
1117
+ refute @api.post_published?(post.id, "other_view")
1118
+
1119
+ # Publish in test_view only (current view)
1120
+ @api.publish_post(post.id)
1121
+
1122
+ # Should be published in test_view, unpublished in other_view
1123
+ assert @api.post_published?(post.id, "test_view")
1124
+ refute @api.post_published?(post.id, "other_view")
1125
+
1126
+ # Publish in other_view
1127
+ @api.publish_post(post.id, "other_view")
1128
+
1129
+ # Should be published in both views
1130
+ assert @api.post_published?(post.id, "test_view")
1131
+ assert @api.post_published?(post.id, "other_view")
1132
+ end
1133
+
1134
+ def test_073_6_deployment_state_management
1135
+ @api.create_view("test_view", "Test View")
1136
+ @api.create_view("other_view", "Other View")
1137
+
1138
+ # Set test_view as current view
1139
+ @api.repo.instance_variable_set(:@current_view, @api.repo.lookup_view("test_view"))
1140
+
1141
+ # Create a post that belongs to both views
1142
+ post = @api.create_post("Test Post", "Test body", views: "test_view other_view")
1143
+
1144
+ # Initially unpublished and undeployed in both views
1145
+ refute @api.post_published?(post.id, "test_view")
1146
+ refute @api.post_deployed?(post.id, "test_view")
1147
+ refute @api.post_published?(post.id, "other_view")
1148
+ refute @api.post_deployed?(post.id, "other_view")
1149
+
1150
+ # Publish in test_view only
1151
+ @api.publish_post(post.id)
1152
+
1153
+ # Should be published but still undeployed in test_view
1154
+ assert @api.post_published?(post.id, "test_view")
1155
+ refute @api.post_deployed?(post.id, "test_view")
1156
+
1157
+ # Deploy in test_view
1158
+ @api.mark_post_deployed(post.id)
1159
+
1160
+ # Should be published and deployed in test_view
1161
+ assert @api.post_published?(post.id, "test_view")
1162
+ assert @api.post_deployed?(post.id, "test_view")
1163
+
1164
+ # Should still be unpublished and undeployed in other_view
1165
+ refute @api.post_published?(post.id, "other_view")
1166
+ refute @api.post_deployed?(post.id, "other_view")
1167
+ end
1168
+
1169
+ def test_073_7_deployment_state_workflow
1170
+ @api.create_view("test_view", "Test View")
1171
+
1172
+ # Set test_view as current view
1173
+ @api.repo.instance_variable_set(:@current_view, @api.repo.lookup_view("test_view"))
1174
+
1175
+ # Create multiple posts
1176
+ post1 = @api.create_post("Post 1", "Body 1")
1177
+ post2 = @api.create_post("Post 2", "Body 2")
1178
+ post3 = @api.create_post("Post 3", "Body 3")
1179
+
1180
+ # Initially all posts are unpublished and undeployed
1181
+ [post1, post2, post3].each do |post|
1182
+ refute @api.post_published?(post.id)
1183
+ refute @api.post_deployed?(post.id)
1184
+ end
1185
+
1186
+ # Publish post1 and post3
1187
+ @api.publish_post(post1.id)
1188
+ @api.publish_post(post3.id)
1189
+
1190
+ # Deploy post1
1191
+ @api.mark_post_deployed(post1.id)
1192
+
1193
+ # Check states
1194
+ assert @api.post_published?(post1.id)
1195
+ assert @api.post_deployed?(post1.id)
1196
+ refute @api.post_published?(post2.id)
1197
+ refute @api.post_deployed?(post2.id)
1198
+ assert @api.post_published?(post3.id)
1199
+ refute @api.post_deployed?(post3.id)
1200
+
1201
+ # Get deployed posts
1202
+ deployed_posts = @api.get_deployed_posts
1203
+ assert_equal 1, deployed_posts.length
1204
+ assert_equal post1.id, deployed_posts.first.id
1205
+
1206
+ # Mark post1 as undeployed
1207
+ @api.mark_post_undeployed(post1.id)
1208
+ refute @api.post_deployed?(post1.id)
1209
+
1210
+ # Get deployed posts again
1211
+ deployed_posts = @api.get_deployed_posts
1212
+ assert_equal 0, deployed_posts.length
1213
+ end
1214
+
1215
+ def test_073_8_deployment_workflow_integration
1216
+ @api.create_view("test_view", "Test View")
1217
+
1218
+ # Set test_view as current view
1219
+ @api.repo.instance_variable_set(:@current_view, @api.repo.lookup_view("test_view"))
1220
+
1221
+ # Create a post (don't publish it yet)
1222
+ post = @api.create_post("Test Post", "Test body")
1223
+
1224
+ # Post should be unpublished and undeployed
1225
+ refute @api.post_published?(post.id)
1226
+ refute @api.post_deployed?(post.id)
1227
+
1228
+ # Try to deploy unpublished post (should fail)
1229
+ assert_raises(PostNotPublished) do
1230
+ @api.mark_post_deployed(post.id)
1231
+ end
1232
+
1233
+ # Now publish the post
1234
+ @api.publish_post(post.id)
1235
+
1236
+ # Post should be published but not deployed
1237
+ assert @api.post_published?(post.id)
1238
+ refute @api.post_deployed?(post.id)
1239
+
1240
+ # Now deploy the published post
1241
+ @api.mark_post_deployed(post.id)
1242
+
1243
+ # Post should be both published and deployed
1244
+ assert @api.post_published?(post.id)
1245
+ assert @api.post_deployed?(post.id)
1246
+ end
1247
+
1248
+ def test_073_9_post_states_display
1249
+ @api.create_view("test_view", "Test View")
1250
+
1251
+ # Set test_view as current view
1252
+ @api.repo.instance_variable_set(:@current_view, @api.repo.lookup_view("test_view"))
1253
+
1254
+ # Create multiple posts
1255
+ post1 = @api.create_post("Post 1", "Body 1")
1256
+ post2 = @api.create_post("Post 2", "Body 2")
1257
+ post3 = @api.create_post("Post 3", "Body 3")
1258
+
1259
+ # Get initial states
1260
+ states = @api.get_post_states
1261
+ assert_equal 3, states.length
1262
+
1263
+ # Check initial state (should be "-" for unpublished/undeployed)
1264
+ assert_equal "-", states[post1.id][:state]
1265
+ assert_equal "-", states[post2.id][:state]
1266
+ assert_equal "-", states[post3.id][:state]
1267
+
1268
+ # Publish post1
1269
+ @api.publish_post(post1.id)
1270
+ states = @api.get_post_states
1271
+ assert_equal "P", states[post1.id][:state] # Published only
1272
+
1273
+ # Deploy post1
1274
+ @api.mark_post_deployed(post1.id)
1275
+ states = @api.get_post_states
1276
+ assert_equal "PD", states[post1.id][:state] # Published and deployed
1277
+
1278
+ # Publish post3
1279
+ @api.publish_post(post3.id)
1280
+ states = @api.get_post_states
1281
+ assert_equal "PD", states[post1.id][:state] # Published and deployed
1282
+ assert_equal "P", states[post3.id][:state] # Published only
1283
+ assert_equal "-", states[post2.id][:state] # Neither
1284
+ end
1285
+
1286
+ def test_073_10_state_validation_rules
1287
+ @api.create_view("test_view", "Test View")
1288
+
1289
+ # Set test_view as current view
1290
+ @api.repo.instance_variable_set(:@current_view, @api.repo.lookup_view("test_view"))
1291
+
1292
+ # Create a post
1293
+ post = @api.create_post("Test Post", "Test body")
1294
+
1295
+ # Test: Cannot publish deleted post
1296
+ @api.delete_post(post.id)
1297
+ assert_raises(PostDeleted) do
1298
+ @api.publish_post(post.id)
1299
+ end
1300
+
1301
+ # Test: Cannot deploy deleted post
1302
+ assert_raises(PostDeleted) do
1303
+ @api.mark_post_deployed(post.id)
1304
+ end
1305
+
1306
+ # Test: Cannot unpublish deployed post
1307
+ @api.undelete_post(post.id)
1308
+ @api.publish_post(post.id)
1309
+ @api.mark_post_deployed(post.id)
1310
+ assert_raises(PostAlreadyDeployed) do
1311
+ @api.unpublish_post(post.id)
1312
+ end
1313
+ end
1314
+
1315
+ def test_073_11_delete_undelete_workflow
1316
+ @api.create_view("test_view", "Test View")
1317
+
1318
+ # Set test_view as current view
1319
+ @api.repo.instance_variable_set(:@current_view, @api.repo.lookup_view("test_view"))
1320
+
1321
+ # Create a post
1322
+ post = @api.create_post("Test Post", "Test body")
1323
+
1324
+ # Post should not be deleted initially
1325
+ refute @api.post_deleted?(post.id)
1326
+
1327
+ # Delete the post
1328
+ @api.delete_post(post.id)
1329
+ assert @api.post_deleted?(post.id)
1330
+
1331
+ # Post should be in deleted state (X)
1332
+ states = @api.get_post_states
1333
+ assert_equal "X", states[post.id][:state]
1334
+
1335
+ # Undelete the post
1336
+ @api.undelete_post(post.id)
1337
+ refute @api.post_deleted?(post.id)
1338
+
1339
+ # Post should be back to normal state (-)
1340
+ states = @api.get_post_states
1341
+ assert_equal "-", states[post.id][:state]
1342
+ end
1343
+
1344
+ def test_073_12_edit_state_transition
1345
+ @api.create_view("test_view", "Test View")
1346
+
1347
+ # Set test_view as current view
1348
+ @api.repo.instance_variable_set(:@current_view, @api.repo.lookup_view("test_view"))
1349
+
1350
+ # Create and publish a post
1351
+ post = @api.create_post("Test Post", "Test body")
1352
+ @api.publish_post(post.id)
1353
+ @api.mark_post_deployed(post.id)
1354
+
1355
+ # Post should be published and deployed
1356
+ assert @api.post_published?(post.id)
1357
+ assert @api.post_deployed?(post.id)
1358
+
1359
+ # Regenerate the post - should reset state to unpublished/undeployed
1360
+ @api.repo.generate_post(post.id)
1361
+
1362
+ # Post should now be unpublished and undeployed (content changed)
1363
+ refute @api.post_published?(post.id)
1364
+ refute @api.post_deployed?(post.id)
1365
+ end
1366
+
1367
+ def test_074_create_post_with_generation
1368
+ @api.create_view("test_view", "Test View")
1369
+ post = @api.create_post("Test Post", "Test body")
1370
+
1371
+ # Post should exist and be generated by default
1372
+ assert File.exist?("#{@test_dir}/posts/#{post.num}/source.lt3")
1373
+ assert File.exist?("#{@test_dir}/posts/#{post.num}/meta.txt")
1374
+ assert File.exist?("#{@test_dir}/posts/#{post.num}/body.html")
1375
+
1376
+ # Should not be published
1377
+ refute @api.post_published?(post.id)
1378
+ end
1379
+
1380
+ def test_075_create_post_with_generation_default
1381
+ @api.create_view("test_view", "Test View")
1382
+ post = @api.create_post("Test Post", "Test body")
1383
+
1384
+ # Post should be generated by default (backward compatibility)
1385
+ assert File.exist?("#{@test_dir}/posts/#{post.num}/body.html")
1386
+
1387
+ # Should NOT be published (generation and publication are separate)
1388
+ # Check the metadata file directly to be sure
1389
+ metadata_file = "#{@test_dir}/posts/#{post.num}/meta.txt"
1390
+ assert File.exist?(metadata_file)
1391
+ metadata_content = read_file(metadata_file)
1392
+ assert_match /post\.published\s+no/, metadata_content
1393
+ refute @api.post_published?(post.id)
1394
+ end
1395
+
1396
+ def test_999_placeholder
1397
+ # This test ensures the file has at least one test method
1398
+ assert true
1399
+ end
1400
+
1401
+ # Asset management tests
1402
+
1403
+ def test_1000_list_assets_global
1404
+ # @api is already set up in setup method
1405
+
1406
+ # Create some test assets
1407
+ write_file("test/scriptorium-TEST/assets/test1.jpg", "Test image 1")
1408
+ write_file("test/scriptorium-TEST/assets/test2.png", "Test image 2")
1409
+
1410
+ assets = @api.list_assets(target: 'global')
1411
+
1412
+ assert_equal 3, assets.length
1413
+ # Check that our test assets are present (imagenotfound.jpg will also be there)
1414
+ filenames = assets.map { |a| a[:filename] }
1415
+ assert_includes filenames, "test1.jpg"
1416
+ assert_includes filenames, "test2.png"
1417
+ assert_includes filenames, "imagenotfound.jpg"
1418
+ assert_equal "image", assets[0][:type]
1419
+ assert assets[0][:size] > 0
1420
+ end
1421
+
1422
+ def test_1001_list_assets_library
1423
+ # @api is already set up in setup method
1424
+
1425
+ # Create library assets
1426
+ write_file("test/scriptorium-TEST/assets/library/sample1.jpg", "Sample 1")
1427
+ write_file("test/scriptorium-TEST/assets/library/sample2.txt", "Sample 2")
1428
+
1429
+ assets = @api.list_assets(target: 'library')
1430
+
1431
+ # There might be existing assets from setup, so check that our test assets are included
1432
+ assert assets.length >= 2, "Should have at least our 2 test assets"
1433
+ filenames = assets.map { |a| a[:filename] }
1434
+ assert_includes filenames, "sample1.jpg"
1435
+ assert_includes filenames, "sample2.txt"
1436
+
1437
+ # Find our specific test assets
1438
+ sample1 = assets.find { |a| a[:filename] == "sample1.jpg" }
1439
+ sample2 = assets.find { |a| a[:filename] == "sample2.txt" }
1440
+
1441
+ assert_equal "image", sample1[:type]
1442
+ assert_equal "document", sample2[:type]
1443
+ end
1444
+
1445
+ def test_1002_list_assets_view
1446
+ # @api is already set up in setup method
1447
+ @api.create_view("testview", "Test View", "Test Subtitle")
1448
+
1449
+ # Create view assets
1450
+ write_file("test/scriptorium-TEST/views/testview/assets/view1.jpg", "View asset 1")
1451
+ write_file("test/scriptorium-TEST/views/testview/assets/view2.svg", "View asset 2")
1452
+
1453
+ assets = @api.list_assets(target: 'view', view: 'testview')
1454
+
1455
+ assert_equal 3, assets.length
1456
+ # Check that our test assets are present (imagenotfound.jpg will also be there)
1457
+ filenames = assets.map { |a| a[:filename] }
1458
+ assert_includes filenames, "view1.jpg"
1459
+ assert_includes filenames, "view2.svg"
1460
+ assert_includes filenames, "imagenotfound.jpg"
1461
+ assert_equal "image", assets[0][:type]
1462
+ end
1463
+
1464
+ def test_1003_list_assets_gem
1465
+ # @api is already set up in setup method
1466
+
1467
+ # Test gem assets (should work in development environment)
1468
+ assets = @api.list_assets(target: 'gem')
1469
+
1470
+ # In development environment, we should find assets from the working directory
1471
+ # If no gem assets are found, that's also acceptable
1472
+ if assets.length > 0
1473
+ assert assets.all? { |asset| asset[:type] == 'image' || asset[:type] == 'other' }
1474
+ end
1475
+ end
1476
+
1477
+ def test_1004_get_asset_info
1478
+ # @api is already set up in setup method
1479
+
1480
+ # Create test asset
1481
+ write_file("test/scriptorium-TEST/assets/test_info.jpg", "Test info image")
1482
+
1483
+ asset_info = @api.get_asset_info("test_info.jpg", target: 'global')
1484
+
1485
+ assert_equal "test_info.jpg", asset_info[:filename]
1486
+ assert_equal "image", asset_info[:type]
1487
+ assert asset_info[:size] > 0
1488
+ assert asset_info[:path].include?("test_info.jpg")
1489
+ end
1490
+
1491
+ def test_1005_asset_exists
1492
+ # @api is already set up in setup method
1493
+
1494
+ # Create test asset
1495
+ write_file("test/scriptorium-TEST/assets/exists.jpg", "Exists")
1496
+
1497
+ assert @api.asset_exists?("exists.jpg", target: 'global')
1498
+ refute @api.asset_exists?("missing.jpg", target: 'global')
1499
+ end
1500
+
1501
+ def test_1006_copy_asset_global_to_view
1502
+ # @api is already set up in setup method
1503
+ @api.create_view("testview", "Test View", "Test Subtitle")
1504
+
1505
+ # Create source asset
1506
+ write_file("test/scriptorium-TEST/assets/source.jpg", "Source image")
1507
+
1508
+ # Copy asset
1509
+ target_path = @api.copy_asset("source.jpg", from: 'global', to: 'view', view: 'testview')
1510
+
1511
+ # Verify copy
1512
+ assert File.exist?(target_path)
1513
+ assert File.exist?("test/scriptorium-TEST/views/testview/assets/source.jpg")
1514
+ assert_equal "Source image", read_file("test/scriptorium-TEST/views/testview/assets/source.jpg").chomp
1515
+ end
1516
+
1517
+ def test_1007_copy_asset_gem_to_global
1518
+ # @api is already set up in setup method
1519
+
1520
+ # Find a gem asset to copy
1521
+ gem_assets = @api.list_assets(target: 'gem')
1522
+ skip "No gem assets available for testing" if gem_assets.empty?
1523
+
1524
+ gem_filename = gem_assets.first[:filename]
1525
+
1526
+ # Copy from gem to global
1527
+ target_path = @api.copy_asset(gem_filename, from: 'gem', to: 'global')
1528
+
1529
+ # Verify copy
1530
+ assert File.exist?(target_path)
1531
+ assert File.exist?("test/scriptorium-TEST/assets/#{gem_filename}")
1532
+ end
1533
+
1534
+ def test_1008_copy_asset_library_to_view
1535
+ # @api is already set up in setup method
1536
+ @api.create_view("testview", "Test View", "Test Subtitle")
1537
+
1538
+ # Create library asset
1539
+ write_file("test/scriptorium-TEST/assets/library/lib.jpg", "Library image")
1540
+
1541
+ # Copy to view
1542
+ target_path = @api.copy_asset("lib.jpg", from: 'library', to: 'view', view: 'testview')
1543
+
1544
+ # Verify copy
1545
+ assert File.exist?(target_path)
1546
+ assert File.exist?("test/scriptorium-TEST/views/testview/assets/lib.jpg")
1547
+ end
1548
+
1549
+ def test_1009_upload_asset
1550
+ # @api is already set up in setup method
1551
+
1552
+ # Create temporary source file
1553
+ source_file = "test/temp_source.jpg"
1554
+ write_file(source_file, "Temporary source image")
1555
+
1556
+ # Upload to global
1557
+ target_path = @api.upload_asset(source_file, target: 'global')
1558
+
1559
+ # Verify upload
1560
+ assert File.exist?(target_path)
1561
+ assert File.exist?("test/scriptorium-TEST/assets/temp_source.jpg")
1562
+ assert_equal "Temporary source image", read_file("test/scriptorium-TEST/assets/temp_source.jpg").chomp
1563
+
1564
+ # Cleanup
1565
+ File.delete(source_file)
1566
+ end
1567
+
1568
+ def test_1010_upload_asset_to_view
1569
+ # @api is already set up in setup method
1570
+ @api.create_view("testview", "Test View", "Test Subtitle")
1571
+
1572
+ # Create temporary source file
1573
+ source_file = "test/temp_view_source.jpg"
1574
+ write_file(source_file, "View source image")
1575
+
1576
+ # Upload to view
1577
+ target_path = @api.upload_asset(source_file, target: 'view', view: 'testview')
1578
+
1579
+ # Verify upload
1580
+ assert File.exist?(target_path)
1581
+ assert File.exist?("test/scriptorium-TEST/views/testview/assets/temp_view_source.jpg")
1582
+
1583
+ # Cleanup
1584
+ File.delete(source_file)
1585
+ end
1586
+
1587
+ def test_1011_delete_asset
1588
+ # @api is already set up in setup method
1589
+
1590
+ # Create test asset
1591
+ write_file("test/scriptorium-TEST/assets/to_delete.jpg", "Delete me")
1592
+
1593
+ # Verify it exists
1594
+ assert File.exist?("test/scriptorium-TEST/assets/to_delete.jpg")
1595
+
1596
+ # Delete it
1597
+ result = @api.delete_asset("to_delete.jpg", target: 'global')
1598
+
1599
+ # Verify deletion
1600
+ assert result
1601
+ refute File.exist?("test/scriptorium-TEST/assets/to_delete.jpg")
1602
+ end
1603
+
1604
+ def test_1012_get_asset_path
1605
+ # @api is already set up in setup method
1606
+
1607
+ # Create test asset
1608
+ write_file("test/scriptorium-TEST/assets/path_test.jpg", "Path test")
1609
+
1610
+ # Get path
1611
+ path = @api.get_asset_path("path_test.jpg", target: 'global')
1612
+
1613
+ assert path.include?("path_test.jpg")
1614
+ assert path.include?("assets")
1615
+ end
1616
+
1617
+ def test_1013_get_asset_dimensions
1618
+ # @api is already set up in setup method
1619
+
1620
+ # Create test image (we'll use a placeholder)
1621
+ write_file("test/scriptorium-TEST/assets/dimensions.jpg", "Image data")
1622
+
1623
+ # Get dimensions (may be nil if FastImage not available)
1624
+ dimensions = @api.get_asset_dimensions("dimensions.jpg", target: 'global')
1625
+
1626
+ # Just verify the method doesn't crash
1627
+ assert true
1628
+ end
1629
+
1630
+ def test_1014_get_asset_size
1631
+ # @api is already set up in setup method
1632
+
1633
+ # Create test asset
1634
+ content = "Test content for size measurement"
1635
+ write_file("test/scriptorium-TEST/assets/size_test.txt", content)
1636
+
1637
+ # Get size (write_file adds a newline, so size will be content.length + 1)
1638
+ size = @api.get_asset_size("size_test.txt", target: 'global')
1639
+
1640
+ assert_equal content.length + 1, size
1641
+ end
1642
+
1643
+ def test_1015_get_asset_type
1644
+ # @api is already set up in setup method
1645
+
1646
+ # Test various file types
1647
+ assert_equal "image", @api.get_asset_type("test.jpg")
1648
+ assert_equal "image", @api.get_asset_type("test.png")
1649
+ assert_equal "image", @api.get_asset_type("test.svg")
1650
+ assert_equal "document", @api.get_asset_type("test.txt")
1651
+ assert_equal "document", @api.get_asset_type("test.md")
1652
+ assert_equal "video", @api.get_asset_type("test.mp4")
1653
+ assert_equal "audio", @api.get_asset_type("test.mp3")
1654
+ assert_equal "other", @api.get_asset_type("test.xyz")
1655
+ assert_nil @api.get_asset_type(nil)
1656
+ end
1657
+
1658
+ def test_1016_bulk_copy_assets
1659
+ # @api is already set up in setup method
1660
+ @api.create_view("testview", "Test View", "Test Subtitle")
1661
+
1662
+ # Create multiple source assets
1663
+ write_file("test/scriptorium-TEST/assets/bulk1.jpg", "Bulk 1")
1664
+ write_file("test/scriptorium-TEST/assets/bulk2.png", "Bulk 2")
1665
+ write_file("test/scriptorium-TEST/assets/bulk3.txt", "Bulk 3")
1666
+
1667
+ filenames = ["bulk1.jpg", "bulk2.png", "bulk3.txt"]
1668
+
1669
+ # Bulk copy
1670
+ results = @api.bulk_copy_assets(filenames, from: 'global', to: 'view', view: 'testview')
1671
+
1672
+ # Verify results
1673
+ assert_equal 3, results.length
1674
+ assert results.all? { |r| r[:success] }
1675
+
1676
+ # Verify files were copied
1677
+ filenames.each do |filename|
1678
+ assert File.exist?("test/scriptorium-TEST/views/testview/assets/#{filename}")
1679
+ end
1680
+ end
1681
+
1682
+ def test_1017_copy_asset_invalid_source
1683
+ # @api is already set up in setup method
1684
+
1685
+ # Try to copy from invalid source
1686
+ assert_raises(InvalidFormatError) do
1687
+ @api.copy_asset("test.jpg", from: 'invalid', to: 'global')
1688
+ end
1689
+ end
1690
+
1691
+ def test_1018_copy_asset_invalid_target
1692
+ # @api is already set up in setup method
1693
+
1694
+ # Try to copy to invalid target
1695
+ assert_raises(InvalidFormatError) do
1696
+ @api.copy_asset("test.jpg", from: 'global', to: 'invalid')
1697
+ end
1698
+ end
1699
+
1700
+ def test_1019_copy_asset_source_not_found
1701
+ # @api is already set up in setup method
1702
+
1703
+ # Try to copy non-existent asset
1704
+ assert_raises(FileNotFoundError) do
1705
+ @api.copy_asset("missing.jpg", from: 'global', to: 'global')
1706
+ end
1707
+ end
1708
+
1709
+ def test_1020_list_assets_no_view_specified
1710
+ # @api is already set up in setup method
1711
+
1712
+ # Should work for global assets
1713
+ assets = @api.list_assets(target: 'global')
1714
+ assert assets.is_a?(Array)
1715
+
1716
+ # Clear the current view to test the error case
1717
+ @api.repo.instance_variable_set(:@current_view, nil)
1718
+
1719
+ # Should fail for view assets without view
1720
+ assert_raises(ViewTargetNil) do
1721
+ @api.list_assets(target: 'view')
1722
+ end
1723
+ end
1724
+
1725
+ def test_999_creates_posts_with_sequential_ids
1726
+ # Create three posts
1727
+ post1 = @api.create_post(
1728
+ "First Post",
1729
+ "This is the first post content.",
1730
+ views: "sample",
1731
+ tags: "test"
1732
+ )
1733
+
1734
+ post2 = @api.create_post(
1735
+ "Second Post",
1736
+ "This is the second post content.",
1737
+ views: "sample",
1738
+ tags: "test"
1739
+ )
1740
+
1741
+ post3 = @api.create_post(
1742
+ "Third Post",
1743
+ "This is the third post content.",
1744
+ views: "sample",
1745
+ tags: "test"
1746
+ )
1747
+
1748
+ # Verify they have sequential IDs
1749
+ assert_equal 1, post1.id, "First post should have ID 1"
1750
+ assert_equal 2, post2.id, "Second post should have ID 2"
1751
+ assert_equal 3, post3.id, "Third post should have ID 3"
1752
+
1753
+ # Verify the posts exist in the repository
1754
+ assert_equal "First Post", @api.post(1).title
1755
+ assert_equal "Second Post", @api.post(2).title
1756
+ assert_equal "Third Post", @api.post(3).title
1757
+ end
1758
+
1759
+ def test_998_post_counter_is_correctly_updated
1760
+ # Check initial counter
1761
+ initial_counter = @api.instance_variable_get(:@repo).last_post_num
1762
+ assert_equal 0, initial_counter, "Initial counter should be 0"
1763
+
1764
+ # Create a post
1765
+ post = @api.create_post(
1766
+ "Test Post",
1767
+ "Test content",
1768
+ views: "sample"
1769
+ )
1770
+
1771
+ # Check counter after creation
1772
+ updated_counter = @api.instance_variable_get(:@repo).last_post_num
1773
+ assert_equal 1, updated_counter, "Counter should be 1 after creating one post"
1774
+ assert_equal 1, post.id, "Post should have ID 1"
1775
+ end
1776
+ end