@flickfyi/flkr 0.0.141 → 0.0.153

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 (494) hide show
  1. package/node_modules/@flickfyi/attestation/.turbo/turbo-build.log +7 -7
  2. package/node_modules/@flickfyi/biscuit/.turbo/turbo-build.log +7 -7
  3. package/node_modules/@flickfyi/core/.oxlintrc.json +2 -1
  4. package/node_modules/@flickfyi/core/.turbo/turbo-build.log +499 -92
  5. package/node_modules/@flickfyi/core/README.md +58 -4
  6. package/node_modules/@flickfyi/core/dist/index.cjs +1 -1
  7. package/node_modules/@flickfyi/core/dist/index.js +35 -24
  8. package/node_modules/@flickfyi/core/dist/kowalski/calculations.d.ts +17 -0
  9. package/node_modules/@flickfyi/core/dist/kowalski/calculator.d.ts +13 -0
  10. package/node_modules/@flickfyi/core/dist/kowalski/catalog.cjs +1 -0
  11. package/node_modules/@flickfyi/core/dist/kowalski/catalog.d.cts +962 -0
  12. package/node_modules/@flickfyi/core/dist/kowalski/catalog.d.ts +962 -0
  13. package/node_modules/@flickfyi/core/dist/kowalski/catalog.js +639 -0
  14. package/node_modules/@flickfyi/core/dist/kowalski/clocks.d.ts +4 -0
  15. package/node_modules/@flickfyi/core/dist/kowalski/date-time.d.ts +22 -0
  16. package/node_modules/@flickfyi/core/dist/kowalski/details.d.ts +7 -0
  17. package/node_modules/@flickfyi/core/dist/kowalski/everyday.d.ts +8 -0
  18. package/node_modules/@flickfyi/core/dist/kowalski/format.d.ts +4 -0
  19. package/node_modules/@flickfyi/core/dist/kowalski/frame.d.ts +161 -0
  20. package/node_modules/@flickfyi/core/dist/kowalski/index.cjs +21 -0
  21. package/node_modules/@flickfyi/core/dist/kowalski/index.d.cts +8 -0
  22. package/node_modules/@flickfyi/core/dist/kowalski/index.d.ts +8 -0
  23. package/node_modules/@flickfyi/core/dist/kowalski/index.js +7496 -0
  24. package/node_modules/@flickfyi/core/dist/kowalski/productivity.d.ts +5 -0
  25. package/node_modules/@flickfyi/core/dist/kowalski/recipe.d.ts +8 -0
  26. package/node_modules/@flickfyi/core/dist/kowalski/tools.d.ts +7 -0
  27. package/node_modules/@flickfyi/core/dist/kowalski/use-clock.d.ts +6 -0
  28. package/node_modules/@flickfyi/core/dist/kowalski/use-configured-feature.d.ts +19 -0
  29. package/node_modules/@flickfyi/core/dist/kowalski/use-feature-store.d.ts +15 -0
  30. package/node_modules/@flickfyi/core/dist/kowalski.css +506 -0
  31. package/node_modules/@flickfyi/core/dist/molecules/field/field.d.ts +8 -0
  32. package/node_modules/@flickfyi/core/dist/molecules/index.cjs +1 -1
  33. package/node_modules/@flickfyi/core/dist/molecules/index.d.cts +3 -0
  34. package/node_modules/@flickfyi/core/dist/molecules/index.d.ts +3 -0
  35. package/node_modules/@flickfyi/core/dist/molecules/index.js +88 -27
  36. package/node_modules/@flickfyi/core/dist/molecules/segmented/segmented.d.ts +10 -0
  37. package/node_modules/@flickfyi/core/dist/molecules/stepper/stepper.d.ts +10 -0
  38. package/node_modules/@flickfyi/core/dist/quantum/use-store.d.ts +7 -1
  39. package/node_modules/@flickfyi/core/dist/themes.css +2 -0
  40. package/node_modules/@flickfyi/core/examples/kowalski/bill-split/bill-split.stories.tsx +15 -0
  41. package/node_modules/@flickfyi/core/examples/kowalski/bill-split/bill-split.tsx +17 -0
  42. package/node_modules/@flickfyi/core/examples/kowalski/bookmark/bookmark.stories.tsx +15 -0
  43. package/node_modules/@flickfyi/core/examples/kowalski/bookmark/bookmark.tsx +23 -0
  44. package/node_modules/@flickfyi/core/examples/kowalski/budget/budget.stories.tsx +15 -0
  45. package/node_modules/@flickfyi/core/examples/kowalski/budget/budget.tsx +26 -0
  46. package/node_modules/@flickfyi/core/examples/kowalski/calculator/calculator.stories.tsx +15 -0
  47. package/node_modules/@flickfyi/core/examples/kowalski/calculator/calculator.tsx +24 -0
  48. package/node_modules/@flickfyi/core/examples/kowalski/checklist/checklist.stories.tsx +15 -0
  49. package/node_modules/@flickfyi/core/examples/kowalski/checklist/checklist.tsx +26 -0
  50. package/node_modules/@flickfyi/core/examples/kowalski/color-palette/color-palette.stories.tsx +15 -0
  51. package/node_modules/@flickfyi/core/examples/kowalski/color-palette/color-palette.tsx +22 -0
  52. package/node_modules/@flickfyi/core/examples/kowalski/contact/contact.stories.tsx +15 -0
  53. package/node_modules/@flickfyi/core/examples/kowalski/contact/contact.tsx +23 -0
  54. package/node_modules/@flickfyi/core/examples/kowalski/countdown/countdown.stories.tsx +15 -0
  55. package/node_modules/@flickfyi/core/examples/kowalski/countdown/countdown.tsx +23 -0
  56. package/node_modules/@flickfyi/core/examples/kowalski/event/event.stories.tsx +15 -0
  57. package/node_modules/@flickfyi/core/examples/kowalski/event/event.tsx +24 -0
  58. package/node_modules/@flickfyi/core/examples/kowalski/expense/expense.stories.tsx +15 -0
  59. package/node_modules/@flickfyi/core/examples/kowalski/expense/expense.tsx +23 -0
  60. package/node_modules/@flickfyi/core/examples/kowalski/goal/goal.stories.tsx +15 -0
  61. package/node_modules/@flickfyi/core/examples/kowalski/goal/goal.tsx +24 -0
  62. package/node_modules/@flickfyi/core/examples/kowalski/habit/habit.stories.tsx +15 -0
  63. package/node_modules/@flickfyi/core/examples/kowalski/habit/habit.tsx +23 -0
  64. package/node_modules/@flickfyi/core/examples/kowalski/ledger/ledger.stories.tsx +15 -0
  65. package/node_modules/@flickfyi/core/examples/kowalski/ledger/ledger.tsx +25 -0
  66. package/node_modules/@flickfyi/core/examples/kowalski/note/note.stories.tsx +15 -0
  67. package/node_modules/@flickfyi/core/examples/kowalski/note/note.tsx +22 -0
  68. package/node_modules/@flickfyi/core/examples/kowalski/poll/poll.stories.tsx +15 -0
  69. package/node_modules/@flickfyi/core/examples/kowalski/poll/poll.tsx +26 -0
  70. package/node_modules/@flickfyi/core/examples/kowalski/random-picker/random-picker.stories.tsx +15 -0
  71. package/node_modules/@flickfyi/core/examples/kowalski/random-picker/random-picker.tsx +23 -0
  72. package/node_modules/@flickfyi/core/examples/kowalski/rating/rating.stories.tsx +15 -0
  73. package/node_modules/@flickfyi/core/examples/kowalski/rating/rating.tsx +19 -0
  74. package/node_modules/@flickfyi/core/examples/kowalski/recipe/recipe.stories.tsx +15 -0
  75. package/node_modules/@flickfyi/core/examples/kowalski/recipe/recipe.tsx +120 -0
  76. package/node_modules/@flickfyi/core/examples/kowalski/reminder/reminder.stories.tsx +15 -0
  77. package/node_modules/@flickfyi/core/examples/kowalski/reminder/reminder.tsx +23 -0
  78. package/node_modules/@flickfyi/core/examples/kowalski/rotation/rotation.stories.tsx +15 -0
  79. package/node_modules/@flickfyi/core/examples/kowalski/rotation/rotation.tsx +27 -0
  80. package/node_modules/@flickfyi/core/examples/kowalski/scoreboard/scoreboard.stories.tsx +15 -0
  81. package/node_modules/@flickfyi/core/examples/kowalski/scoreboard/scoreboard.tsx +26 -0
  82. package/node_modules/@flickfyi/core/examples/kowalski/shopping-list/shopping-list.stories.tsx +15 -0
  83. package/node_modules/@flickfyi/core/examples/kowalski/shopping-list/shopping-list.tsx +27 -0
  84. package/node_modules/@flickfyi/core/examples/kowalski/task-board/task-board.stories.tsx +15 -0
  85. package/node_modules/@flickfyi/core/examples/kowalski/task-board/task-board.tsx +27 -0
  86. package/node_modules/@flickfyi/core/examples/kowalski/theme.ts +3 -0
  87. package/node_modules/@flickfyi/core/examples/kowalski/time-zone/time-zone.stories.tsx +15 -0
  88. package/node_modules/@flickfyi/core/examples/kowalski/time-zone/time-zone.tsx +22 -0
  89. package/node_modules/@flickfyi/core/examples/kowalski/timer/timer.stories.tsx +15 -0
  90. package/node_modules/@flickfyi/core/examples/kowalski/timer/timer.tsx +23 -0
  91. package/node_modules/@flickfyi/core/examples/kowalski/trip/trip.stories.tsx +15 -0
  92. package/node_modules/@flickfyi/core/examples/kowalski/trip/trip.tsx +26 -0
  93. package/node_modules/@flickfyi/core/examples/kowalski/unit-converter/unit-converter.stories.tsx +15 -0
  94. package/node_modules/@flickfyi/core/examples/kowalski/unit-converter/unit-converter.tsx +24 -0
  95. package/node_modules/@flickfyi/core/examples/parrott/calculator/calculator.stories.tsx +3 -1
  96. package/node_modules/@flickfyi/core/examples/parrott/checklist/checklist.stories.tsx +3 -1
  97. package/node_modules/@flickfyi/core/examples/parrott/countdown/countdown.stories.tsx +3 -1
  98. package/node_modules/@flickfyi/core/examples/parrott/counter/counter.stories.tsx +1 -0
  99. package/node_modules/@flickfyi/core/examples/parrott/fortune/fortune.stories.tsx +3 -1
  100. package/node_modules/@flickfyi/core/examples/parrott/grid/grid.stories.tsx +3 -1
  101. package/node_modules/@flickfyi/core/examples/parrott/kale-smoothie/kale-smoothie.stories.tsx +1 -0
  102. package/node_modules/@flickfyi/core/examples/parrott/list/list.stories.tsx +3 -1
  103. package/node_modules/@flickfyi/core/examples/parrott/log/log.stories.tsx +3 -1
  104. package/node_modules/@flickfyi/core/examples/parrott/timed-task/timed-task.stories.tsx +3 -1
  105. package/node_modules/@flickfyi/core/examples/parrott/timer/timer.stories.tsx +3 -1
  106. package/node_modules/@flickfyi/core/fixtures/native/host/project.yml +3 -0
  107. package/node_modules/@flickfyi/core/fixtures/native/src/catalog-features.tsx +88 -0
  108. package/node_modules/@flickfyi/core/package.json +35 -5
  109. package/node_modules/@flickfyi/core/package.tsgo.json +19 -1
  110. package/node_modules/@flickfyi/core/scripts/vite-globals.d.ts +2 -0
  111. package/node_modules/@flickfyi/core/src/generated-types.d.ts +1 -0
  112. package/node_modules/@flickfyi/core/src/kowalski/README.md +9 -0
  113. package/node_modules/@flickfyi/core/src/kowalski/calculations.ts +106 -0
  114. package/node_modules/@flickfyi/core/src/kowalski/calculator.ts +243 -0
  115. package/node_modules/@flickfyi/core/src/kowalski/catalog.ts +787 -0
  116. package/node_modules/@flickfyi/core/src/kowalski/clocks.tsx +464 -0
  117. package/node_modules/@flickfyi/core/src/kowalski/date-time.tsx +469 -0
  118. package/node_modules/@flickfyi/core/src/kowalski/details.tsx +1387 -0
  119. package/node_modules/@flickfyi/core/src/kowalski/everyday.tsx +652 -0
  120. package/node_modules/@flickfyi/core/src/kowalski/format.ts +48 -0
  121. package/node_modules/@flickfyi/core/src/kowalski/frame.tsx +351 -0
  122. package/node_modules/@flickfyi/core/src/kowalski/index.ts +23 -0
  123. package/node_modules/@flickfyi/core/src/kowalski/productivity.tsx +575 -0
  124. package/node_modules/@flickfyi/core/src/kowalski/recipe.tsx +555 -0
  125. package/node_modules/@flickfyi/core/src/kowalski/stories/composition.stories.tsx +47 -0
  126. package/node_modules/@flickfyi/core/src/kowalski/stories/composition.tsx +100 -0
  127. package/node_modules/@flickfyi/core/src/kowalski/tools.tsx +1213 -0
  128. package/node_modules/@flickfyi/core/src/kowalski/use-clock.ts +19 -0
  129. package/node_modules/@flickfyi/core/src/kowalski/use-configured-feature.ts +85 -0
  130. package/node_modules/@flickfyi/core/src/kowalski/use-feature-store.ts +119 -0
  131. package/node_modules/@flickfyi/core/src/molecules/field/field.tsx +34 -0
  132. package/node_modules/@flickfyi/core/src/molecules/index.ts +3 -0
  133. package/node_modules/@flickfyi/core/src/molecules/segmented/segmented.tsx +48 -0
  134. package/node_modules/@flickfyi/core/src/molecules/stepper/stepper.tsx +54 -0
  135. package/node_modules/@flickfyi/core/src/quantum/use-store.ts +41 -16
  136. package/node_modules/@flickfyi/core/tests/fly.test.ts +34 -7
  137. package/node_modules/@flickfyi/core/tests/kowalski-calculations.test.ts +60 -0
  138. package/node_modules/@flickfyi/core/tests/kowalski-calculator.test.ts +142 -0
  139. package/node_modules/@flickfyi/core/tests/kowalski-contracts.test.ts +223 -0
  140. package/node_modules/@flickfyi/core/tests/kowalski-date-time.test.tsx +462 -0
  141. package/node_modules/@flickfyi/core/tests/kowalski-features.test.tsx +2158 -0
  142. package/node_modules/@flickfyi/core/tests/kowalski-store.test.tsx +514 -0
  143. package/node_modules/@flickfyi/core/tests/preview-build.test.ts +1 -1
  144. package/node_modules/@flickfyi/core/tests/quantum-use-store.test.ts +53 -1
  145. package/node_modules/@flickfyi/core/tests/verify-tsgo-assets.test.ts +1 -1
  146. package/node_modules/@flickfyi/core/tsconfig.vite-dts.json +2 -1
  147. package/node_modules/@flickfyi/core/tsgo.bun.lock +111 -9
  148. package/node_modules/@flickfyi/core/tsgo.package.json +21 -3
  149. package/node_modules/@flickfyi/core/vite.config.ts +9 -5
  150. package/node_modules/@flickfyi/core/vitest.config.ts +1 -1
  151. package/node_modules/@flickfyi/http-message-sig/.turbo/turbo-build.log +7 -7
  152. package/node_modules/@flickfyi/identity/.turbo/turbo-build.log +8 -9
  153. package/node_modules/@flickfyi/lens/.turbo/turbo-build.log +7 -7
  154. package/node_modules/@flickfyi/photon/.turbo/turbo-build.log +4 -4
  155. package/node_modules/@flickfyi/photon/Sources/Photon/Components/Button.swift +29 -2
  156. package/node_modules/@flickfyi/photon/Sources/PhotonVersion/Resources/photon-version.json +1 -1
  157. package/node_modules/@flickfyi/photon/Tests/PhotonTests/Components/ButtonHostTests.swift +26 -0
  158. package/node_modules/@flickfyi/photon/Tests/PhotonTests/Fixtures/StyleX/catalog-features.styles.json +1 -0
  159. package/node_modules/@flickfyi/photon/Tests/PhotonTests/Fixtures/StyleX/catalog-native.styles.json +1 -1
  160. package/node_modules/@flickfyi/photon/Tests/PhotonTests/Fixtures/StyleX/catalog-scale.styles.json +1 -1
  161. package/node_modules/@flickfyi/photon/Tests/PhotonTests/Fixtures/StyleX/catalog-theme.styles.json +1 -1
  162. package/node_modules/@flickfyi/photon/Tests/PhotonTests/Fixtures/StyleX/catalog-typography.styles.json +1 -1
  163. package/node_modules/@flickfyi/photon/Tests/PhotonTests/Fixtures/StyleX/stylex-calculator.styles.json +1 -1
  164. package/node_modules/@flickfyi/photon/dist/globals.d.ts +5 -0
  165. package/node_modules/@flickfyi/photon/dist/version.cjs +1 -1
  166. package/node_modules/@flickfyi/photon/dist/version.js +1 -1
  167. package/node_modules/@flickfyi/photon/generated/react-swiftui.d.ts +1 -0
  168. package/node_modules/@flickfyi/photon/package.json +1 -1
  169. package/node_modules/@flickfyi/photon/src/globals.d.ts +5 -0
  170. package/node_modules/@flickfyi/rwsdk-extensions/.turbo/turbo-build.log +7 -7
  171. package/node_modules/@formatjs/fast-memoize/LICENSE.md +9 -0
  172. package/node_modules/@formatjs/fast-memoize/README.md +3 -0
  173. package/node_modules/@formatjs/fast-memoize/index.d.ts +35 -0
  174. package/node_modules/@formatjs/fast-memoize/index.js +69 -0
  175. package/node_modules/@formatjs/fast-memoize/index.js.map +1 -0
  176. package/node_modules/@formatjs/fast-memoize/package.json +25 -0
  177. package/node_modules/@formatjs/icu-messageformat-parser/LICENSE.md +9 -0
  178. package/node_modules/@formatjs/icu-messageformat-parser/README.md +38 -0
  179. package/node_modules/@formatjs/icu-messageformat-parser/index.d.ts +393 -0
  180. package/node_modules/@formatjs/icu-messageformat-parser/index.js +2187 -0
  181. package/node_modules/@formatjs/icu-messageformat-parser/index.js.map +1 -0
  182. package/node_modules/@formatjs/icu-messageformat-parser/manipulator.d.ts +175 -0
  183. package/node_modules/@formatjs/icu-messageformat-parser/manipulator.js +209 -0
  184. package/node_modules/@formatjs/icu-messageformat-parser/manipulator.js.map +1 -0
  185. package/node_modules/@formatjs/icu-messageformat-parser/no-parser.d.ts +183 -0
  186. package/node_modules/@formatjs/icu-messageformat-parser/no-parser.js +162 -0
  187. package/node_modules/@formatjs/icu-messageformat-parser/no-parser.js.map +1 -0
  188. package/node_modules/@formatjs/icu-messageformat-parser/package.json +21 -0
  189. package/node_modules/@formatjs/icu-messageformat-parser/printer.d.ts +154 -0
  190. package/node_modules/@formatjs/icu-messageformat-parser/printer.js +224 -0
  191. package/node_modules/@formatjs/icu-messageformat-parser/printer.js.map +1 -0
  192. package/node_modules/@formatjs/icu-skeleton-parser/LICENSE.md +9 -0
  193. package/node_modules/@formatjs/icu-skeleton-parser/README.md +1 -0
  194. package/node_modules/@formatjs/icu-skeleton-parser/index.d.ts +67 -0
  195. package/node_modules/@formatjs/icu-skeleton-parser/index.js +380 -0
  196. package/node_modules/@formatjs/icu-skeleton-parser/index.js.map +1 -0
  197. package/node_modules/@formatjs/icu-skeleton-parser/package.json +15 -0
  198. package/node_modules/@formatjs/intl/LICENSE.md +9 -0
  199. package/node_modules/@formatjs/intl/README.md +3 -0
  200. package/node_modules/@formatjs/intl/index.d.ts +499 -0
  201. package/node_modules/@formatjs/intl/index.js +595 -0
  202. package/node_modules/@formatjs/intl/index.js.map +1 -0
  203. package/node_modules/@formatjs/intl/package.json +37 -0
  204. package/node_modules/decimal.js/LICENCE.md +23 -0
  205. package/node_modules/decimal.js/README.md +246 -0
  206. package/node_modules/decimal.js/decimal.d.ts +301 -0
  207. package/node_modules/decimal.js/decimal.js +4951 -0
  208. package/node_modules/decimal.js/decimal.mjs +4914 -0
  209. package/node_modules/decimal.js/package.json +55 -0
  210. package/node_modules/format-quantity/LICENSE +21 -0
  211. package/node_modules/format-quantity/README.md +181 -0
  212. package/node_modules/format-quantity/dist/cjs/format-quantity.cjs.development.d.ts +131 -0
  213. package/node_modules/format-quantity/dist/cjs/format-quantity.cjs.development.js +214 -0
  214. package/node_modules/format-quantity/dist/cjs/format-quantity.cjs.development.js.map +1 -0
  215. package/node_modules/format-quantity/dist/cjs/format-quantity.cjs.production.d.ts +131 -0
  216. package/node_modules/format-quantity/dist/cjs/format-quantity.cjs.production.js +2 -0
  217. package/node_modules/format-quantity/dist/cjs/format-quantity.cjs.production.js.map +1 -0
  218. package/node_modules/format-quantity/dist/cjs/index.d.ts +1 -0
  219. package/node_modules/format-quantity/dist/cjs/index.js +6 -0
  220. package/node_modules/format-quantity/dist/format-quantity.d.mts +131 -0
  221. package/node_modules/format-quantity/dist/format-quantity.legacy-esm.d.ts +131 -0
  222. package/node_modules/format-quantity/dist/format-quantity.legacy-esm.js +268 -0
  223. package/node_modules/format-quantity/dist/format-quantity.legacy-esm.js.map +1 -0
  224. package/node_modules/format-quantity/dist/format-quantity.mjs +208 -0
  225. package/node_modules/format-quantity/dist/format-quantity.mjs.map +1 -0
  226. package/node_modules/format-quantity/dist/format-quantity.production.d.mts +131 -0
  227. package/node_modules/format-quantity/dist/format-quantity.production.mjs +2 -0
  228. package/node_modules/format-quantity/dist/format-quantity.production.mjs.map +1 -0
  229. package/node_modules/format-quantity/dist/format-quantity.umd.min.js +3 -0
  230. package/node_modules/format-quantity/dist/format-quantity.umd.min.js.map +1 -0
  231. package/node_modules/format-quantity/package.json +81 -0
  232. package/node_modules/intl-messageformat/LICENSE.md +33 -0
  233. package/node_modules/intl-messageformat/README.md +3 -0
  234. package/node_modules/intl-messageformat/index.d.ts +177 -0
  235. package/node_modules/intl-messageformat/index.js +327 -0
  236. package/node_modules/intl-messageformat/index.js.map +1 -0
  237. package/node_modules/intl-messageformat/intl-messageformat.iife.js +2819 -0
  238. package/node_modules/intl-messageformat/package.json +40 -0
  239. package/node_modules/numeric-quantity/LICENSE +21 -0
  240. package/node_modules/numeric-quantity/README.md +306 -0
  241. package/node_modules/numeric-quantity/dist/cjs/index.d.ts +1 -0
  242. package/node_modules/numeric-quantity/dist/cjs/index.js +6 -0
  243. package/node_modules/numeric-quantity/dist/cjs/numeric-quantity.cjs.development.d.ts +293 -0
  244. package/node_modules/numeric-quantity/dist/cjs/numeric-quantity.cjs.development.js +579 -0
  245. package/node_modules/numeric-quantity/dist/cjs/numeric-quantity.cjs.development.js.map +1 -0
  246. package/node_modules/numeric-quantity/dist/cjs/numeric-quantity.cjs.production.d.ts +293 -0
  247. package/node_modules/numeric-quantity/dist/cjs/numeric-quantity.cjs.production.js +2 -0
  248. package/node_modules/numeric-quantity/dist/cjs/numeric-quantity.cjs.production.js.map +1 -0
  249. package/node_modules/numeric-quantity/dist/numeric-quantity.d.mts +293 -0
  250. package/node_modules/numeric-quantity/dist/numeric-quantity.iife.umd.min.js +2 -0
  251. package/node_modules/numeric-quantity/dist/numeric-quantity.iife.umd.min.js.map +1 -0
  252. package/node_modules/numeric-quantity/dist/numeric-quantity.legacy-esm.d.ts +293 -0
  253. package/node_modules/numeric-quantity/dist/numeric-quantity.legacy-esm.js +622 -0
  254. package/node_modules/numeric-quantity/dist/numeric-quantity.legacy-esm.js.map +1 -0
  255. package/node_modules/numeric-quantity/dist/numeric-quantity.mjs +564 -0
  256. package/node_modules/numeric-quantity/dist/numeric-quantity.mjs.map +1 -0
  257. package/node_modules/numeric-quantity/dist/numeric-quantity.production.d.mts +293 -0
  258. package/node_modules/numeric-quantity/dist/numeric-quantity.production.mjs +2 -0
  259. package/node_modules/numeric-quantity/dist/numeric-quantity.production.mjs.map +1 -0
  260. package/node_modules/numeric-quantity/package.json +72 -0
  261. package/node_modules/validator/LICENSE +20 -0
  262. package/node_modules/validator/README.md +269 -0
  263. package/node_modules/validator/es/index.js +218 -0
  264. package/node_modules/validator/es/lib/alpha.js +151 -0
  265. package/node_modules/validator/es/lib/blacklist.js +5 -0
  266. package/node_modules/validator/es/lib/contains.js +15 -0
  267. package/node_modules/validator/es/lib/equals.js +5 -0
  268. package/node_modules/validator/es/lib/escape.js +5 -0
  269. package/node_modules/validator/es/lib/isAbaRouting.js +15 -0
  270. package/node_modules/validator/es/lib/isAfter.js +10 -0
  271. package/node_modules/validator/es/lib/isAlpha.js +23 -0
  272. package/node_modules/validator/es/lib/isAlphanumeric.js +23 -0
  273. package/node_modules/validator/es/lib/isAscii.js +10 -0
  274. package/node_modules/validator/es/lib/isBIC.js +16 -0
  275. package/node_modules/validator/es/lib/isBase32.js +15 -0
  276. package/node_modules/validator/es/lib/isBase58.js +8 -0
  277. package/node_modules/validator/es/lib/isBase64.js +23 -0
  278. package/node_modules/validator/es/lib/isBefore.js +10 -0
  279. package/node_modules/validator/es/lib/isBoolean.js +15 -0
  280. package/node_modules/validator/es/lib/isBtcAddress.js +7 -0
  281. package/node_modules/validator/es/lib/isByteLength.js +19 -0
  282. package/node_modules/validator/es/lib/isCreditCard.js +43 -0
  283. package/node_modules/validator/es/lib/isCurrency.js +74 -0
  284. package/node_modules/validator/es/lib/isDataURI.js +31 -0
  285. package/node_modules/validator/es/lib/isDate.js +93 -0
  286. package/node_modules/validator/es/lib/isDecimal.js +22 -0
  287. package/node_modules/validator/es/lib/isDivisibleBy.js +6 -0
  288. package/node_modules/validator/es/lib/isEAN.js +70 -0
  289. package/node_modules/validator/es/lib/isEmail.js +165 -0
  290. package/node_modules/validator/es/lib/isEmpty.js +10 -0
  291. package/node_modules/validator/es/lib/isEthereumAddress.js +6 -0
  292. package/node_modules/validator/es/lib/isFQDN.js +67 -0
  293. package/node_modules/validator/es/lib/isFloat.js +14 -0
  294. package/node_modules/validator/es/lib/isFullWidth.js +6 -0
  295. package/node_modules/validator/es/lib/isHSL.js +13 -0
  296. package/node_modules/validator/es/lib/isHalfWidth.js +6 -0
  297. package/node_modules/validator/es/lib/isHash.js +21 -0
  298. package/node_modules/validator/es/lib/isHexColor.js +13 -0
  299. package/node_modules/validator/es/lib/isHexadecimal.js +6 -0
  300. package/node_modules/validator/es/lib/isIBAN.js +171 -0
  301. package/node_modules/validator/es/lib/isIMEI.js +40 -0
  302. package/node_modules/validator/es/lib/isIP.js +58 -0
  303. package/node_modules/validator/es/lib/isIPRange.js +41 -0
  304. package/node_modules/validator/es/lib/isISBN.js +47 -0
  305. package/node_modules/validator/es/lib/isISIN.js +55 -0
  306. package/node_modules/validator/es/lib/isISO15924.js +9 -0
  307. package/node_modules/validator/es/lib/isISO31661Alpha2.js +21 -0
  308. package/node_modules/validator/es/lib/isISO31661Alpha3.js +20 -0
  309. package/node_modules/validator/es/lib/isISO31661Numeric.js +8 -0
  310. package/node_modules/validator/es/lib/isISO4217.js +9 -0
  311. package/node_modules/validator/es/lib/isISO6346.js +28 -0
  312. package/node_modules/validator/es/lib/isISO6391.js +6 -0
  313. package/node_modules/validator/es/lib/isISO8601.js +42 -0
  314. package/node_modules/validator/es/lib/isISRC.js +8 -0
  315. package/node_modules/validator/es/lib/isISSN.js +19 -0
  316. package/node_modules/validator/es/lib/isIdentityCard.js +407 -0
  317. package/node_modules/validator/es/lib/isIn.js +23 -0
  318. package/node_modules/validator/es/lib/isInt.js +19 -0
  319. package/node_modules/validator/es/lib/isJSON.js +26 -0
  320. package/node_modules/validator/es/lib/isJWT.js +15 -0
  321. package/node_modules/validator/es/lib/isLatLong.js +21 -0
  322. package/node_modules/validator/es/lib/isLength.js +27 -0
  323. package/node_modules/validator/es/lib/isLicensePlate.js +58 -0
  324. package/node_modules/validator/es/lib/isLocale.js +107 -0
  325. package/node_modules/validator/es/lib/isLowercase.js +5 -0
  326. package/node_modules/validator/es/lib/isLuhnNumber.js +25 -0
  327. package/node_modules/validator/es/lib/isMACAddress.js +36 -0
  328. package/node_modules/validator/es/lib/isMD5.js +6 -0
  329. package/node_modules/validator/es/lib/isMagnetURI.js +9 -0
  330. package/node_modules/validator/es/lib/isMailtoURI.js +75 -0
  331. package/node_modules/validator/es/lib/isMimeType.js +40 -0
  332. package/node_modules/validator/es/lib/isMobilePhone.js +212 -0
  333. package/node_modules/validator/es/lib/isMongoId.js +6 -0
  334. package/node_modules/validator/es/lib/isMultibyte.js +10 -0
  335. package/node_modules/validator/es/lib/isNumeric.js +10 -0
  336. package/node_modules/validator/es/lib/isOctal.js +6 -0
  337. package/node_modules/validator/es/lib/isPassportNumber.js +145 -0
  338. package/node_modules/validator/es/lib/isPort.js +8 -0
  339. package/node_modules/validator/es/lib/isPostalCode.js +100 -0
  340. package/node_modules/validator/es/lib/isRFC3339.js +21 -0
  341. package/node_modules/validator/es/lib/isRgbColor.js +34 -0
  342. package/node_modules/validator/es/lib/isSemVer.js +14 -0
  343. package/node_modules/validator/es/lib/isSlug.js +6 -0
  344. package/node_modules/validator/es/lib/isStrongPassword.js +90 -0
  345. package/node_modules/validator/es/lib/isSurrogatePair.js +6 -0
  346. package/node_modules/validator/es/lib/isTaxID.js +1407 -0
  347. package/node_modules/validator/es/lib/isTime.js +22 -0
  348. package/node_modules/validator/es/lib/isULID.js +5 -0
  349. package/node_modules/validator/es/lib/isURL.js +241 -0
  350. package/node_modules/validator/es/lib/isUUID.js +23 -0
  351. package/node_modules/validator/es/lib/isUppercase.js +5 -0
  352. package/node_modules/validator/es/lib/isVAT.js +264 -0
  353. package/node_modules/validator/es/lib/isVariableWidth.js +7 -0
  354. package/node_modules/validator/es/lib/isWhitelisted.js +10 -0
  355. package/node_modules/validator/es/lib/ltrim.js +7 -0
  356. package/node_modules/validator/es/lib/matches.js +8 -0
  357. package/node_modules/validator/es/lib/normalizeEmail.js +131 -0
  358. package/node_modules/validator/es/lib/rtrim.js +15 -0
  359. package/node_modules/validator/es/lib/stripLow.js +7 -0
  360. package/node_modules/validator/es/lib/toBoolean.js +8 -0
  361. package/node_modules/validator/es/lib/toDate.js +6 -0
  362. package/node_modules/validator/es/lib/toFloat.js +5 -0
  363. package/node_modules/validator/es/lib/toInt.js +5 -0
  364. package/node_modules/validator/es/lib/trim.js +5 -0
  365. package/node_modules/validator/es/lib/unescape.js +8 -0
  366. package/node_modules/validator/es/lib/util/algorithms.js +79 -0
  367. package/node_modules/validator/es/lib/util/assertString.js +4 -0
  368. package/node_modules/validator/es/lib/util/checkHost.js +12 -0
  369. package/node_modules/validator/es/lib/util/includesArray.js +6 -0
  370. package/node_modules/validator/es/lib/util/includesString.js +4 -0
  371. package/node_modules/validator/es/lib/util/merge.js +14 -0
  372. package/node_modules/validator/es/lib/util/multilineRegex.js +12 -0
  373. package/node_modules/validator/es/lib/util/nullUndefinedCheck.js +3 -0
  374. package/node_modules/validator/es/lib/util/toString.js +13 -0
  375. package/node_modules/validator/es/lib/util/typeOf.js +10 -0
  376. package/node_modules/validator/es/lib/whitelist.js +5 -0
  377. package/node_modules/validator/index.js +229 -0
  378. package/node_modules/validator/lib/alpha.js +157 -0
  379. package/node_modules/validator/lib/blacklist.js +14 -0
  380. package/node_modules/validator/lib/contains.js +24 -0
  381. package/node_modules/validator/lib/equals.js +14 -0
  382. package/node_modules/validator/lib/escape.js +14 -0
  383. package/node_modules/validator/lib/isAbaRouting.js +23 -0
  384. package/node_modules/validator/lib/isAfter.js +19 -0
  385. package/node_modules/validator/lib/isAlpha.js +31 -0
  386. package/node_modules/validator/lib/isAlphanumeric.js +31 -0
  387. package/node_modules/validator/lib/isAscii.js +18 -0
  388. package/node_modules/validator/lib/isBIC.js +24 -0
  389. package/node_modules/validator/lib/isBase32.js +24 -0
  390. package/node_modules/validator/lib/isBase58.js +16 -0
  391. package/node_modules/validator/lib/isBase64.js +32 -0
  392. package/node_modules/validator/lib/isBefore.js +19 -0
  393. package/node_modules/validator/lib/isBoolean.js +24 -0
  394. package/node_modules/validator/lib/isBtcAddress.js +16 -0
  395. package/node_modules/validator/lib/isByteLength.js +27 -0
  396. package/node_modules/validator/lib/isCreditCard.js +52 -0
  397. package/node_modules/validator/lib/isCurrency.js +83 -0
  398. package/node_modules/validator/lib/isDataURI.js +40 -0
  399. package/node_modules/validator/lib/isDate.js +102 -0
  400. package/node_modules/validator/lib/isDecimal.js +31 -0
  401. package/node_modules/validator/lib/isDivisibleBy.js +15 -0
  402. package/node_modules/validator/lib/isEAN.js +78 -0
  403. package/node_modules/validator/lib/isEmail.js +174 -0
  404. package/node_modules/validator/lib/isEmpty.js +19 -0
  405. package/node_modules/validator/lib/isEthereumAddress.js +15 -0
  406. package/node_modules/validator/lib/isFQDN.js +76 -0
  407. package/node_modules/validator/lib/isFloat.js +22 -0
  408. package/node_modules/validator/lib/isFullWidth.js +14 -0
  409. package/node_modules/validator/lib/isHSL.js +22 -0
  410. package/node_modules/validator/lib/isHalfWidth.js +14 -0
  411. package/node_modules/validator/lib/isHash.js +30 -0
  412. package/node_modules/validator/lib/isHexColor.js +22 -0
  413. package/node_modules/validator/lib/isHexadecimal.js +15 -0
  414. package/node_modules/validator/lib/isIBAN.js +178 -0
  415. package/node_modules/validator/lib/isIMEI.js +49 -0
  416. package/node_modules/validator/lib/isIP.js +67 -0
  417. package/node_modules/validator/lib/isIPRange.js +50 -0
  418. package/node_modules/validator/lib/isISBN.js +56 -0
  419. package/node_modules/validator/lib/isISIN.js +64 -0
  420. package/node_modules/validator/lib/isISO15924.js +16 -0
  421. package/node_modules/validator/lib/isISO31661Alpha2.js +28 -0
  422. package/node_modules/validator/lib/isISO31661Alpha3.js +28 -0
  423. package/node_modules/validator/lib/isISO31661Numeric.js +16 -0
  424. package/node_modules/validator/lib/isISO4217.js +16 -0
  425. package/node_modules/validator/lib/isISO6346.js +35 -0
  426. package/node_modules/validator/lib/isISO6391.js +15 -0
  427. package/node_modules/validator/lib/isISO8601.js +50 -0
  428. package/node_modules/validator/lib/isISRC.js +16 -0
  429. package/node_modules/validator/lib/isISSN.js +28 -0
  430. package/node_modules/validator/lib/isIdentityCard.js +416 -0
  431. package/node_modules/validator/lib/isIn.js +32 -0
  432. package/node_modules/validator/lib/isInt.js +28 -0
  433. package/node_modules/validator/lib/isJSON.js +35 -0
  434. package/node_modules/validator/lib/isJWT.js +24 -0
  435. package/node_modules/validator/lib/isLatLong.js +30 -0
  436. package/node_modules/validator/lib/isLength.js +35 -0
  437. package/node_modules/validator/lib/isLicensePlate.js +67 -0
  438. package/node_modules/validator/lib/isLocale.js +115 -0
  439. package/node_modules/validator/lib/isLowercase.js +14 -0
  440. package/node_modules/validator/lib/isLuhnNumber.js +34 -0
  441. package/node_modules/validator/lib/isMACAddress.js +45 -0
  442. package/node_modules/validator/lib/isMD5.js +15 -0
  443. package/node_modules/validator/lib/isMagnetURI.js +18 -0
  444. package/node_modules/validator/lib/isMailtoURI.js +84 -0
  445. package/node_modules/validator/lib/isMimeType.js +48 -0
  446. package/node_modules/validator/lib/isMobilePhone.js +219 -0
  447. package/node_modules/validator/lib/isMongoId.js +15 -0
  448. package/node_modules/validator/lib/isMultibyte.js +18 -0
  449. package/node_modules/validator/lib/isNumeric.js +19 -0
  450. package/node_modules/validator/lib/isOctal.js +15 -0
  451. package/node_modules/validator/lib/isPassportNumber.js +152 -0
  452. package/node_modules/validator/lib/isPort.js +17 -0
  453. package/node_modules/validator/lib/isPostalCode.js +107 -0
  454. package/node_modules/validator/lib/isRFC3339.js +29 -0
  455. package/node_modules/validator/lib/isRgbColor.js +42 -0
  456. package/node_modules/validator/lib/isSemVer.js +22 -0
  457. package/node_modules/validator/lib/isSlug.js +15 -0
  458. package/node_modules/validator/lib/isStrongPassword.js +99 -0
  459. package/node_modules/validator/lib/isSurrogatePair.js +15 -0
  460. package/node_modules/validator/lib/isTaxID.js +1417 -0
  461. package/node_modules/validator/lib/isTime.js +31 -0
  462. package/node_modules/validator/lib/isULID.js +14 -0
  463. package/node_modules/validator/lib/isURL.js +249 -0
  464. package/node_modules/validator/lib/isUUID.js +32 -0
  465. package/node_modules/validator/lib/isUppercase.js +14 -0
  466. package/node_modules/validator/lib/isVAT.js +274 -0
  467. package/node_modules/validator/lib/isVariableWidth.js +16 -0
  468. package/node_modules/validator/lib/isWhitelisted.js +19 -0
  469. package/node_modules/validator/lib/ltrim.js +16 -0
  470. package/node_modules/validator/lib/matches.js +17 -0
  471. package/node_modules/validator/lib/normalizeEmail.js +140 -0
  472. package/node_modules/validator/lib/rtrim.js +24 -0
  473. package/node_modules/validator/lib/stripLow.js +16 -0
  474. package/node_modules/validator/lib/toBoolean.js +17 -0
  475. package/node_modules/validator/lib/toDate.js +15 -0
  476. package/node_modules/validator/lib/toFloat.js +14 -0
  477. package/node_modules/validator/lib/toInt.js +14 -0
  478. package/node_modules/validator/lib/trim.js +14 -0
  479. package/node_modules/validator/lib/unescape.js +17 -0
  480. package/node_modules/validator/lib/util/algorithms.js +88 -0
  481. package/node_modules/validator/lib/util/assertString.js +12 -0
  482. package/node_modules/validator/lib/util/checkHost.js +20 -0
  483. package/node_modules/validator/lib/util/includesArray.js +14 -0
  484. package/node_modules/validator/lib/util/includesString.js +12 -0
  485. package/node_modules/validator/lib/util/merge.js +22 -0
  486. package/node_modules/validator/lib/util/multilineRegex.js +20 -0
  487. package/node_modules/validator/lib/util/nullUndefinedCheck.js +11 -0
  488. package/node_modules/validator/lib/util/toString.js +21 -0
  489. package/node_modules/validator/lib/util/typeOf.js +18 -0
  490. package/node_modules/validator/lib/whitelist.js +14 -0
  491. package/node_modules/validator/package.json +76 -0
  492. package/node_modules/validator/validator.js +5808 -0
  493. package/node_modules/validator/validator.min.js +23 -0
  494. package/package.json +3 -3
@@ -0,0 +1,2158 @@
1
+ // @vitest-environment happy-dom
2
+ import {
3
+ BillSplit,
4
+ Bookmark,
5
+ Budget,
6
+ Calculator,
7
+ Checklist,
8
+ ColorPalette,
9
+ Contact,
10
+ Countdown,
11
+ Event,
12
+ Expense,
13
+ Goal,
14
+ Habit,
15
+ Ledger,
16
+ Note,
17
+ Poll,
18
+ RandomPicker,
19
+ Rating,
20
+ Recipe,
21
+ Reminder,
22
+ Rotation,
23
+ Scoreboard,
24
+ ShoppingList,
25
+ TaskBoard,
26
+ Timer,
27
+ TimeZone,
28
+ Trip,
29
+ UnitConverter,
30
+ } from '@flickfyi/core/kowalski'
31
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
32
+ import * as React from 'react'
33
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
34
+
35
+ import { kowalskiCatalog } from '~/kowalski/catalog'
36
+
37
+ vi.mock('@flickfyi/photon', async () => {
38
+ const { Children, createElement, isValidElement } = await import('react')
39
+ function Host({ children }: React.PropsWithChildren) {
40
+ return createElement('div', {}, children)
41
+ }
42
+ return {
43
+ Button: ({
44
+ accessibilityLabel,
45
+ children,
46
+ disabled,
47
+ href,
48
+ onClick,
49
+ }: React.PropsWithChildren<{
50
+ accessibilityLabel?: string
51
+ disabled?: boolean
52
+ href?: string
53
+ onClick?: () => void
54
+ }>) =>
55
+ createElement(
56
+ href === undefined ? 'button' : 'a',
57
+ { 'aria-label': accessibilityLabel, disabled, href, onClick },
58
+ children
59
+ ),
60
+ Flex: Host,
61
+ Icon: ({ name }: { name: string }) => createElement('span', { 'data-icon': name }),
62
+ Input: ({
63
+ multiline,
64
+ value,
65
+ onChange,
66
+ placeholder,
67
+ }: {
68
+ value: string
69
+ multiline?: boolean
70
+ onChange: (text: string) => void
71
+ placeholder?: string
72
+ }) =>
73
+ createElement(multiline ? 'textarea' : 'input', {
74
+ onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
75
+ onChange(event.target.value),
76
+ placeholder,
77
+ value,
78
+ }),
79
+ Picker: ({
80
+ children,
81
+ label,
82
+ onChange,
83
+ value,
84
+ }: React.PropsWithChildren<{
85
+ label?: string
86
+ onChange: (value: string) => void
87
+ value: string
88
+ }>) =>
89
+ createElement(
90
+ 'select',
91
+ {
92
+ 'aria-label': label,
93
+ onChange: (event: React.ChangeEvent<HTMLSelectElement>) =>
94
+ onChange(event.target.value),
95
+ value,
96
+ },
97
+ Children.map(children, child =>
98
+ isValidElement<React.PropsWithChildren<{ value: string }>>(child)
99
+ ? createElement('option', { value: child.props.value }, child.props.children)
100
+ : null
101
+ )
102
+ ),
103
+ Text: Host,
104
+ }
105
+ })
106
+
107
+ const stores = new Map<string, ReturnType<typeof nativeStore>>()
108
+ beforeEach(() => {
109
+ vi.stubGlobal('_CRAYONCORE_$REACT', React)
110
+ vi.stubGlobal('Quantum', {
111
+ async openStore({
112
+ name,
113
+ initialState,
114
+ scope,
115
+ }: {
116
+ name: string
117
+ initialState: object
118
+ scope: string
119
+ }) {
120
+ const key = `${scope}:${name}`
121
+ if (!stores.has(key)) {
122
+ stores.set(key, nativeStore(initialState))
123
+ }
124
+ return stores.get(key)?.store
125
+ },
126
+ })
127
+ })
128
+ afterEach(() => {
129
+ cleanup()
130
+ stores.clear()
131
+ vi.unstubAllGlobals()
132
+ vi.useRealTimers()
133
+ })
134
+
135
+ describe('Kowalski authored features', () => {
136
+ it.each(['invalid', 'rejected'])(
137
+ 'replaces a %s people draft when the user explicitly steps the count',
138
+ async kind => {
139
+ const native = nativeStore(
140
+ kowalskiCatalog.billSplit.storeSchema.parse({ people: 3, total: 20 })
141
+ )
142
+ const setAsync = vi
143
+ .fn()
144
+ .mockImplementation(native.store.setAsync.bind(native.store))
145
+ if (kind === 'rejected') {
146
+ setAsync.mockRejectedValueOnce(new Error('Rejected'))
147
+ }
148
+ vi.stubGlobal('Quantum', { openStore: async () => ({ ...native.store, setAsync }) })
149
+ render(<BillSplit total={20} people={3} />)
150
+ await waitFor(() =>
151
+ expect(
152
+ screen
153
+ .getByRole('button', { exact: true, name: 'More people' })
154
+ .hasAttribute('disabled')
155
+ ).toBe(false)
156
+ )
157
+ fireEvent.change(screen.getByPlaceholderText('People'), {
158
+ target: { value: kind === 'invalid' ? '-' : '7' },
159
+ })
160
+ if (kind === 'rejected') {
161
+ await waitFor(() =>
162
+ expect(
163
+ screen.getByText('Could not save this change. Please try again.')
164
+ ).toBeTruthy()
165
+ )
166
+ }
167
+ fireEvent.click(screen.getByRole('button', { exact: true, name: 'More people' }))
168
+ await waitFor(() => expect(native.value().people).toBe(4))
169
+ expect(screen.getByPlaceholderText<HTMLInputElement>('People').value).toBe('4')
170
+ }
171
+ )
172
+ it.each(['Bill total', 'People', 'Tip percent', 'Value'])(
173
+ 'retains the latest repeated %s draft after an old acknowledgement and final rejection',
174
+ async label => {
175
+ const native = nativeStore(
176
+ label === 'Value'
177
+ ? kowalskiCatalog.unitConverter.storeSchema.parse({
178
+ from: { value: 'm' },
179
+ to: { value: 'km' },
180
+ value: 3,
181
+ })
182
+ : kowalskiCatalog.billSplit.storeSchema.parse({
183
+ people: 3,
184
+ tipPercent: 0,
185
+ total: 20,
186
+ })
187
+ )
188
+ const writes: {
189
+ patch: object
190
+ pending: ReturnType<
191
+ typeof Promise.withResolvers<{ rawValue: unknown; version: number }>
192
+ >
193
+ }[] = []
194
+ vi.stubGlobal('Quantum', {
195
+ openStore: async () => ({
196
+ ...native.store,
197
+ setAsync(patch: object) {
198
+ const pending = Promise.withResolvers<{
199
+ rawValue: unknown
200
+ version: number
201
+ }>()
202
+ writes.push({ patch, pending })
203
+ return pending.promise
204
+ },
205
+ }),
206
+ })
207
+ render(
208
+ label === 'Value'
209
+ ? React.createElement(UnitConverter, { from: 'm', to: 'km', value: 3 })
210
+ : React.createElement(BillSplit, { people: 3, total: 20 })
211
+ )
212
+ if (label !== 'Value') {
213
+ await waitFor(() =>
214
+ expect(screen.getByText('Custom').closest('button')?.disabled).toBe(false)
215
+ )
216
+ if (label === 'Tip percent') {
217
+ fireEvent.click(screen.getByText('Custom'))
218
+ }
219
+ }
220
+ await waitFor(() => expect(screen.getByPlaceholderText(label)).toBeTruthy())
221
+ for (const value of ['1', '12', '1']) {
222
+ fireEvent.change(screen.getByPlaceholderText(label), { target: { value } })
223
+ }
224
+ expect(writes.length).toBe(3)
225
+ for (const write of writes.slice(0, 2)) {
226
+ await act(async () => {
227
+ native.store.set(write.patch)
228
+ write.pending.resolve({ rawValue: native.value(), version: 0 })
229
+ })
230
+ }
231
+ await act(async () => writes[2].pending.reject(new Error('Rejected latest write')))
232
+ expect(screen.getByPlaceholderText<HTMLInputElement>(label).value).toBe('1')
233
+ }
234
+ )
235
+
236
+ it.each([
237
+ { expected: 86.75, field: 'total', label: 'Bill total', typed: '$86.750' },
238
+ { expected: 7, field: 'people', label: 'People', typed: '7' },
239
+ { expected: 12.5, field: 'tipPercent', label: 'Tip percent', typed: '12.50' },
240
+ ])(
241
+ 'retains and retries a rejected $label draft',
242
+ async ({ label, typed, field, expected }) => {
243
+ const native = nativeStore(
244
+ kowalskiCatalog.billSplit.storeSchema.parse({
245
+ people: 3,
246
+ tipPercent: 0,
247
+ total: 20,
248
+ })
249
+ )
250
+ const pending = Promise.withResolvers<{ rawValue: unknown; version: number }>()
251
+ const setAsync = vi
252
+ .fn()
253
+ .mockImplementationOnce(() => pending.promise)
254
+ .mockImplementation(native.store.setAsync.bind(native.store))
255
+ vi.stubGlobal('Quantum', { openStore: async () => ({ ...native.store, setAsync }) })
256
+ render(<BillSplit total={20} people={3} />)
257
+ await waitFor(() =>
258
+ expect(screen.getByText('Custom').closest('button')?.disabled).toBe(false)
259
+ )
260
+ if (label === 'Tip percent') {
261
+ fireEvent.click(screen.getByText('Custom'))
262
+ }
263
+ fireEvent.change(screen.getByPlaceholderText(label), { target: { value: typed } })
264
+ await act(async () => {
265
+ pending.reject(new Error('Rejected'))
266
+ })
267
+ expect(screen.getByPlaceholderText<HTMLInputElement>(label).value).toBe(typed)
268
+ expect(
269
+ screen.getByText('Could not save this change. Please try again.')
270
+ ).toBeTruthy()
271
+ fireEvent.click(
272
+ screen.getByRole('button', { name: `Retry ${label.toLowerCase()}` })
273
+ )
274
+ await waitFor(() => expect(native.value()[field]).toBe(expected))
275
+ expect(screen.getByPlaceholderText<HTMLInputElement>(label).value).toBe(typed)
276
+ expect(
277
+ screen.queryByText('Could not save this change. Please try again.')
278
+ ).toBeNull()
279
+ }
280
+ )
281
+ it.each(
282
+ [
283
+ { expected: 86.75, field: 'total', label: 'Bill total', typed: '$86.750' },
284
+ { expected: 7, field: 'people', label: 'People', typed: '7' },
285
+ { expected: 12.5, field: 'tipPercent', label: 'Tip percent', typed: '12.50' },
286
+ ].flatMap(input => [
287
+ { ...input, rejects: false },
288
+ { ...input, rejects: true },
289
+ ])
290
+ )(
291
+ 'preserves newer invalid $label text when an older write settles (rejects=$rejects)',
292
+ async ({ label, typed, field, expected, rejects }) => {
293
+ const native = nativeStore(
294
+ kowalskiCatalog.billSplit.storeSchema.parse({
295
+ people: 3,
296
+ tipPercent: 0,
297
+ total: 20,
298
+ })
299
+ )
300
+ const pending = Promise.withResolvers<{ rawValue: unknown; version: number }>()
301
+ vi.stubGlobal('Quantum', {
302
+ openStore: async () => ({ ...native.store, setAsync: () => pending.promise }),
303
+ })
304
+ render(<BillSplit total={20} people={3} />)
305
+ await waitFor(() =>
306
+ expect(screen.getByText('Custom').closest('button')?.disabled).toBe(false)
307
+ )
308
+ if (label === 'Tip percent') {
309
+ fireEvent.click(screen.getByText('Custom'))
310
+ }
311
+ fireEvent.change(screen.getByPlaceholderText(label), { target: { value: typed } })
312
+ fireEvent.change(screen.getByPlaceholderText(label), { target: { value: '-' } })
313
+ await act(async () => {
314
+ if (rejects) {
315
+ pending.reject(new Error('Rejected'))
316
+ } else {
317
+ native.store.set({ [field]: expected })
318
+ pending.resolve({ rawValue: native.value(), version: 0 })
319
+ }
320
+ })
321
+ expect(screen.getByPlaceholderText<HTMLInputElement>(label).value).toBe('-')
322
+ }
323
+ )
324
+ it('attempts a rejected picker domain reset once and allows a new user pick', async () => {
325
+ const native = nativeStore(
326
+ kowalskiCatalog.randomPicker.storeSchema.parse({
327
+ result: {
328
+ domain: { mode: 'list', options: ['Old'] },
329
+ value: 'Old',
330
+ },
331
+ })
332
+ )
333
+ const setAsync = vi
334
+ .fn()
335
+ .mockRejectedValueOnce(new Error('Rejected'))
336
+ .mockImplementation(native.store.setAsync.bind(native.store))
337
+ vi.stubGlobal('Quantum', { openStore: async () => ({ ...native.store, setAsync }) })
338
+ render(<RandomPicker mode="list" options={['New']} />)
339
+ await waitFor(() =>
340
+ expect(
341
+ screen.getByText('Could not save this change. Please try again.')
342
+ ).toBeTruthy()
343
+ )
344
+ expect(setAsync).toHaveBeenCalledTimes(1)
345
+ expect(screen.queryByText('Old')).toBeNull()
346
+ fireEvent.click(screen.getByRole('button'))
347
+ await waitFor(() => expect(screen.getByText('New')).toBeTruthy())
348
+ expect(setAsync).toHaveBeenCalledTimes(2)
349
+ })
350
+ it('retains custom tip decimal digits when its numeric value is acknowledged', async () => {
351
+ render(<BillSplit total={86.5} people={3} />)
352
+ await waitFor(() =>
353
+ expect(screen.getByText('Custom').closest('button')?.disabled).toBe(false)
354
+ )
355
+ fireEvent.click(screen.getByText('Custom'))
356
+ fireEvent.change(screen.getByPlaceholderText('Tip percent'), {
357
+ target: { value: '12.50' },
358
+ })
359
+ await waitFor(() =>
360
+ expect(stores.get('private:KowalskiBillSplit')?.value().tipPercent).toBe(12.5)
361
+ )
362
+ expect(screen.getByPlaceholderText<HTMLInputElement>('Tip percent').value).toBe(
363
+ '12.50'
364
+ )
365
+ fireEvent.change(screen.getByPlaceholderText('Tip percent'), {
366
+ target: { value: '12.36' },
367
+ })
368
+ await waitFor(() => expect(screen.getByText('Tip (12.36%)')).toBeTruthy())
369
+ })
370
+ it('opens grouped converter values and preserves the typed decimal draft through acknowledgement', async () => {
371
+ render(<UnitConverter value={1234.5} from="m" to="ft" />)
372
+ await waitFor(() =>
373
+ expect(screen.getByPlaceholderText<HTMLInputElement>('Value').value).toBe('1,234.5')
374
+ )
375
+ fireEvent.change(screen.getByPlaceholderText('Value'), {
376
+ target: { value: '1,234.00' },
377
+ })
378
+ await waitFor(() =>
379
+ expect(stores.get('private:KowalskiUnitConverter')?.value().value).toBe(1234)
380
+ )
381
+ expect(screen.getByPlaceholderText<HTMLInputElement>('Value').value).toBe('1,234.00')
382
+ })
383
+ it.each(['BillSplit', 'Expense'])(
384
+ 'parses formatted currency in $name and rejects malformed numeric input',
385
+ async name => {
386
+ const bill = name === 'BillSplit'
387
+ render(
388
+ bill ? <BillSplit total={10} people={2} /> : <Expense title="Lunch" amount={10} />
389
+ )
390
+ const field = bill ? 'Bill total' : 'Amount'
391
+ if (!bill) {
392
+ await waitFor(() =>
393
+ expect(
394
+ screen.getByRole('button', { name: 'Edit expense' }).closest('button')
395
+ ?.disabled
396
+ ).toBe(false)
397
+ )
398
+ fireEvent.click(screen.getByRole('button', { name: 'Edit expense' }))
399
+ }
400
+ await screen.findByPlaceholderText(field)
401
+ for (const value of [
402
+ '',
403
+ '$',
404
+ '€12.50',
405
+ '0x10',
406
+ '1e3',
407
+ '12.3.4',
408
+ '1,23.50',
409
+ '1,,234',
410
+ ]) {
411
+ fireEvent.change(screen.getByPlaceholderText(field), { target: { value } })
412
+ if (bill) {
413
+ expect(screen.getByText('Enter a valid bill total.')).toBeTruthy()
414
+ expect(stores.get('private:KowalskiBillSplit')?.value().total).toBe(10)
415
+ } else {
416
+ expect(screen.getByText('Save changes').closest('button')?.disabled).toBe(true)
417
+ }
418
+ }
419
+ const typed = '\u200E$1,234.50'
420
+ fireEvent.change(screen.getByPlaceholderText(field), {
421
+ target: { value: typed },
422
+ })
423
+ if (!bill) {
424
+ fireEvent.click(screen.getByText('Save changes'))
425
+ await waitFor(() => expect(screen.queryByPlaceholderText(field)).toBeNull())
426
+ }
427
+ await waitFor(() =>
428
+ expect(
429
+ stores.get(`private:Kowalski${name}`)?.value()[bill ? 'total' : 'amount']
430
+ ).toBe(1234.5)
431
+ )
432
+ if (!bill) {
433
+ fireEvent.click(screen.getByRole('button', { name: 'Edit expense' }))
434
+ }
435
+ expect(screen.getByPlaceholderText<HTMLInputElement>(field).value).toBe(
436
+ bill ? typed : '$1,234.50'
437
+ )
438
+ }
439
+ )
440
+ it('keeps very small currency amounts readable without rounding to zero', async () => {
441
+ render(<Expense title="Precise amount" amount={1e-21} />)
442
+ await waitFor(() =>
443
+ expect(
444
+ screen.getByRole('button', { name: 'Edit expense' }).closest('button')?.disabled
445
+ ).toBe(false)
446
+ )
447
+ expect(screen.getByText('$0.000000000000000000001')).toBeTruthy()
448
+ fireEvent.click(screen.getByRole('button', { name: 'Edit expense' }))
449
+ expect(screen.getByPlaceholderText<HTMLInputElement>('Amount').value).toBe(
450
+ '$0.000000000000000000001'
451
+ )
452
+ })
453
+ it('opens small converter values as standard decimals', async () => {
454
+ render(<UnitConverter value={1e-7} from="k" to="k" />)
455
+ await waitFor(() =>
456
+ expect(screen.getByPlaceholderText<HTMLTextAreaElement>('Value').value).toBe(
457
+ '0.0000001'
458
+ )
459
+ )
460
+ })
461
+ it('keeps newer invalid converter input when an older retry is acknowledged', async () => {
462
+ const native = nativeStore(
463
+ kowalskiCatalog.unitConverter.storeSchema.parse({
464
+ from: { value: 'm' },
465
+ to: { value: 'km' },
466
+ value: 1,
467
+ })
468
+ )
469
+ const retry = Promise.withResolvers<{ rawValue: unknown; version: number }>()
470
+ let submitted: object = {}
471
+ let attempts = 0
472
+ vi.stubGlobal('Quantum', {
473
+ openStore: async () => ({
474
+ ...native.store,
475
+ async setAsync(patch: object) {
476
+ if (++attempts === 1) {
477
+ throw new Error('Rejected')
478
+ }
479
+ submitted = patch
480
+ return retry.promise
481
+ },
482
+ }),
483
+ })
484
+ render(<UnitConverter value={1} from="m" to="km" />)
485
+ await waitFor(() =>
486
+ expect(
487
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Value')
488
+ ).toBeTruthy()
489
+ )
490
+ fireEvent.change(
491
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Value'),
492
+ { target: { value: '2' } }
493
+ )
494
+ await waitFor(() => expect(screen.getByText('Retry saving value')).toBeTruthy())
495
+ fireEvent.click(screen.getByText('Retry saving value'))
496
+ fireEvent.change(
497
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Value'),
498
+ { target: { value: '-' } }
499
+ )
500
+ await act(async () => {
501
+ native.store.set(submitted)
502
+ retry.resolve({ rawValue: native.value(), version: 0 })
503
+ })
504
+ expect(
505
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Value').value
506
+ ).toBe('-')
507
+ expect(screen.getByText('Enter a valid number.')).toBeTruthy()
508
+ expect(native.value().value).toBe(2)
509
+ })
510
+ it('retains rejected note text across an unrelated successful title save and retries it', async () => {
511
+ const native = nativeStore(
512
+ kowalskiCatalog.note.storeSchema.parse({
513
+ body: { value: 'Old body' },
514
+ title: { value: 'Old title' },
515
+ })
516
+ )
517
+ let rejectBody = true
518
+ vi.stubGlobal('Quantum', {
519
+ openStore: async () => ({
520
+ ...native.store,
521
+ async setAsync(patch: object) {
522
+ if ('body' in patch && rejectBody) {
523
+ rejectBody = false
524
+ throw new Error('Rejected')
525
+ }
526
+ native.store.set(patch)
527
+ return { rawValue: native.value(), version: 0 }
528
+ },
529
+ }),
530
+ })
531
+ const component = <Note title="Old title" body="Old body" />
532
+ const view = render(component)
533
+ await waitFor(() => expect(screen.getByText('Saved')).toBeTruthy())
534
+ const body = ' Keep this\nexact text 👩🏽‍💻 '
535
+ fireEvent.change(
536
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(
537
+ 'Start writing…'
538
+ ),
539
+ {
540
+ target: { value: body },
541
+ }
542
+ )
543
+ await waitFor(() => expect(screen.getByText('Not saved')).toBeTruthy())
544
+ fireEvent.change(
545
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(
546
+ 'Untitled note'
547
+ ),
548
+ {
549
+ target: { value: ' New title ' },
550
+ }
551
+ )
552
+ await waitFor(() => expect(native.value().title).toEqual({ value: ' New title ' }))
553
+ await waitFor(() => expect(screen.getByText('Not saved')).toBeTruthy())
554
+ expect(
555
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(
556
+ 'Start writing…'
557
+ ).value
558
+ ).toBe(body)
559
+ fireEvent.click(screen.getByText('Retry save'))
560
+ await waitFor(() => expect(screen.getByText('Saved')).toBeTruthy())
561
+ view.unmount()
562
+ render(component)
563
+ await waitFor(() =>
564
+ expect(
565
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(
566
+ 'Start writing…'
567
+ ).value
568
+ ).toBe(body)
569
+ )
570
+ })
571
+ it('does not clear an invalid note draft when an older write is acknowledged', async () => {
572
+ const native = nativeStore(kowalskiCatalog.note.storeSchema.parse({}))
573
+ const ack = Promise.withResolvers<{ rawValue: unknown; version: number }>()
574
+ let submitted: object = {}
575
+ vi.stubGlobal('Quantum', {
576
+ openStore: async () => ({
577
+ ...native.store,
578
+ setAsync(patch: object) {
579
+ submitted = patch
580
+ return ack.promise
581
+ },
582
+ }),
583
+ })
584
+ render(<Note title="" body="" />)
585
+ await waitFor(() => expect(screen.getByText('Saved')).toBeTruthy())
586
+ fireEvent.change(
587
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(
588
+ 'Start writing…'
589
+ ),
590
+ {
591
+ target: { value: 'Valid' },
592
+ }
593
+ )
594
+ const invalid = 'x'.repeat(20_001)
595
+ fireEvent.change(
596
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(
597
+ 'Start writing…'
598
+ ),
599
+ {
600
+ target: { value: invalid },
601
+ }
602
+ )
603
+ await act(async () => {
604
+ native.store.set(submitted)
605
+ ack.resolve({ rawValue: native.value(), version: 0 })
606
+ })
607
+ expect(
608
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(
609
+ 'Start writing…'
610
+ ).value
611
+ ).toBe(invalid)
612
+ expect(screen.getByText('Not saved')).toBeTruthy()
613
+ expect(native.value().body).toEqual({ value: 'Valid' })
614
+ })
615
+ it.each([
616
+ { body: 'hello world', count: '2 words' },
617
+ { body: '123 45', count: '2 words' },
618
+ { body: '👩🏽‍💻 !!! —', count: '0 words' },
619
+ { body: 'word '.repeat(1000), count: '1,000 words' },
620
+ ])('counts readable note words in $count', async ({ body, count }) => {
621
+ render(<Note title="Words" body={body} />)
622
+ await waitFor(() => expect(screen.getByText('Saved')).toBeTruthy())
623
+ expect(screen.getByText(count)).toBeTruthy()
624
+ })
625
+ it.each([
626
+ { expected: 0.3, progress: 0.2, step: 0.1 },
627
+ { expected: 1e12 + 1, progress: 1e12, step: 1 },
628
+ ])(
629
+ 'increments goal $progress by $step without display arithmetic errors',
630
+ async ({ progress, step, expected }) => {
631
+ render(<Goal title="Progress" progress={progress} target={1e12} step={step} />)
632
+ await waitFor(() =>
633
+ expect(screen.getByText(`+${step}`).closest('button')?.disabled).toBe(false)
634
+ )
635
+ fireEvent.click(screen.getByText(`+${step}`))
636
+ await waitFor(() =>
637
+ expect(stores.get('private:KowalskiGoal')?.value().progress).toBe(expected)
638
+ )
639
+ }
640
+ )
641
+ it('does not round nonzero poll shares to zero or a nonunanimous result to 100%', async () => {
642
+ stores.set(
643
+ 'private:KowalskiPoll',
644
+ nativeStore(
645
+ Object.fromEntries(
646
+ Array.from({ length: 2000 }, (_, index) => [
647
+ `vote_${index}`,
648
+ index === 0 ? 'a' : 'b',
649
+ ])
650
+ )
651
+ )
652
+ )
653
+ render(
654
+ <Poll
655
+ question="Choose"
656
+ options={[
657
+ { id: 'a', label: 'A' },
658
+ { id: 'b', label: 'B' },
659
+ ]}
660
+ />
661
+ )
662
+ await waitFor(() => expect(screen.getByText('2,000 votes')).toBeTruthy())
663
+ expect(screen.getByText('1 vote · <0.1%')).toBeTruthy()
664
+ expect(screen.getByText('1,999 votes · >99.9%')).toBeTruthy()
665
+ })
666
+ it('shows singular countdown units without clock-style leading zeroes', async () => {
667
+ vi.setSystemTime(new Date('2026-10-15T00:00:00Z'))
668
+ render(<Countdown title="Arriving" target="2026-10-16T01:01:01Z" />)
669
+ await waitFor(() => expect(screen.getByText('day to go')).toBeTruthy())
670
+ for (const unit of ['hour', 'minute', 'second']) {
671
+ expect(screen.getByText(unit).parentElement?.textContent).toBe(`1${unit}`)
672
+ }
673
+ })
674
+ it('applies both trip date drafts before saving and discards outer canceled changes', async () => {
675
+ render(
676
+ <Trip
677
+ origin="Paris"
678
+ destination="Rome"
679
+ startDate="2026-10-15"
680
+ endDate="2026-10-20"
681
+ />
682
+ )
683
+ await waitFor(() =>
684
+ expect(
685
+ screen.getByRole('button', { name: 'Edit trip' }).closest('button')?.disabled
686
+ ).toBe(false)
687
+ )
688
+ fireEvent.click(screen.getByRole('button', { name: 'Edit trip' }))
689
+ fireEvent.change(
690
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('From'),
691
+ {
692
+ target: { value: ' London ' },
693
+ }
694
+ )
695
+ fireEvent.change(screen.getAllByPlaceholderText('MM/DD/YYYY')[0], {
696
+ target: { value: '10/' },
697
+ })
698
+ expect(screen.getByText('Save changes').closest('button')?.disabled).toBe(true)
699
+ fireEvent.change(screen.getAllByPlaceholderText('MM/DD/YYYY')[0], {
700
+ target: { value: '10/16/2026' },
701
+ })
702
+ fireEvent.change(screen.getAllByPlaceholderText('MM/DD/YYYY')[1], {
703
+ target: { value: '10/21/2026' },
704
+ })
705
+ expect(screen.getByText('Save changes').closest('button')?.disabled).toBe(false)
706
+ expect(stores.get('private:KowalskiTrip')?.value().startDate).toEqual({
707
+ value: '2026-10-15',
708
+ })
709
+ fireEvent.click(screen.getByText('Cancel'))
710
+ fireEvent.click(screen.getByRole('button', { name: 'Edit trip' }))
711
+ expect(
712
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('From').value
713
+ ).toBe('Paris')
714
+ fireEvent.change(screen.getAllByPlaceholderText('MM/DD/YYYY')[0], {
715
+ target: { value: '10/16/2026' },
716
+ })
717
+ fireEvent.click(screen.getByText('Save changes'))
718
+ await waitFor(() =>
719
+ expect(stores.get('private:KowalskiTrip')?.value().startDate).toEqual({
720
+ value: '2026-10-16',
721
+ })
722
+ )
723
+ })
724
+ it('preserves negative fractional input while switching compatible conversion units', async () => {
725
+ render(<UnitConverter value={20} from="c" to="f" />)
726
+ await waitFor(() =>
727
+ expect(
728
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Value')
729
+ ).toBeTruthy()
730
+ )
731
+ for (const value of ['-', '-0', '-0.', '-0.5']) {
732
+ fireEvent.change(
733
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Value'),
734
+ { target: { value } }
735
+ )
736
+ await act(async () => {})
737
+ expect(
738
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Value').value
739
+ ).toBe(value)
740
+ }
741
+ expect(stores.get('private:KowalskiUnitConverter')?.value().value).toBe(-0.5)
742
+ fireEvent.change(screen.getByLabelText('To unit'), { target: { value: 'Kelvin' } })
743
+ await waitFor(() =>
744
+ expect(stores.get('private:KowalskiUnitConverter')?.value().to).toEqual({
745
+ value: 'k',
746
+ })
747
+ )
748
+ fireEvent.click(screen.getByText('Weight'))
749
+ await waitFor(() =>
750
+ expect(stores.get('private:KowalskiUnitConverter')?.value()).toMatchObject({
751
+ from: { value: 'kg' },
752
+ to: { value: 'lb' },
753
+ })
754
+ )
755
+ expect(screen.queryByRole('option', { name: 'Kelvin' })).toBeNull()
756
+ })
757
+ it('saves a readable time-zone comparison, reopens it, and returns to live time', async () => {
758
+ vi.setSystemTime(new Date('2026-11-01T00:00:00Z'))
759
+ const component = <TimeZone zones={['UTC', 'Europe/London']} />
760
+ const view = render(component)
761
+ await waitFor(() =>
762
+ expect(screen.getByText('Compare another time').closest('button')?.disabled).toBe(
763
+ false
764
+ )
765
+ )
766
+ fireEvent.click(screen.getByText('Compare another time'))
767
+ fireEvent.change(screen.getByPlaceholderText('MM/DD/YYYY'), {
768
+ target: { value: '12/01/2026' },
769
+ })
770
+ fireEvent.change(screen.getByPlaceholderText('h:mm AM/PM'), {
771
+ target: { value: '12:00 AM' },
772
+ })
773
+ fireEvent.click(screen.getByText('Compare this time'))
774
+ await waitFor(() =>
775
+ expect(stores.get('private:KowalskiTimeZone')?.value().at).toEqual({
776
+ value: '2026-12-01T00:00:00.000Z',
777
+ })
778
+ )
779
+ view.unmount()
780
+ render(component)
781
+ await waitFor(() => expect(screen.getByText('Fixed time')).toBeTruthy())
782
+ fireEvent.click(screen.getByText('Return to live'))
783
+ await waitFor(() =>
784
+ expect(stores.get('private:KowalskiTimeZone')?.value().at).toEqual({ value: null })
785
+ )
786
+ })
787
+ it('shows note saving status until the authoritative acknowledgement arrives', async () => {
788
+ const pending: {
789
+ patch: object
790
+ resolve: (value: { rawValue: unknown; version: number }) => void
791
+ }[] = []
792
+ const native = nativeStore(
793
+ kowalskiCatalog.note.storeSchema.parse({
794
+ body: { value: 'Old body' },
795
+ title: { value: 'Draft' },
796
+ })
797
+ )
798
+ vi.stubGlobal('Quantum', {
799
+ openStore: async () => ({
800
+ ...native.store,
801
+ setAsync: (patch: object) =>
802
+ new Promise<{ rawValue: unknown; version: number }>(resolve =>
803
+ pending.push({ patch, resolve })
804
+ ),
805
+ }),
806
+ })
807
+ render(<Note title="Draft" body="Old body" />)
808
+ await waitFor(() => expect(screen.getByText('Saved')).toBeTruthy())
809
+ fireEvent.change(
810
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(
811
+ 'Start writing…'
812
+ ),
813
+ {
814
+ target: { value: 'New body' },
815
+ }
816
+ )
817
+ expect(screen.getByText('Saving…')).toBeTruthy()
818
+ expect(native.value().body).toEqual({ value: 'Old body' })
819
+ await act(async () => {
820
+ native.store.set(pending[0].patch)
821
+ pending[0].resolve({ rawValue: native.value(), version: 0 })
822
+ })
823
+ await waitFor(() => expect(screen.getByText('Saved')).toBeTruthy())
824
+ expect(native.value().body).toEqual({ value: 'New body' })
825
+ })
826
+ it('restarts a completed timer and pauses a running stopwatch', async () => {
827
+ vi.setSystemTime(new Date('2026-10-15T12:00:00Z'))
828
+ const view = render(<Timer durationSeconds={1} />)
829
+ await waitFor(() =>
830
+ expect(screen.getByText('Start').closest('button')?.disabled).toBe(false)
831
+ )
832
+ fireEvent.click(screen.getByText('Start'))
833
+ view.unmount()
834
+ vi.setSystemTime(new Date('2026-10-15T12:00:02Z'))
835
+ const complete = render(<Timer durationSeconds={1} />)
836
+ await waitFor(() => expect(screen.getByText('Time’s up')).toBeTruthy())
837
+ fireEvent.click(screen.getByText('Start again'))
838
+ await waitFor(() =>
839
+ expect(stores.get('private:KowalskiTimer')?.value().startedAt).toBe(Date.now())
840
+ )
841
+ complete.unmount()
842
+ stores.clear()
843
+ const stopwatch = render(<Timer mode="stopwatch" />)
844
+ await waitFor(() =>
845
+ expect(screen.getByText('Start').closest('button')?.disabled).toBe(false)
846
+ )
847
+ fireEvent.click(screen.getByText('Start'))
848
+ stopwatch.unmount()
849
+ vi.setSystemTime(new Date('2026-10-15T12:00:12Z'))
850
+ render(<Timer mode="stopwatch" />)
851
+ await waitFor(() => expect(screen.getByText('00:10')).toBeTruthy())
852
+ fireEvent.click(screen.getByText('Pause'))
853
+ await waitFor(() =>
854
+ expect(stores.get('private:KowalskiTimer')?.value()).toMatchObject({
855
+ elapsedSeconds: 10,
856
+ startedAt: null,
857
+ })
858
+ )
859
+ })
860
+ it.each([
861
+ { component: <Event title="Old" />, editor: 'event', field: 'Title' },
862
+ { component: <Reminder title="Old" />, editor: 'reminder', field: 'Reminder' },
863
+ {
864
+ component: <Trip origin="Old" destination="Rome" />,
865
+ editor: 'trip',
866
+ field: 'From',
867
+ },
868
+ { component: <Contact name="Old" />, editor: 'contact', field: 'Name' },
869
+ {
870
+ component: <Bookmark title="Old" url="https://example.com" />,
871
+ editor: 'bookmark',
872
+ field: 'Title',
873
+ },
874
+ {
875
+ component: <Expense title="Old" amount={10} />,
876
+ editor: 'expense',
877
+ field: 'Description',
878
+ },
879
+ ])(
880
+ 'retains a rejected $editor draft for retry',
881
+ async ({ component, editor, field }) => {
882
+ let reject = true
883
+ vi.stubGlobal('Quantum', {
884
+ async openStore({ initialState }: { initialState: object }) {
885
+ const native = nativeStore(initialState)
886
+ return {
887
+ ...native.store,
888
+ async setAsync(patch: object) {
889
+ if (reject) {
890
+ throw new Error('Local write rejected')
891
+ }
892
+ return native.store.setAsync(patch)
893
+ },
894
+ }
895
+ },
896
+ })
897
+ render(component)
898
+ await waitFor(() =>
899
+ expect(
900
+ screen.getByRole('button', { name: `Edit ${editor}` }).closest('button')
901
+ ?.disabled
902
+ ).toBe(false)
903
+ )
904
+ fireEvent.click(screen.getByRole('button', { name: `Edit ${editor}` }))
905
+ fireEvent.change(
906
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(field),
907
+ {
908
+ target: { value: 'Keep this draft' },
909
+ }
910
+ )
911
+ fireEvent.click(screen.getByText('Save changes'))
912
+ await waitFor(() =>
913
+ expect(
914
+ screen.getByText('Could not save this change. Please try again.')
915
+ ).toBeTruthy()
916
+ )
917
+ expect(
918
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(field).value
919
+ ).toBe('Keep this draft')
920
+ reject = false
921
+ fireEvent.click(screen.getByText('Save changes'))
922
+ await waitFor(() => expect(screen.queryByPlaceholderText(field)).toBeNull())
923
+ expect(screen.getByText('Keep this draft')).toBeTruthy()
924
+ }
925
+ )
926
+ it('keeps the submitted editor closed to further input until a delayed save finishes', async () => {
927
+ let acknowledge: (() => void) | undefined
928
+ vi.stubGlobal('Quantum', {
929
+ async openStore({ initialState }: { initialState: object }) {
930
+ const native = nativeStore(initialState)
931
+ return {
932
+ ...native.store,
933
+ setAsync: (patch: object) =>
934
+ new Promise<{ rawValue: unknown; version: number }>(resolve => {
935
+ acknowledge = () => {
936
+ native.store.set(patch)
937
+ resolve({ rawValue: native.value(), version: 0 })
938
+ }
939
+ }),
940
+ }
941
+ },
942
+ })
943
+ render(<Event title="Original" />)
944
+ await waitFor(() =>
945
+ expect(
946
+ screen.getByRole('button', { name: 'Edit event' }).closest('button')?.disabled
947
+ ).toBe(false)
948
+ )
949
+ fireEvent.click(screen.getByRole('button', { name: 'Edit event' }))
950
+ fireEvent.change(
951
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Title'),
952
+ {
953
+ target: { value: 'Submitted title' },
954
+ }
955
+ )
956
+ fireEvent.click(screen.getByText('Save changes'))
957
+ expect(screen.getByText('Saving changes…')).toBeTruthy()
958
+ expect(screen.queryByPlaceholderText('Title')).toBeNull()
959
+ expect(screen.queryByText('Cancel')).toBeNull()
960
+ expect(screen.queryByRole('button', { name: 'Edit event' })).toBeNull()
961
+ await act(async () => acknowledge?.())
962
+ await waitFor(() => expect(screen.queryByText('Saving changes…')).toBeNull())
963
+ fireEvent.click(screen.getByRole('button', { name: 'Edit event' }))
964
+ expect(
965
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Title').value
966
+ ).toBe('Submitted title')
967
+ })
968
+ it('retains rapid keypad input and a pending decimal operation through reopen', async () => {
969
+ const component = <Calculator left={1} right={2} />
970
+ const view = render(component)
971
+ await waitFor(() =>
972
+ expect(
973
+ screen.getByRole('button', { exact: true, name: 'AC' }).hasAttribute('disabled')
974
+ ).toBe(false)
975
+ )
976
+ act(() => {
977
+ for (const name of ['AC', '1', '2', '+', '3', '.']) {
978
+ fireEvent.click(screen.getByRole('button', { exact: true, name }))
979
+ }
980
+ })
981
+ await waitFor(() =>
982
+ expect(stores.get('private:KowalskiCalculator')?.value()).toMatchObject({
983
+ calculation: { entry: '3.', left: 12, phase: 'right' },
984
+ })
985
+ )
986
+ view.unmount()
987
+ render(component)
988
+ await waitFor(() => expect(screen.getByText('3.')).toBeTruthy())
989
+ fireEvent.click(screen.getByRole('button', { exact: true, name: '5' }))
990
+ fireEvent.click(screen.getByRole('button', { exact: true, name: '=' }))
991
+ await waitFor(() => expect(screen.getByText('15.5')).toBeTruthy())
992
+ })
993
+ it('edits a countdown through calendar controls and retains its target after reopening', async () => {
994
+ const component = (
995
+ <Countdown title="Launch" target="2026-11-01T00:00:00Z" timeZone="UTC" />
996
+ )
997
+ const view = render(component)
998
+ await waitFor(() =>
999
+ expect(screen.getByText('Change date & time').closest('button')?.disabled).toBe(
1000
+ false
1001
+ )
1002
+ )
1003
+ expect(screen.queryByRole('textbox')).toBeNull()
1004
+ fireEvent.click(screen.getByText('Change date & time'))
1005
+ fireEvent.change(screen.getByPlaceholderText('MM/DD/YYYY'), {
1006
+ target: { value: '12/01/2026' },
1007
+ })
1008
+ fireEvent.click(screen.getByText('Save date'))
1009
+ await waitFor(() =>
1010
+ expect(stores.get('private:KowalskiCountdown')?.value()).toMatchObject({
1011
+ target: { value: '2026-12-01T00:00:00.000Z' },
1012
+ })
1013
+ )
1014
+ view.unmount()
1015
+ render(component)
1016
+ await waitFor(() => expect(screen.getByText('Tue, December 1, 2026')).toBeTruthy())
1017
+ })
1018
+ it('cancels contact drafts, validates edits, and preserves an untouched live field on save', async () => {
1019
+ const component = <Contact name="Alex" phone="555-0100" email="old@example.com" />
1020
+ const view = render(component)
1021
+ await waitFor(() =>
1022
+ expect(
1023
+ screen.getByRole('button', { name: 'Edit contact' }).closest('button')?.disabled
1024
+ ).toBe(false)
1025
+ )
1026
+ expect(screen.queryByRole('textbox')).toBeNull()
1027
+ expect(screen.getByRole('link', { name: 'Call Alex' }).getAttribute('href')).toBe(
1028
+ 'tel:5550100'
1029
+ )
1030
+ expect(screen.getByRole('link', { name: 'Email Alex' }).getAttribute('href')).toBe(
1031
+ 'mailto:old%40example.com'
1032
+ )
1033
+ fireEvent.click(screen.getByRole('button', { name: 'Edit contact' }))
1034
+ fireEvent.change(
1035
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Name'),
1036
+ {
1037
+ target: { value: 'Unsaved name' },
1038
+ }
1039
+ )
1040
+ fireEvent.click(screen.getByText('Cancel'))
1041
+ expect(stores.get('private:KowalskiContact')?.value()).toMatchObject({
1042
+ name: { value: 'Alex' },
1043
+ })
1044
+ fireEvent.click(screen.getByRole('button', { name: 'Edit contact' }))
1045
+ fireEvent.change(
1046
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Email'),
1047
+ {
1048
+ target: { value: 'invalid' },
1049
+ }
1050
+ )
1051
+ expect(screen.getByText('Save changes').closest('button')?.disabled).toBe(true)
1052
+ fireEvent.change(
1053
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Email'),
1054
+ {
1055
+ target: { value: 'new@example.com' },
1056
+ }
1057
+ )
1058
+ act(() =>
1059
+ stores.get('private:KowalskiContact')?.store.set({ phone: { value: '555-0199' } })
1060
+ )
1061
+ fireEvent.click(screen.getByText('Save changes'))
1062
+ await waitFor(() =>
1063
+ expect(stores.get('private:KowalskiContact')?.value()).toMatchObject({
1064
+ email: { value: 'new@example.com' },
1065
+ phone: { value: '555-0199' },
1066
+ })
1067
+ )
1068
+ view.unmount()
1069
+ render(component)
1070
+ await waitFor(() => expect(screen.getByText('new@example.com')).toBeTruthy())
1071
+ expect(screen.getByText('555-0199')).toBeTruthy()
1072
+ })
1073
+ it('cancels every staged event edit and preserves the exact instant on title-only saves', async () => {
1074
+ const startsAt = '2026-11-05T18:30:42-08:00'
1075
+ render(
1076
+ <Event
1077
+ title="Talk"
1078
+ location="Lobby"
1079
+ startsAt={startsAt}
1080
+ timeZone="America/Los_Angeles"
1081
+ />
1082
+ )
1083
+ await waitFor(() =>
1084
+ expect(
1085
+ screen.getByRole('button', { name: 'Edit event' }).closest('button')?.disabled
1086
+ ).toBe(false)
1087
+ )
1088
+ fireEvent.click(screen.getByRole('button', { name: 'Edit event' }))
1089
+ fireEvent.change(
1090
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Title'),
1091
+ {
1092
+ target: { value: 'Unsaved title' },
1093
+ }
1094
+ )
1095
+ fireEvent.change(screen.getByPlaceholderText('MM/DD/YYYY'), {
1096
+ target: { value: '12/05/2026' },
1097
+ })
1098
+ fireEvent.click(screen.getByText('Cancel'))
1099
+ expect(stores.get('private:KowalskiEvent')?.value()).toMatchObject({
1100
+ startsAt: { value: startsAt },
1101
+ title: { value: 'Talk' },
1102
+ })
1103
+ fireEvent.click(screen.getByRole('button', { name: 'Edit event' }))
1104
+ fireEvent.change(
1105
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('Title'),
1106
+ {
1107
+ target: { value: 'New title' },
1108
+ }
1109
+ )
1110
+ fireEvent.click(screen.getByText('Save changes'))
1111
+ await waitFor(() =>
1112
+ expect(stores.get('private:KowalskiEvent')?.value()).toMatchObject({
1113
+ startsAt: { value: startsAt },
1114
+ title: { value: 'New title' },
1115
+ })
1116
+ )
1117
+ })
1118
+ it.each(
1119
+ ['BillSplit', 'Expense'].flatMap(name => [
1120
+ { amount: 86.5, currency: 'USD', expected: '$86.50', name },
1121
+ { amount: 1000, currency: 'JPY', expected: '¥1,000', name },
1122
+ { amount: 1.2, currency: 'KWD', expected: 'KWD\u00A01.200', name },
1123
+ { amount: 123.4, currency: 'XCG', expected: 'Cg.\u00A0123.40', name },
1124
+ { amount: 0.004, currency: 'USD', expected: '$0.004', name },
1125
+ ])
1126
+ )(
1127
+ 'preserves $name $currency precision and formatting in its amount field',
1128
+ async ({ name, currency, amount, expected }) => {
1129
+ const bill = name === 'BillSplit'
1130
+ render(
1131
+ bill ? (
1132
+ <BillSplit total={amount} people={3} currency={currency} />
1133
+ ) : (
1134
+ <Expense title="Lunch" amount={amount} currency={currency} />
1135
+ )
1136
+ )
1137
+ if (!bill) {
1138
+ await waitFor(() =>
1139
+ expect(
1140
+ screen.getByRole('button', { name: 'Edit expense' }).closest('button')
1141
+ ?.disabled
1142
+ ).toBe(false)
1143
+ )
1144
+ fireEvent.click(screen.getByRole('button', { name: 'Edit expense' }))
1145
+ }
1146
+ const field = bill ? 'Bill total' : 'Amount'
1147
+ expect((await screen.findByPlaceholderText<HTMLInputElement>(field)).value).toBe(
1148
+ expected
1149
+ )
1150
+ fireEvent.change(screen.getByPlaceholderText(field), { target: { value: '' } })
1151
+ if (bill) {
1152
+ expect(screen.getByText('Enter a valid bill total.')).toBeTruthy()
1153
+ expect(stores.get('private:KowalskiBillSplit')?.value().total).toBe(amount)
1154
+ } else {
1155
+ expect(screen.getByText('Save changes').closest('button')?.disabled).toBe(true)
1156
+ }
1157
+ await act(async () => {
1158
+ fireEvent.change(screen.getByPlaceholderText(field), {
1159
+ target: { value: expected },
1160
+ })
1161
+ })
1162
+ if (bill) {
1163
+ expect(screen.queryByText('Enter a valid bill total.')).toBeNull()
1164
+ expect(screen.getByPlaceholderText<HTMLInputElement>(field).value).toBe(expected)
1165
+ } else {
1166
+ fireEvent.change(screen.getByPlaceholderText('Description'), {
1167
+ target: { value: 'Updated lunch' },
1168
+ })
1169
+ fireEvent.click(screen.getByText('Save changes'))
1170
+ await waitFor(() => expect(screen.queryByPlaceholderText(field)).toBeNull())
1171
+ }
1172
+ const state = stores
1173
+ .get(bill ? 'private:KowalskiBillSplit' : 'private:KowalskiExpense')
1174
+ ?.value()
1175
+ expect(state?.[bill ? 'total' : 'amount']).toBe(amount)
1176
+ }
1177
+ )
1178
+ it.each([
1179
+ {
1180
+ component: (
1181
+ <Event
1182
+ title=" Design night "
1183
+ startsAt="2026-11-05T18:30:42.125-08:00"
1184
+ timeZone="America/Los_Angeles"
1185
+ />
1186
+ ),
1187
+ name: 'Event',
1188
+ storedField: 'startsAt',
1189
+ titleField: 'Title',
1190
+ },
1191
+ {
1192
+ component: <Reminder title=" Water plants " dueAt="2026-11-06T17:00:42.125Z" />,
1193
+ name: 'Reminder',
1194
+ storedField: 'dueAt',
1195
+ titleField: 'Reminder',
1196
+ },
1197
+ ])(
1198
+ 'blocks outer $name Save for an invalid date draft and retains subsecond precision',
1199
+ async ({ component, name, titleField, storedField }) => {
1200
+ render(component)
1201
+ await waitFor(() =>
1202
+ expect(
1203
+ screen
1204
+ .getByRole('button', { name: `Edit ${name.toLowerCase()}` })
1205
+ .closest('button')?.disabled
1206
+ ).toBe(false)
1207
+ )
1208
+ const original = stores.get(`private:Kowalski${name}`)?.value()[storedField]
1209
+ fireEvent.click(screen.getByRole('button', { name: `Edit ${name.toLowerCase()}` }))
1210
+ fireEvent.change(
1211
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(titleField),
1212
+ {
1213
+ target: { value: ' Keep these spaces ' },
1214
+ }
1215
+ )
1216
+ fireEvent.change(screen.getByPlaceholderText('MM/DD/YYYY'), {
1217
+ target: { value: '12/' },
1218
+ })
1219
+ expect(screen.getByText('Save changes').closest('button')?.disabled).toBe(true)
1220
+ expect(stores.get(`private:Kowalski${name}`)?.value()[storedField]).toEqual(
1221
+ original
1222
+ )
1223
+ fireEvent.change(screen.getByPlaceholderText('MM/DD/YYYY'), {
1224
+ target: { value: '12/06/2026' },
1225
+ })
1226
+ expect(screen.getByText('Save changes').closest('button')?.disabled).toBe(false)
1227
+ fireEvent.click(screen.getByText('Save changes'))
1228
+ await waitFor(() => expect(screen.queryByPlaceholderText(titleField)).toBeNull())
1229
+ expect(stores.get(`private:Kowalski${name}`)?.value().title).toEqual({
1230
+ value: ' Keep these spaces ',
1231
+ })
1232
+ expect(
1233
+ JSON.stringify(stores.get(`private:Kowalski${name}`)?.value()[storedField])
1234
+ ).toContain('42.125')
1235
+ }
1236
+ )
1237
+ it('autosaves the inline bill total while preserving typed decimals and saved values on reopen', async () => {
1238
+ const component = <BillSplit total={86.5} people={3} tipPercent={20} />
1239
+ const view = render(component)
1240
+ const amount = await screen.findByPlaceholderText<HTMLInputElement>('Bill total')
1241
+ expect(amount.value).toBe('$86.50')
1242
+ expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull()
1243
+ expect(screen.queryByRole('button', { exact: true, name: 'Save' })).toBeNull()
1244
+ expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull()
1245
+ for (const value of ['86.', '86.7', '86.750']) {
1246
+ fireEvent.change(amount, { target: { value } })
1247
+ await waitFor(() =>
1248
+ expect(stores.get('private:KowalskiBillSplit')?.value().total).toBe(Number(value))
1249
+ )
1250
+ expect(screen.getByPlaceholderText('Bill total')).toBe(amount)
1251
+ expect(amount.value).toBe(value)
1252
+ }
1253
+ await waitFor(() => expect(screen.getByText('$34.70')).toBeTruthy())
1254
+ view.unmount()
1255
+ render(component)
1256
+ await waitFor(() =>
1257
+ expect(screen.getByPlaceholderText<HTMLInputElement>('Bill total').value).toBe(
1258
+ '$86.75'
1259
+ )
1260
+ )
1261
+ })
1262
+ it('uses the currency’s minor digits in the inline bill total', async () => {
1263
+ render(<BillSplit total={1000} people={3} currency="JPY" />)
1264
+ expect(
1265
+ (await screen.findByPlaceholderText<HTMLInputElement>('Bill total')).value
1266
+ ).toBe('¥1,000')
1267
+ expect(screen.queryByDisplayValue('¥1,000.00')).toBeNull()
1268
+ })
1269
+ it('keeps bill steppers bounded and retains direct edits and tip presets after reopening', async () => {
1270
+ const component = <BillSplit title="Dinner" total={100} people={1} tipPercent={0} />
1271
+ const view = render(component)
1272
+ await waitFor(() =>
1273
+ expect(
1274
+ screen
1275
+ .getByRole('button', { exact: true, name: 'More people' })
1276
+ .hasAttribute('disabled')
1277
+ ).toBe(false)
1278
+ )
1279
+ expect(
1280
+ screen
1281
+ .getByRole('button', { exact: true, name: 'Fewer people' })
1282
+ .hasAttribute('disabled')
1283
+ ).toBe(true)
1284
+ act(() => {
1285
+ fireEvent.click(screen.getByRole('button', { exact: true, name: 'More people' }))
1286
+ fireEvent.click(screen.getByRole('button', { exact: true, name: 'More people' }))
1287
+ })
1288
+ await waitFor(() =>
1289
+ expect(stores.get('private:KowalskiBillSplit')?.value().people).toBe(3)
1290
+ )
1291
+ fireEvent.click(screen.getByRole('button', { exact: true, name: '20%' }))
1292
+ await waitFor(() =>
1293
+ expect(screen.getByText('Total with tip').parentElement?.textContent).toContain(
1294
+ '$120.00'
1295
+ )
1296
+ )
1297
+ fireEvent.change(
1298
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('People'),
1299
+ { target: { value: '1000' } }
1300
+ )
1301
+ await waitFor(() =>
1302
+ expect(
1303
+ screen
1304
+ .getByRole('button', { exact: true, name: 'More people' })
1305
+ .hasAttribute('disabled')
1306
+ ).toBe(true)
1307
+ )
1308
+ view.unmount()
1309
+ render(component)
1310
+ await waitFor(() =>
1311
+ expect(
1312
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>('People')
1313
+ .value
1314
+ ).toBe('1,000')
1315
+ )
1316
+ expect(stores.get('private:KowalskiBillSplit')?.value().tipPercent).toBe(20)
1317
+ })
1318
+ it('writes independent completion fields for checklist items', async () => {
1319
+ render(
1320
+ <Checklist
1321
+ title="Packing"
1322
+ items={[
1323
+ { id: 'tent', label: 'Tent' },
1324
+ { id: 'water', label: 'Water' },
1325
+ ]}
1326
+ />
1327
+ )
1328
+ await waitFor(() =>
1329
+ expect(screen.getAllByRole('button')[0].hasAttribute('disabled')).toBe(false)
1330
+ )
1331
+ fireEvent.click(screen.getByRole('button', { name: 'Tent' }))
1332
+ await waitFor(() =>
1333
+ expect(screen.getAllByRole('button').map(button => button.textContent)).toEqual([
1334
+ 'Water',
1335
+ 'Tent',
1336
+ ])
1337
+ )
1338
+ fireEvent.click(screen.getByRole('button', { name: 'Water' }))
1339
+ await waitFor(() =>
1340
+ expect(stores.get('private:KowalskiChecklist')?.value()).toMatchObject({
1341
+ completed_tent: true,
1342
+ completed_water: true,
1343
+ })
1344
+ )
1345
+ })
1346
+ it('preserves a picker result for title edits and clears it for a changed choice domain', async () => {
1347
+ const sample = vi.spyOn(Math, 'random').mockReturnValue(0)
1348
+ try {
1349
+ const view = render(
1350
+ <RandomPicker
1351
+ id="Picker"
1352
+ revision={1}
1353
+ title="Choose"
1354
+ mode="list"
1355
+ options={['Tea', 'Coffee']}
1356
+ />
1357
+ )
1358
+ await waitFor(() =>
1359
+ expect(screen.getByRole('button').hasAttribute('disabled')).toBe(false)
1360
+ )
1361
+ fireEvent.click(screen.getByRole('button'))
1362
+ await waitFor(() => expect(screen.getByText('Tea')).toBeTruthy())
1363
+ view.rerender(
1364
+ <RandomPicker
1365
+ id="Picker"
1366
+ revision={2}
1367
+ title="New title"
1368
+ mode="list"
1369
+ options={['Tea', 'Coffee']}
1370
+ />
1371
+ )
1372
+ await waitFor(() =>
1373
+ expect(stores.get('private:Picker')?.value().configurationRevision).toBe(2)
1374
+ )
1375
+ expect(screen.getByText('Tea')).toBeTruthy()
1376
+ view.rerender(<RandomPicker id="Picker" revision={3} title="Roll" mode="dice" />)
1377
+ await waitFor(() => expect(stores.get('private:Picker')?.value().result).toBeNull())
1378
+ expect(screen.queryByText('Tea')).toBeNull()
1379
+ } finally {
1380
+ sample.mockRestore()
1381
+ }
1382
+ })
1383
+ it.each([
1384
+ {
1385
+ after: { mode: 'list', options: ['Coffee'] },
1386
+ before: { mode: 'list', options: ['Tea', 'Coffee'] },
1387
+ },
1388
+ {
1389
+ after: { maximum: 10, minimum: 5, mode: 'range' },
1390
+ before: { maximum: 10, minimum: 1, mode: 'range' },
1391
+ },
1392
+ ] as const)(
1393
+ 'clears a picker result after its choice is removed (%#)',
1394
+ async ({ before, after }) => {
1395
+ const sample = vi.spyOn(Math, 'random').mockReturnValue(0)
1396
+ try {
1397
+ const view = render(<RandomPicker id="ChangedPicker" revision={1} {...before} />)
1398
+ await waitFor(() =>
1399
+ expect(screen.getByRole('button').hasAttribute('disabled')).toBe(false)
1400
+ )
1401
+ fireEvent.click(screen.getByRole('button'))
1402
+ await waitFor(() =>
1403
+ expect(stores.get('private:ChangedPicker')?.value().result).not.toBeNull()
1404
+ )
1405
+ view.rerender(<RandomPicker id="ChangedPicker" revision={2} {...after} />)
1406
+ await waitFor(() =>
1407
+ expect(stores.get('private:ChangedPicker')?.value().result).toBeNull()
1408
+ )
1409
+ } finally {
1410
+ sample.mockRestore()
1411
+ }
1412
+ }
1413
+ )
1414
+ it('rejects identity-free custom checklist and poll data before opening stores', () => {
1415
+ // @ts-expect-error Custom source must supply stable item IDs; composition assigns them before rendering.
1416
+ expect(() => render(<Checklist title="Pack" items={[{ label: 'Tent' }]} />)).toThrow()
1417
+ // @ts-expect-error Custom source must supply stable option IDs.
1418
+ expect(() =>
1419
+ render(
1420
+ <Poll question="Lunch?" options={[{ label: 'Pizza' }, { label: 'Tacos' }]} />
1421
+ )
1422
+ ).toThrow()
1423
+ expect(stores.size).toBe(0)
1424
+ })
1425
+ it.each([
1426
+ {
1427
+ component: <Event title="Talk" location="Lobby" />,
1428
+ expected: { location: { value: 'Main hall' } },
1429
+ label: 'Location',
1430
+ name: 'Event',
1431
+ text: 'Main hall',
1432
+ },
1433
+ {
1434
+ component: <Reminder title="Water" />,
1435
+ expected: { title: { value: 'Water plants' } },
1436
+ label: 'Reminder',
1437
+ name: 'Reminder',
1438
+ text: 'Water plants',
1439
+ },
1440
+ {
1441
+ component: <Trip origin="Paris" destination="Rome" />,
1442
+ expected: { destination: { value: 'Berlin' } },
1443
+ label: 'To',
1444
+ name: 'Trip',
1445
+ text: 'Berlin',
1446
+ },
1447
+
1448
+ {
1449
+ component: <ColorPalette color="#123456" />,
1450
+ expected: { color: { value: '#ABCDEF' } },
1451
+ label: 'Hex color',
1452
+ name: 'ColorPalette',
1453
+ text: '#abcdef',
1454
+ },
1455
+ {
1456
+ component: <BillSplit total={10} people={2} />,
1457
+ expected: { people: 3 },
1458
+ label: 'People',
1459
+ name: 'BillSplit',
1460
+ text: '3',
1461
+ },
1462
+ {
1463
+ component: <Expense title="Lunch" amount={10} />,
1464
+ expected: { amount: 12.5 },
1465
+ label: 'Amount',
1466
+ name: 'Expense',
1467
+ text: '12.5',
1468
+ },
1469
+ {
1470
+ component: <UnitConverter from="m" to="km" value={10} />,
1471
+ expected: { value: 2000 },
1472
+ label: 'Value',
1473
+ name: 'UnitConverter',
1474
+ text: '2000',
1475
+ },
1476
+
1477
+ {
1478
+ component: <Contact name="Alex" />,
1479
+ expected: { email: { value: 'alex@example.com' } },
1480
+ label: 'Email',
1481
+ name: 'Contact',
1482
+ text: 'alex@example.com',
1483
+ },
1484
+ {
1485
+ component: <Bookmark title="Reference" url="https://example.com" />,
1486
+ expected: { url: { value: 'https://example.org' } },
1487
+ label: 'Web address',
1488
+ name: 'Bookmark',
1489
+ text: 'https://example.org',
1490
+ },
1491
+ ])(
1492
+ 'persists $name edits through an authoritative save and reopen',
1493
+ async ({ component, name, label, text, expected }) => {
1494
+ const view = render(component)
1495
+ if (
1496
+ ['Contact', 'Event', 'Reminder', 'Trip', 'Expense', 'Bookmark'].includes(name)
1497
+ ) {
1498
+ await waitFor(() =>
1499
+ expect(
1500
+ screen
1501
+ .getByRole('button', { name: `Edit ${name.toLowerCase()}` })
1502
+ .closest('button')?.disabled
1503
+ ).toBe(false)
1504
+ )
1505
+ fireEvent.click(
1506
+ screen.getByRole('button', { name: `Edit ${name.toLowerCase()}` })
1507
+ )
1508
+ }
1509
+ if (name === 'ColorPalette') {
1510
+ await waitFor(() =>
1511
+ expect(screen.getByText('Edit hex').closest('button')?.disabled).toBe(false)
1512
+ )
1513
+ fireEvent.click(screen.getByText('Edit hex'))
1514
+ }
1515
+ await waitFor(() =>
1516
+ expect(
1517
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(label)
1518
+ ).toBeTruthy()
1519
+ )
1520
+ fireEvent.change(
1521
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(label),
1522
+ { target: { value: text } }
1523
+ )
1524
+ if (name === 'ColorPalette') {
1525
+ fireEvent.click(screen.getByText('Done'))
1526
+ }
1527
+ if (
1528
+ ['Contact', 'Event', 'Reminder', 'Trip', 'Expense', 'Bookmark'].includes(name)
1529
+ ) {
1530
+ fireEvent.click(screen.getByText('Save changes'))
1531
+ }
1532
+ await waitFor(() =>
1533
+ expect(stores.get(`private:Kowalski${name}`)?.value()).toMatchObject(expected)
1534
+ )
1535
+ view.unmount()
1536
+ render(component)
1537
+ if (
1538
+ ['Contact', 'Event', 'Reminder', 'Trip', 'Expense', 'Bookmark'].includes(name)
1539
+ ) {
1540
+ await waitFor(() =>
1541
+ expect(
1542
+ screen
1543
+ .getByRole('button', { name: `Edit ${name.toLowerCase()}` })
1544
+ .closest('button')?.disabled
1545
+ ).toBe(false)
1546
+ )
1547
+ fireEvent.click(
1548
+ screen.getByRole('button', { name: `Edit ${name.toLowerCase()}` })
1549
+ )
1550
+ }
1551
+ if (name === 'ColorPalette') {
1552
+ await waitFor(() =>
1553
+ expect(screen.getByText('Edit hex').closest('button')?.disabled).toBe(false)
1554
+ )
1555
+ fireEvent.click(screen.getByText('Edit hex'))
1556
+ }
1557
+ await waitFor(() =>
1558
+ expect(
1559
+ screen.getByPlaceholderText<HTMLInputElement | HTMLTextAreaElement>(label).value
1560
+ ).toBe(
1561
+ name === 'Expense'
1562
+ ? '$12.50'
1563
+ : name === 'ColorPalette'
1564
+ ? 'ABCDEF'
1565
+ : name === 'UnitConverter'
1566
+ ? '2,000'
1567
+ : text
1568
+ )
1569
+ )
1570
+ }
1571
+ )
1572
+ it('retains today’s habit completion after reopening', async () => {
1573
+ vi.setSystemTime(new Date('2026-10-15T12:00:00Z'))
1574
+ const component = <Habit title="Read" timeZone="UTC" weekdays={['thursday']} />
1575
+ const view = render(component)
1576
+ await waitFor(() =>
1577
+ expect(screen.getByText('Complete today').closest('button')?.disabled).toBe(false)
1578
+ )
1579
+ fireEvent.click(screen.getByText('Complete today'))
1580
+ await waitFor(() =>
1581
+ expect(stores.get('private:KowalskiHabit')?.value()['completed_2026-10-15']).toBe(
1582
+ true
1583
+ )
1584
+ )
1585
+ view.unmount()
1586
+ render(component)
1587
+ await waitFor(() => expect(screen.getByText('Undo')).toBeTruthy())
1588
+ })
1589
+ it('persists a valid random choice after reopening', async () => {
1590
+ const component = <RandomPicker mode="list" options={['Tea', 'Coffee']} />
1591
+ const view = render(component)
1592
+ await waitFor(() =>
1593
+ expect(screen.getByRole('button').hasAttribute('disabled')).toBe(false)
1594
+ )
1595
+ fireEvent.click(screen.getByRole('button'))
1596
+ await waitFor(() =>
1597
+ expect(['Tea', 'Coffee']).toContain(
1598
+ kowalskiCatalog.randomPicker.storeSchema.parse(
1599
+ stores.get('private:KowalskiRandomPicker')?.value()
1600
+ ).result?.value
1601
+ )
1602
+ )
1603
+ const result = kowalskiCatalog.randomPicker.storeSchema.parse(
1604
+ stores.get('private:KowalskiRandomPicker')?.value()
1605
+ ).result?.value
1606
+ view.unmount()
1607
+ render(component)
1608
+ await waitFor(() => expect(screen.getByText(String(result))).toBeTruthy())
1609
+ })
1610
+ it.each([
1611
+ { component: <UnitConverter value={0.1} from="m" to="km" /> },
1612
+ {
1613
+ component: <Goal title="Small goal" progress={0.0001} target={1} unit="steps" />,
1614
+ },
1615
+ ])(
1616
+ 'displays valid small numbers without rounding them to zero (%#)',
1617
+ async ({ component }) => {
1618
+ render(component)
1619
+ await waitFor(() => expect(screen.getByText('0.0001')).toBeTruthy())
1620
+ }
1621
+ )
1622
+ it('retains a representable small goal increment', async () => {
1623
+ render(
1624
+ <Goal title="Precise goal" progress={10_000} target={20_000} step={0.00000001} />
1625
+ )
1626
+ const button = screen.getByText('+0.00000001').closest('button')
1627
+ await waitFor(() => expect(button?.disabled).toBe(false))
1628
+ fireEvent.click(screen.getByText('+0.00000001'))
1629
+ await waitFor(() =>
1630
+ expect(stores.get('private:KowalskiGoal')?.value().progress).toBe(10_000.00000001)
1631
+ )
1632
+ })
1633
+ it('shows the final countdown second until it has elapsed', async () => {
1634
+ vi.setSystemTime(new Date('2026-10-15T12:00:00Z'))
1635
+ const view = render(<Timer durationSeconds={1} />)
1636
+ await waitFor(() =>
1637
+ expect(screen.getByText('Start').closest('button')?.disabled).toBe(false)
1638
+ )
1639
+ fireEvent.click(screen.getByText('Start'))
1640
+ view.unmount()
1641
+ vi.setSystemTime(new Date('2026-10-15T12:00:00.999Z'))
1642
+ render(<Timer durationSeconds={1} />)
1643
+ await waitFor(() => expect(screen.getByText('Pause')).toBeTruthy())
1644
+ expect(screen.getByText('00:01')).toBeTruthy()
1645
+ })
1646
+ it('treats prototype-like item IDs as unchecked until explicitly completed', async () => {
1647
+ render(<Checklist title="Packing" items={[{ id: 'constructor', label: 'Tent' }]} />)
1648
+ await waitFor(() =>
1649
+ expect(screen.getByRole('button').hasAttribute('disabled')).toBe(false)
1650
+ )
1651
+ expect(screen.getByText('0 of 1 complete')).toBeTruthy()
1652
+ expect(screen.getByRole('button').querySelector('[data-icon=checkmark]')).toBeNull()
1653
+ fireEvent.click(screen.getByRole('button'))
1654
+ await waitFor(() => expect(screen.getByText('1 of 1 complete')).toBeTruthy())
1655
+ expect(
1656
+ screen.getByRole('button').querySelector('[data-icon=checkmark]')
1657
+ ).not.toBeNull()
1658
+ })
1659
+ it('keeps duplicate-label item identity when the list reorders', async () => {
1660
+ const first = { id: 'one', label: 'Tent' }
1661
+ const second = { id: 'two', label: 'Tent' }
1662
+ const view = render(<Checklist title="Packing" items={[first, second]} />)
1663
+ await waitFor(() =>
1664
+ expect(screen.getAllByRole('button')[0].hasAttribute('disabled')).toBe(false)
1665
+ )
1666
+ fireEvent.click(screen.getAllByRole('button')[0])
1667
+ view.rerender(<Checklist title="Packing" items={[second, first]} />)
1668
+ expect(
1669
+ screen.getAllByRole('button')[0].querySelector('[data-icon=checkmark]')
1670
+ ).toBeNull()
1671
+ expect(
1672
+ screen.getAllByRole('button')[1].querySelector('[data-icon=checkmark]')
1673
+ ).not.toBeNull()
1674
+ })
1675
+ it('keeps repeated feature IDs independent across reopen', async () => {
1676
+ const view = render(
1677
+ <>
1678
+ <Goal id="One" title="One" target={10} />
1679
+ <Goal id="Two" title="Two" target={10} />
1680
+ </>
1681
+ )
1682
+ await waitFor(() =>
1683
+ expect(screen.getAllByText('+1')[0].closest('button')?.disabled).toBe(false)
1684
+ )
1685
+ fireEvent.click(screen.getAllByText('+1')[0])
1686
+ await waitFor(() => expect(stores.get('private:One')?.value().progress).toBe(1))
1687
+ expect(stores.get('private:Two')?.value().progress).toBe(0)
1688
+ expect([...stores.keys()].toSorted()).toEqual(['private:One', 'private:Two'])
1689
+ view.unmount()
1690
+ render(<Goal id="One" title="One" target={10} />)
1691
+ await waitFor(() => expect(screen.getByText('1')).toBeTruthy())
1692
+ })
1693
+ it('persists editable note text through unmount and reopen', async () => {
1694
+ const view = render(<Note title="Notes" body="Seed" />)
1695
+ await waitFor(() =>
1696
+ expect(
1697
+ screen.getAllByRole<HTMLInputElement | HTMLTextAreaElement>('textbox')
1698
+ ).toHaveLength(2)
1699
+ )
1700
+ fireEvent.change(
1701
+ screen.getAllByRole<HTMLInputElement | HTMLTextAreaElement>('textbox')[1],
1702
+ {
1703
+ target: { value: 'My saved writing' },
1704
+ }
1705
+ )
1706
+ view.unmount()
1707
+ render(<Note title="Notes" body="Seed" />)
1708
+ await waitFor(() =>
1709
+ expect(
1710
+ screen.getAllByRole<HTMLInputElement | HTMLTextAreaElement>('textbox')[1].value
1711
+ ).toBe('My saved writing')
1712
+ )
1713
+ })
1714
+ it('changes one personal vote without counting the same voter twice', async () => {
1715
+ render(
1716
+ <Poll
1717
+ question="Lunch?"
1718
+ options={[
1719
+ { id: 'a', label: 'Pizza' },
1720
+ { id: 'b', label: 'Tacos' },
1721
+ ]}
1722
+ />
1723
+ )
1724
+ await waitFor(() =>
1725
+ expect(screen.getAllByRole('button')[0].hasAttribute('disabled')).toBe(false)
1726
+ )
1727
+ fireEvent.click(screen.getAllByRole('button')[0])
1728
+ fireEvent.click(screen.getAllByRole('button')[1])
1729
+ await waitFor(() => expect(screen.getByText('1 vote')).toBeTruthy())
1730
+ expect(
1731
+ Object.entries(stores.get('private:KowalskiPoll')?.value() ?? {})
1732
+ .filter(([key]) => key.startsWith('vote_'))
1733
+ .map(([, value]) => value)
1734
+ ).toEqual(['b'])
1735
+ })
1736
+ it('restores a running timer from its saved wall-clock anchor', async () => {
1737
+ vi.setSystemTime(new Date('2026-10-15T12:00:00Z'))
1738
+ const view = render(<Timer durationSeconds={60} />)
1739
+ await waitFor(() =>
1740
+ expect(screen.getByText('Start').closest('button')?.disabled).toBe(false)
1741
+ )
1742
+ fireEvent.click(screen.getByText('Start'))
1743
+ view.unmount()
1744
+ vi.setSystemTime(new Date('2026-10-15T12:00:20Z'))
1745
+ render(<Timer durationSeconds={60} />)
1746
+ await waitFor(() => expect(screen.getByText('00:40')).toBeTruthy())
1747
+ await act(async () => fireEvent.click(screen.getByText('Pause')))
1748
+ expect(stores.get('private:KowalskiTimer')?.value().startedAt).toBeNull()
1749
+ })
1750
+ it('adds spending to a budget category and keeps the running total', async () => {
1751
+ render(
1752
+ <Budget
1753
+ categories={[
1754
+ { id: 'groceries', label: 'Groceries', limit: 400 },
1755
+ { id: 'transport', label: 'Transport', limit: 100 },
1756
+ ]}
1757
+ />
1758
+ )
1759
+ fireEvent.change(screen.getByPlaceholderText('Amount'), {
1760
+ target: { value: '12.50' },
1761
+ })
1762
+ await waitFor(() =>
1763
+ expect(screen.getByText('Add to Groceries').closest('button')?.disabled).toBe(false)
1764
+ )
1765
+ fireEvent.click(screen.getByText('Add to Groceries'))
1766
+ await waitFor(() =>
1767
+ expect(stores.get('private:KowalskiBudget')?.value().spent_groceries).toBe(12.5)
1768
+ )
1769
+ expect(screen.getByText('$12.50 of $400.00')).toBeTruthy()
1770
+ expect(screen.getByText('$487.50 left')).toBeTruthy()
1771
+ })
1772
+ it('keeps score per player and names the leader', async () => {
1773
+ render(
1774
+ <Scoreboard
1775
+ players={[
1776
+ { id: 'maya', label: 'Maya' },
1777
+ { id: 'theo', label: 'Theo' },
1778
+ ]}
1779
+ />
1780
+ )
1781
+ await waitFor(() =>
1782
+ expect(
1783
+ screen.getByRole('button', { name: 'Raise Theo' }).hasAttribute('disabled')
1784
+ ).toBe(false)
1785
+ )
1786
+ fireEvent.click(screen.getByRole('button', { name: 'Raise Theo' }))
1787
+ fireEvent.click(screen.getByRole('button', { name: 'Raise Theo' }))
1788
+ await waitFor(() =>
1789
+ expect(stores.get('private:KowalskiScoreboard')?.value().score_theo).toBe(2)
1790
+ )
1791
+ expect(screen.getByText('Theo leads')).toBeTruthy()
1792
+ })
1793
+ it('moves a task from To do to Doing', async () => {
1794
+ render(<TaskBoard tasks={[{ id: 'invite', label: 'Send the invitations' }]} />)
1795
+ const start = await screen.findByRole('button', {
1796
+ name: 'Start: Send the invitations',
1797
+ })
1798
+ await waitFor(() => expect(start.hasAttribute('disabled')).toBe(false))
1799
+ fireEvent.click(start)
1800
+ await waitFor(() =>
1801
+ expect(stores.get('private:KowalskiTaskBoard')?.value().status_invite).toBe('doing')
1802
+ )
1803
+ fireEvent.click(screen.getByText('Doing 1'))
1804
+ expect(
1805
+ screen.getByRole('button', { name: 'Finish: Send the invitations' })
1806
+ ).toBeTruthy()
1807
+ })
1808
+ it('scales recipe ingredient quantities with servings', async () => {
1809
+ render(
1810
+ <Recipe
1811
+ ingredients={[
1812
+ { amount: 200, id: 'pasta', label: 'Spaghetti', unit: 'g' },
1813
+ { amount: 1.5, id: 'water', label: 'Water', unit: 'cups' },
1814
+ { id: 'salt', label: 'Salt', unit: 'a pinch' },
1815
+ ]}
1816
+ servings={2}
1817
+ title="Pasta"
1818
+ steps={[{ id: 'cook', instruction: 'Cook it.' }]}
1819
+ />
1820
+ )
1821
+ await waitFor(() =>
1822
+ expect(
1823
+ screen.getByRole('button', { name: 'More servings' }).hasAttribute('disabled')
1824
+ ).toBe(false)
1825
+ )
1826
+ fireEvent.click(screen.getByRole('button', { name: 'More servings' }))
1827
+ await waitFor(() => expect(screen.getByText('300 g')).toBeTruthy())
1828
+ expect(screen.getByText('2¼ cups')).toBeTruthy()
1829
+ expect(screen.getByText('a pinch')).toBeTruthy()
1830
+ expect(stores.get('private:KowalskiRecipe')?.value().servings).toBe(3)
1831
+ })
1832
+ it('cooks an RGF recipe through waits, timers, and doneness', async () => {
1833
+ render(
1834
+ <Recipe
1835
+ ingredients={[{ amount: 200, id: 'spaghetti', label: 'Spaghetti', unit: 'g' }]}
1836
+ servings={2}
1837
+ title="Pasta"
1838
+ steps={[
1839
+ {
1840
+ id: 'water',
1841
+ instruction: 'Boil the water.',
1842
+ outputs: [{ id: 'boiling', name: 'boiling water' }],
1843
+ },
1844
+ {
1845
+ doneness: 'al dente',
1846
+ duration: { minMinutes: 9, raw: '9 min' },
1847
+ id: 'pasta',
1848
+ instruction: 'Cook the pasta.',
1849
+ label: 'pasta',
1850
+ outputs: [{ id: 'pot', name: 'pasta in its water' }],
1851
+ uses: [
1852
+ { id: 'u-water', source: { kind: 'output', outputId: 'boiling' } },
1853
+ {
1854
+ id: 'u-pasta',
1855
+ source: { ingredientId: 'spaghetti', kind: 'ingredient' },
1856
+ },
1857
+ ],
1858
+ },
1859
+ {
1860
+ id: 'drain',
1861
+ instruction: 'Drain the pasta.',
1862
+ uses: [{ id: 'u-pot', source: { kind: 'output', outputId: 'pot' } }],
1863
+ },
1864
+ ]}
1865
+ />
1866
+ )
1867
+ const button = (name: string | RegExp) => screen.getByRole('button', { name })
1868
+ await waitFor(() =>
1869
+ expect(button('Start cooking').hasAttribute('disabled')).toBe(false)
1870
+ )
1871
+ expect(screen.getByText('9 min total, 9 min hands-on')).toBeTruthy()
1872
+ fireEvent.click(button('Start cooking'))
1873
+ await waitFor(() => expect(button('Done').hasAttribute('disabled')).toBe(false))
1874
+ expect(stores.get('private:KowalskiRecipe')?.value().cooking).toBe(true)
1875
+ expect(screen.getByText('2 servings · 1 ingredient')).toBeTruthy()
1876
+ expect(button('Drain the pasta.').hasAttribute('disabled')).toBe(true)
1877
+ fireEvent.click(button('Done'))
1878
+ await waitFor(() =>
1879
+ expect(button('Start 9 min').hasAttribute('disabled')).toBe(false)
1880
+ )
1881
+ fireEvent.click(button('Start 9 min'))
1882
+ await waitFor(() => expect(screen.getByText('9:00')).toBeTruthy())
1883
+ act(() =>
1884
+ stores
1885
+ .get('private:KowalskiRecipe')
1886
+ ?.store.set({ step_pasta: { done: false, endsAt: Date.now() - 60_000 } })
1887
+ )
1888
+ await waitFor(() => expect(button('+1 min')).toBeTruthy())
1889
+ expect(screen.getByText('Time’s up. Done when al dente.')).toBeTruthy()
1890
+ expect(button('Drain the pasta.').hasAttribute('disabled')).toBe(true)
1891
+ fireEvent.click(button('Done'))
1892
+ await waitFor(() => expect(screen.getByText('2 of 3 done')).toBeTruthy())
1893
+ expect(screen.queryByRole('button', { name: 'Drain the pasta.' })).toBeNull()
1894
+ expect(button('Done').hasAttribute('disabled')).toBe(false)
1895
+ })
1896
+ it('rejects RGF steps that use later outputs or unknown ingredients', () => {
1897
+ const recipe = (steps: unknown[]) =>
1898
+ kowalskiCatalog.recipe.props.safeParse({
1899
+ ingredients: [{ id: 'salt', label: 'Salt' }],
1900
+ steps,
1901
+ title: 'Broken',
1902
+ }).success
1903
+ expect(recipe([{ id: 'a', instruction: 'A', uses: [] }])).toBe(true)
1904
+ expect(
1905
+ recipe([
1906
+ { after: ['b'], id: 'a', instruction: 'A' },
1907
+ { id: 'b', instruction: 'B' },
1908
+ ])
1909
+ ).toBe(false)
1910
+ expect(
1911
+ recipe([
1912
+ {
1913
+ id: 'a',
1914
+ instruction: 'A',
1915
+ uses: [{ id: 'u', source: { ingredientId: 'sugar', kind: 'ingredient' } }],
1916
+ },
1917
+ ])
1918
+ ).toBe(false)
1919
+ })
1920
+ it('passes a rotation turn on and counts completed turns', async () => {
1921
+ render(
1922
+ <Rotation
1923
+ people={[
1924
+ { id: 'ava', label: 'Ava' },
1925
+ { id: 'ben', label: 'Ben' },
1926
+ { id: 'cleo', label: 'Cleo' },
1927
+ ]}
1928
+ task="Recycling"
1929
+ />
1930
+ )
1931
+ await waitFor(() =>
1932
+ expect(screen.getByText('Done').closest('button')?.disabled).toBe(false)
1933
+ )
1934
+ fireEvent.click(screen.getByText('Done'))
1935
+ await waitFor(() =>
1936
+ expect(stores.get('private:KowalskiRotation')?.value()).toMatchObject({
1937
+ completed: 1,
1938
+ turn: 'ben',
1939
+ })
1940
+ )
1941
+ // Ben is now up: named in the card and marked in the order.
1942
+ expect(screen.getAllByText('Ben')).toHaveLength(2)
1943
+ })
1944
+ it('totals the checked items of a shopping list', async () => {
1945
+ render(
1946
+ <ShoppingList
1947
+ items={[
1948
+ { id: 'eggs', label: 'Eggs', price: 4.5 },
1949
+ { id: 'apples', label: 'Apples', price: 0.8, quantity: 6 },
1950
+ ]}
1951
+ />
1952
+ )
1953
+ const eggs = await screen.findByRole('button', { name: 'Eggs. $4.50' })
1954
+ await waitFor(() => expect(eggs.hasAttribute('disabled')).toBe(false))
1955
+ expect(eggs.querySelector('[data-icon="checkmark"]')).toBeNull()
1956
+ fireEvent.click(eggs)
1957
+ await waitFor(() =>
1958
+ expect(stores.get('private:KowalskiShoppingList')?.value().got_eggs).toBe(true)
1959
+ )
1960
+ expect(screen.getByText('In the cart of $9.30')).toBeTruthy()
1961
+ expect(screen.getByText('1 of 2')).toBeTruthy()
1962
+ expect(eggs.querySelector('[data-icon="checkmark"]')).not.toBeNull()
1963
+ expect(
1964
+ screen
1965
+ .getByRole('button', { name: 'Apples. 6 × $0.80' })
1966
+ .querySelector('[data-icon="checkmark"]')
1967
+ ).toBeNull()
1968
+ })
1969
+ it('rates with stars and clears a repeated star', async () => {
1970
+ render(<Rating subject="Dinner" />)
1971
+ await waitFor(() =>
1972
+ expect(
1973
+ screen.getByRole('button', { name: '4 stars' }).hasAttribute('disabled')
1974
+ ).toBe(false)
1975
+ )
1976
+ fireEvent.click(screen.getByRole('button', { name: '4 stars' }))
1977
+ await waitFor(() => expect(screen.getByText('Great')).toBeTruthy())
1978
+ fireEvent.click(screen.getByRole('button', { name: '4 stars' }))
1979
+ await waitFor(() =>
1980
+ expect(stores.get('private:KowalskiRating')?.value().stars).toBe(0)
1981
+ )
1982
+ })
1983
+ it('splits a shared cost evenly and settles the tab', async () => {
1984
+ render(
1985
+ <Ledger
1986
+ people={[
1987
+ { id: 'sam', label: 'Sam' },
1988
+ { id: 'riley', label: 'Riley' },
1989
+ ]}
1990
+ />
1991
+ )
1992
+ await waitFor(() => expect(screen.getByPlaceholderText('Amount')).toBeTruthy())
1993
+ fireEvent.change(screen.getByPlaceholderText('What for'), {
1994
+ target: { value: 'Groceries' },
1995
+ })
1996
+ fireEvent.change(screen.getByPlaceholderText('Amount'), { target: { value: '30' } })
1997
+ fireEvent.click(screen.getByText('Add to the tab'))
1998
+ await waitFor(() => expect(screen.getByText('Riley owes Sam')).toBeTruthy())
1999
+ expect(screen.getByText('$15.00')).toBeTruthy()
2000
+ fireEvent.click(screen.getByText('Settle up'))
2001
+ await waitFor(() => expect(screen.getByText('All square')).toBeTruthy())
2002
+ })
2003
+ })
2004
+
2005
+ describe('new feature input boundaries', () => {
2006
+ const categories = [
2007
+ { id: 'first', label: 'Food', limit: 100 },
2008
+ { id: 'second', label: 'Food', limit: 100 },
2009
+ ]
2010
+ const people = [
2011
+ { id: 'sam', label: 'Sam' },
2012
+ { id: 'riley', label: 'Riley' },
2013
+ ]
2014
+ it.each(['budget', 'ledger'] as const)(
2015
+ 'preserves the next %s draft across a delayed save',
2016
+ async component => {
2017
+ const native = nativeStore(kowalskiCatalog[component].storeSchema.parse({}))
2018
+ const ack = Promise.withResolvers<void>()
2019
+ const save = native.store.setAsync.bind(native.store)
2020
+ vi.spyOn(native.store, 'setAsync').mockImplementation(async patch => {
2021
+ await ack.promise
2022
+ return save(patch)
2023
+ })
2024
+ stores.set(
2025
+ component === 'budget' ? 'private:KowalskiBudget' : 'private:KowalskiLedger',
2026
+ native
2027
+ )
2028
+ render(
2029
+ component === 'budget' ? (
2030
+ <Budget categories={categories} />
2031
+ ) : (
2032
+ <Ledger people={people} />
2033
+ )
2034
+ )
2035
+ const add = screen.getByText(
2036
+ component === 'budget' ? 'Add to Food' : 'Add to the tab'
2037
+ )
2038
+ fireEvent.change(screen.getByPlaceholderText('Amount'), { target: { value: '10' } })
2039
+ if (component === 'ledger') {
2040
+ fireEvent.change(screen.getByPlaceholderText('What for'), {
2041
+ target: { value: 'First' },
2042
+ })
2043
+ }
2044
+ await waitFor(() => expect(add.closest('button')?.disabled).toBe(false))
2045
+ fireEvent.click(add)
2046
+ fireEvent.change(screen.getByPlaceholderText('Amount'), { target: { value: '20' } })
2047
+ if (component === 'ledger') {
2048
+ fireEvent.change(screen.getByPlaceholderText('What for'), {
2049
+ target: { value: 'Next' },
2050
+ })
2051
+ }
2052
+ await act(async () => ack.resolve())
2053
+ expect(screen.getByPlaceholderText<HTMLInputElement>('Amount').value).toBe('20')
2054
+ if (component === 'ledger') {
2055
+ expect(screen.getByPlaceholderText<HTMLInputElement>('What for').value).toBe(
2056
+ 'Next'
2057
+ )
2058
+ }
2059
+ }
2060
+ )
2061
+ it('selects independent budget categories with the same label', async () => {
2062
+ render(<Budget categories={categories} />)
2063
+ fireEvent.change(screen.getByRole('combobox', { name: 'Category' }), {
2064
+ target: { value: 'second' },
2065
+ })
2066
+ fireEvent.change(screen.getByPlaceholderText('Amount'), { target: { value: '10' } })
2067
+ await waitFor(() =>
2068
+ expect(screen.getByText('Add to Food').closest('button')?.disabled).toBe(false)
2069
+ )
2070
+ fireEvent.click(screen.getByText('Add to Food'))
2071
+ await waitFor(() =>
2072
+ expect(stores.get('private:KowalskiBudget')?.value().spent_second).toBe(10)
2073
+ )
2074
+ expect(stores.get('private:KowalskiBudget')?.value().spent_first).toBeUndefined()
2075
+ })
2076
+ it('blocks spending that would exceed the stored category limit', async () => {
2077
+ stores.set(
2078
+ 'private:KowalskiBudget',
2079
+ nativeStore(kowalskiCatalog.budget.storeSchema.parse({ spent_first: 1e12 }))
2080
+ )
2081
+ render(<Budget categories={categories} />)
2082
+ await waitFor(() =>
2083
+ expect(screen.getByText('$1,000,000,000,000.00 of $100.00')).toBeTruthy()
2084
+ )
2085
+ fireEvent.change(screen.getByPlaceholderText('Amount'), { target: { value: '1' } })
2086
+ expect(screen.getByText('Add to Food').closest('button')?.disabled).toBe(true)
2087
+ })
2088
+ it('requires an admissible ledger description and recovers when it is shortened', async () => {
2089
+ render(<Ledger people={people} />)
2090
+ fireEvent.change(screen.getByPlaceholderText('Amount'), { target: { value: '10' } })
2091
+ await waitFor(() =>
2092
+ expect(screen.getByText('Add to the tab').closest('button')?.disabled).toBe(false)
2093
+ )
2094
+ fireEvent.change(screen.getByPlaceholderText('What for'), {
2095
+ target: { value: 'x'.repeat(161) },
2096
+ })
2097
+ expect(screen.getByText('Add to the tab').closest('button')?.disabled).toBe(true)
2098
+ fireEvent.change(screen.getByPlaceholderText('What for'), {
2099
+ target: { value: 'Dinner' },
2100
+ })
2101
+ fireEvent.click(screen.getByText('Add to the tab'))
2102
+ await waitFor(() =>
2103
+ expect(stores.get('private:KowalskiLedger')?.value().entries).toMatchObject([
2104
+ { amount: 10, label: 'Dinner' },
2105
+ ])
2106
+ )
2107
+ })
2108
+ it('can clear a balanced full ledger and add new costs', async () => {
2109
+ stores.set(
2110
+ 'private:KowalskiLedger',
2111
+ nativeStore(
2112
+ kowalskiCatalog.ledger.storeSchema.parse({
2113
+ entries: Array.from({ length: 200 }, (_, index) => ({
2114
+ amount: 10,
2115
+ id: `entry${index}`,
2116
+ label: '',
2117
+ paidBy: index % 2 ? 'sam' : 'riley',
2118
+ })),
2119
+ })
2120
+ )
2121
+ )
2122
+ render(<Ledger people={people} />)
2123
+ await waitFor(() => expect(screen.getByText('Settle up')).toBeTruthy())
2124
+ fireEvent.click(screen.getByText('Settle up'))
2125
+ await waitFor(() =>
2126
+ expect(stores.get('private:KowalskiLedger')?.value().entries).toEqual([])
2127
+ )
2128
+ fireEvent.change(screen.getByPlaceholderText('Amount'), { target: { value: '10' } })
2129
+ expect(screen.getByText('Add to the tab').closest('button')?.disabled).toBe(false)
2130
+ })
2131
+ })
2132
+
2133
+ function nativeStore(initial: object) {
2134
+ let value: Record<string, unknown> = { ...initial }
2135
+ const listeners = new Set<(raw: { rawValue: unknown; version: number }) => void>()
2136
+ return {
2137
+ store: {
2138
+ set(patch: object) {
2139
+ value = { ...value, ...patch }
2140
+ for (const listener of listeners) {
2141
+ listener({ rawValue: value, version: 0 })
2142
+ }
2143
+ },
2144
+ async setAsync(patch: object) {
2145
+ this.set(patch)
2146
+ return { rawValue: value, version: 0 }
2147
+ },
2148
+ subscribe(listener: (raw: { rawValue: unknown; version: number }) => void) {
2149
+ listeners.add(listener)
2150
+ listener({ rawValue: value, version: 0 })
2151
+ return () => {
2152
+ listeners.delete(listener)
2153
+ }
2154
+ },
2155
+ },
2156
+ value: () => value,
2157
+ }
2158
+ }