brane 0.1.1 → 0.3.1

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 (352) hide show
  1. package/AGENTS.md +367 -0
  2. package/CHANGELOG.md +66 -0
  3. package/CONTRIBUTING.md +101 -0
  4. package/DESIGN.md +405 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +213 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +133 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +360 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1101 -0
  37. package/dist/commands/token.js +154 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6353 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +643 -0
  58. package/dist/db/tokens.js +624 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/app-origin.js +26 -0
  96. package/dist/shared/block-glyphs.js +114 -0
  97. package/dist/shared/diagram-css.js +17 -0
  98. package/dist/shared/export-selection.js +101 -0
  99. package/dist/shared/find-in-text.js +110 -0
  100. package/dist/shared/glow-hues.js +225 -0
  101. package/dist/shared/hex-dump.js +62 -0
  102. package/dist/shared/hex-pages.js +52 -0
  103. package/dist/shared/hit-drum.js +79 -0
  104. package/dist/shared/known-nids.js +58 -0
  105. package/dist/shared/list-scroll-keys.js +56 -0
  106. package/dist/shared/markdown-lists.js +72 -0
  107. package/dist/shared/markdown-math.js +485 -0
  108. package/dist/shared/markdown-mentions.js +79 -0
  109. package/dist/shared/markdown-nids.js +41 -0
  110. package/dist/shared/markdown-protocols.js +53 -0
  111. package/dist/shared/markdown-refs.js +123 -0
  112. package/dist/shared/membrane-invasion.js +342 -0
  113. package/dist/shared/membrane-motion.js +83 -0
  114. package/dist/shared/membrane-palette.js +253 -0
  115. package/dist/shared/membrane-terrain.js +212 -0
  116. package/dist/shared/message-selection.js +123 -0
  117. package/dist/shared/mo-glyph.js +57 -0
  118. package/dist/shared/narrative-format.js +237 -0
  119. package/dist/shared/narrative-menu.js +84 -0
  120. package/dist/shared/narrative-sort.js +47 -0
  121. package/dist/shared/narrative-status.js +52 -0
  122. package/dist/shared/no-pan.js +85 -0
  123. package/dist/shared/pane-layout.js +50 -0
  124. package/dist/shared/payload-inline.js +83 -0
  125. package/dist/shared/speaker-color.js +61 -0
  126. package/dist/shared/token-rotation.js +14 -0
  127. package/dist/shared/view-url.js +216 -0
  128. package/dist/shared/wiki-links.js +70 -0
  129. package/dist/shared/wiki-window.js +4 -0
  130. package/dist/tailscale.js +55 -0
  131. package/dist/test/absorb-claude.test.js +892 -0
  132. package/dist/test/agent-scope.test.js +193 -0
  133. package/dist/test/agents.test.js +55 -0
  134. package/dist/test/api-request.test.js +122 -0
  135. package/dist/test/app-host-literal.test.js +53 -0
  136. package/dist/test/awaiting-glow.test.js +54 -0
  137. package/dist/test/awaiting.test.js +122 -0
  138. package/dist/test/bind-token.test.js +88 -0
  139. package/dist/test/block-glyphs.test.js +68 -0
  140. package/dist/test/cert-manager.test.js +262 -0
  141. package/dist/test/claude-live-session.test.js +110 -0
  142. package/dist/test/codex-tool-log.test.js +568 -0
  143. package/dist/test/config.test.js +71 -0
  144. package/dist/test/copilot-tool-log.test.js +93 -0
  145. package/dist/test/cors-preflight.test.js +56 -0
  146. package/dist/test/cost-linger.test.js +31 -0
  147. package/dist/test/cost-pricing.test.js +72 -0
  148. package/dist/test/cost-reprice.test.js +139 -0
  149. package/dist/test/cost-sessions.test.js +301 -0
  150. package/dist/test/crush-tool-log.test.js +289 -0
  151. package/dist/test/daemon-client.test.js +109 -0
  152. package/dist/test/daemon-socket.test.js +293 -0
  153. package/dist/test/diagram-arrows.test.js +205 -0
  154. package/dist/test/diagram-export.test.js +210 -0
  155. package/dist/test/diagram-labels.test.js +313 -0
  156. package/dist/test/diagram-parse.test.js +191 -0
  157. package/dist/test/diagram-render.test.js +175 -0
  158. package/dist/test/diagram-review.test.js +288 -0
  159. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  160. package/dist/test/embedder.test.js +84 -0
  161. package/dist/test/events.test.js +123 -0
  162. package/dist/test/export-selection.test.js +102 -0
  163. package/dist/test/export.test.js +505 -0
  164. package/dist/test/files-browse.test.js +393 -0
  165. package/dist/test/files-find.test.js +272 -0
  166. package/dist/test/find-in-text.test.js +86 -0
  167. package/dist/test/first-paint-viewport.test.js +59 -0
  168. package/dist/test/fixtures/embedding-golden.js +23 -0
  169. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  170. package/dist/test/glow-hues.test.js +202 -0
  171. package/dist/test/harness-cost.test.js +423 -0
  172. package/dist/test/helpers.js +21 -0
  173. package/dist/test/hex-pages.test.js +68 -0
  174. package/dist/test/highlight.test.js +69 -0
  175. package/dist/test/hit-drum.test.js +71 -0
  176. package/dist/test/hook-config.test.js +299 -0
  177. package/dist/test/json-gzip.test.js +70 -0
  178. package/dist/test/key-routing.test.js +58 -0
  179. package/dist/test/known-nids.test.js +106 -0
  180. package/dist/test/link-preview.test.js +67 -0
  181. package/dist/test/list-scroll-keys.test.js +66 -0
  182. package/dist/test/listen-own-words.test.js +147 -0
  183. package/dist/test/listen-timeout.test.js +33 -0
  184. package/dist/test/markdown-diagnostics.test.js +121 -0
  185. package/dist/test/markdown-lists.test.js +182 -0
  186. package/dist/test/markdown-math.test.js +256 -0
  187. package/dist/test/markdown-nids.test.js +146 -0
  188. package/dist/test/markdown-protocols.test.js +139 -0
  189. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  190. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  191. package/dist/test/mcp-narrative.test.js +193 -0
  192. package/dist/test/mcp.test.js +548 -0
  193. package/dist/test/meld.test.js +143 -0
  194. package/dist/test/membrane-invasion.test.js +482 -0
  195. package/dist/test/membrane-motion.test.js +101 -0
  196. package/dist/test/membrane-palette.test.js +166 -0
  197. package/dist/test/membrane-terrain.test.js +170 -0
  198. package/dist/test/memory-log.test.js +202 -0
  199. package/dist/test/mentions-e2e.test.js +261 -0
  200. package/dist/test/mentions-ledger.test.js +265 -0
  201. package/dist/test/mentions-listen.test.js +265 -0
  202. package/dist/test/mentions.test.js +238 -0
  203. package/dist/test/message-selection.test.js +114 -0
  204. package/dist/test/mo-glyph.test.js +22 -0
  205. package/dist/test/model-resolution.test.js +166 -0
  206. package/dist/test/narrate-validate.test.js +77 -0
  207. package/dist/test/narrate-wait.test.js +160 -0
  208. package/dist/test/narrative-format.test.js +219 -0
  209. package/dist/test/narrative-menu.test.js +150 -0
  210. package/dist/test/narrative-sort.test.js +75 -0
  211. package/dist/test/no-pan.test.js +60 -0
  212. package/dist/test/pane-layout.test.js +153 -0
  213. package/dist/test/paths.test.js +37 -0
  214. package/dist/test/payload-inline.test.js +91 -0
  215. package/dist/test/precache-fonts.test.js +72 -0
  216. package/dist/test/presence-gate.test.js +440 -0
  217. package/dist/test/presence.test.js +160 -0
  218. package/dist/test/read-events.test.js +220 -0
  219. package/dist/test/remote-meld.test.js +128 -0
  220. package/dist/test/revive-cli.test.js +93 -0
  221. package/dist/test/search-floor.test.js +206 -0
  222. package/dist/test/search-fusion.test.js +168 -0
  223. package/dist/test/search.test.js +205 -0
  224. package/dist/test/security.test.js +48 -0
  225. package/dist/test/selection-wiring.test.js +61 -0
  226. package/dist/test/session-affinity.test.js +295 -0
  227. package/dist/test/signal-source.test.js +32 -0
  228. package/dist/test/socket-account.test.js +268 -0
  229. package/dist/test/spawn-toml.test.js +72 -0
  230. package/dist/test/speaker-color.test.js +90 -0
  231. package/dist/test/tailscale-rebind.test.js +79 -0
  232. package/dist/test/terminal-cursor.test.js +61 -0
  233. package/dist/test/terminal-resync.test.js +23 -0
  234. package/dist/test/terminal-size.test.js +36 -0
  235. package/dist/test/thin-cli.test.js +382 -0
  236. package/dist/test/token-bootstrap.test.js +272 -0
  237. package/dist/test/token-devices.test.js +646 -0
  238. package/dist/test/token-lapse.test.js +94 -0
  239. package/dist/test/token-ring.test.js +188 -0
  240. package/dist/test/token-rotate-cli.test.js +58 -0
  241. package/dist/test/token-routes.test.js +95 -0
  242. package/dist/test/tool-uses.test.js +1006 -0
  243. package/dist/test/usage-ledger.test.js +126 -0
  244. package/dist/test/view-url.test.js +627 -0
  245. package/dist/test/wiki-links.test.js +57 -0
  246. package/dist/test/wiki-sources.test.js +85 -0
  247. package/dist/test/wiki-window.test.js +31 -0
  248. package/dist/test/wiki.test.js +399 -0
  249. package/dist/test/wordpiece.test.js +68 -0
  250. package/dist/test/wrangler-route.test.js +27 -0
  251. package/dist/tls.js +148 -0
  252. package/dist/tmux.js +213 -0
  253. package/dist/user-agent.js +46 -0
  254. package/docker-entrypoint.sh +154 -0
  255. package/docs/README.md +3 -0
  256. package/docs/absorb.md +231 -0
  257. package/docs/cost-ledger.md +125 -0
  258. package/docs/daemon-api.md +189 -0
  259. package/docs/diagrams.md +163 -0
  260. package/docs/doctor.md +36 -0
  261. package/docs/export-scope.md +69 -0
  262. package/docs/files-view.md +133 -0
  263. package/docs/link-previews.md +38 -0
  264. package/docs/math.md +32 -0
  265. package/docs/mcp.md +69 -0
  266. package/docs/memory-search.md +75 -0
  267. package/docs/mentions.md +101 -0
  268. package/docs/multi-brane.md +14 -0
  269. package/docs/passkeys.md +165 -0
  270. package/docs/presence.md +202 -0
  271. package/docs/releasing.md +104 -0
  272. package/docs/rendering-warnings.md +22 -0
  273. package/docs/request-layer.md +72 -0
  274. package/docs/selection-store.md +50 -0
  275. package/docs/session-affinity.md +61 -0
  276. package/docs/terminal-mirroring.md +243 -0
  277. package/docs/token-rotation.md +431 -0
  278. package/docs/view-urls.md +134 -0
  279. package/docs/web-app.md +23 -0
  280. package/docs/wiki.md +43 -0
  281. package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
  282. package/expo/dist/apple-touch-icon.png +0 -0
  283. package/expo/dist/favicon.svg +3 -0
  284. package/expo/dist/index.html +177 -0
  285. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  301. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  302. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  303. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  304. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  305. package/expo/dist/katex/katex.min.css +1 -0
  306. package/expo/dist/manifest.json +16 -0
  307. package/expo/dist/metadata.json +1 -0
  308. package/expo/dist/og/membrane.v1.png +0 -0
  309. package/expo/dist/pdf.min.mjs +28 -0
  310. package/expo/dist/pdf.worker.min.mjs +28 -0
  311. package/expo/dist/sw.js +154 -0
  312. package/package.json +79 -27
  313. package/protocols/LIVE_loop.md +407 -0
  314. package/protocols/bebrief.md +41 -0
  315. package/protocols/digest.md +231 -0
  316. package/protocols/fly.md +266 -0
  317. package/protocols/idle.md +81 -0
  318. package/protocols/keeper.md +829 -0
  319. package/protocols/markdown.md +80 -0
  320. package/protocols/memory.md +419 -0
  321. package/protocols/mentions.md +93 -0
  322. package/protocols/payloads.md +53 -0
  323. package/protocols/prelude.md +91 -0
  324. package/protocols/ramify.md +105 -0
  325. package/protocols/review.md +356 -0
  326. package/protocols/rrr.md +232 -0
  327. package/protocols/scribe.md +429 -0
  328. package/protocols/summarize.md +203 -0
  329. package/protocols/twin.md +174 -0
  330. package/protocols/wiki.md +438 -0
  331. package/protocols/worker.md +38 -0
  332. package/wiki/shared/append-only-event-log.md +148 -0
  333. package/wiki/shared/index.md +53 -0
  334. package/wiki/shared/owning-your-narratives.md +161 -0
  335. package/wiki/shared/premature-ontologies.md +435 -0
  336. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  337. package/wiki/shared/sequestering-the-narrative.md +143 -0
  338. package/wiki/shared/why-wikis-work.md +216 -0
  339. package/.travis.yml +0 -7
  340. package/lib/brane.cjs.js +0 -12
  341. package/lib/brane.cjs.js.map +0 -1
  342. package/lib/brane.d.ts +0 -11
  343. package/lib/brane.js +0 -6
  344. package/lib/brane.js.map +0 -1
  345. package/lib/tests.d.ts +0 -1
  346. package/lib/tests.js +0 -8
  347. package/lib/tests.js.map +0 -1
  348. package/rollup.config.js +0 -21
  349. package/scripts/test.sh +0 -8
  350. package/src/brane.ts +0 -21
  351. package/src/tests.ts +0 -8
  352. package/tsconfig.json +0 -16
@@ -0,0 +1,143 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createTestDb } from "./helpers.js";
4
+ import { buildMeldUrl, meldLink } from "../commands/meld.js";
5
+ import { cleanAppHost } from "../shared/app-origin.js";
6
+ import { statusAppDomain } from "../daemon/server.js";
7
+ function setTls(db, value) {
8
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('daemon_tls', ?)").run(value ? "true" : "false");
9
+ }
10
+ const daemon = { port: "8443", token: "tok" };
11
+ const TS_HOST = "myhost.tailnet.ts.net";
12
+ describe("buildMeldUrl", () => {
13
+ let db;
14
+ before(() => {
15
+ db = createTestDb();
16
+ });
17
+ after(() => {
18
+ db.close();
19
+ });
20
+ it("emits an HTTPS Tailscale URL when a cert is present (healthy path)", () => {
21
+ setTls(db, true);
22
+ const r = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST });
23
+ assert.equal(r.scheme, "https");
24
+ assert.equal(r.useLocal, false);
25
+ assert.equal(r.isLoopbackFallback, false);
26
+ assert.equal(r.tlsDegraded, false);
27
+ assert.ok(r.url.includes("brane.organelle.dev"));
28
+ });
29
+ it("carries host and token in the fragment, never the query string", () => {
30
+ // The shared SPA is served by a third party (Cloudflare). A query string
31
+ // is part of the request it receives; a fragment never leaves the browser.
32
+ setTls(db, true);
33
+ const u = new URL(buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST }).url);
34
+ assert.equal(u.search, "");
35
+ const conn = new URLSearchParams(u.hash.slice(1));
36
+ // daemonHost is host:port unless the TLS port is 443; the round trip
37
+ // through the fragment must keep the port, which is what a phone dials.
38
+ assert.equal(conn.get("host"), `${TS_HOST}:${daemon.port}`);
39
+ assert.equal(conn.get("token"), daemon.token);
40
+ assert.ok(!u.href.includes("?"), u.href);
41
+ });
42
+ it("the direct daemon URL carries only the token, in the fragment", () => {
43
+ setTls(db, false);
44
+ const u = new URL(buildMeldUrl({ db, daemon, tailscaleHost: undefined }).url);
45
+ assert.equal(u.search, "");
46
+ const conn = new URLSearchParams(u.hash.slice(1));
47
+ assert.equal(conn.get("host"), null);
48
+ assert.equal(conn.get("token"), daemon.token);
49
+ });
50
+ it("keeps the fragment after a path", () => {
51
+ setTls(db, true);
52
+ const u = new URL(buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST, path: "claude-abc123" }).url);
53
+ assert.equal(u.pathname, "/claude-abc123");
54
+ assert.equal(new URLSearchParams(u.hash.slice(1)).get("token"), daemon.token);
55
+ });
56
+ it("flags tlsDegraded when Tailscale is up but no cert exists", () => {
57
+ setTls(db, false);
58
+ const r = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST });
59
+ assert.equal(r.scheme, "http");
60
+ assert.equal(r.useLocal, true);
61
+ assert.equal(r.isLoopbackFallback, false);
62
+ assert.equal(r.tlsDegraded, true, "should warn: Tailscale up, no cert");
63
+ assert.ok(r.daemonHost.startsWith(TS_HOST));
64
+ });
65
+ it("does not flag tlsDegraded when --local is forced", () => {
66
+ setTls(db, false);
67
+ const r = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST, local: true });
68
+ assert.equal(r.tlsDegraded, false, "explicit --local is a deliberate choice");
69
+ assert.equal(r.useLocal, true);
70
+ });
71
+ it("falls back to loopback (not tlsDegraded) when Tailscale is absent", () => {
72
+ setTls(db, false);
73
+ const r = buildMeldUrl({ db, daemon, tailscaleHost: undefined });
74
+ assert.equal(r.isLoopbackFallback, true);
75
+ assert.equal(r.tlsDegraded, false);
76
+ assert.ok(r.daemonHost.startsWith("127.0.0.1"));
77
+ });
78
+ it("points at the app_domain config, which --app overrides, and a literal never decides", () => {
79
+ setTls(db, true);
80
+ // Default: the shipped default, brane's own shell.
81
+ db.prepare("DELETE FROM meta WHERE key = 'config.app_domain'").run();
82
+ assert.equal(buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST }).appHost, "brane.organelle.dev");
83
+ // `brane config set app_domain …`: a staging hostname, or a fork's shell.
84
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('config.app_domain', 'main.brane.organelle.dev')").run();
85
+ const configured = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST });
86
+ assert.equal(configured.appHost, "main.brane.organelle.dev");
87
+ assert.equal(new URL(configured.url).host, "main.brane.organelle.dev");
88
+ assert.equal(new URLSearchParams(new URL(configured.url).hash.slice(1)).get("host"), `${TS_HOST}:${daemon.port}`);
89
+ // --app, for one QR.
90
+ const once = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST, app: "https://fork.example/" });
91
+ assert.equal(once.appHost, "fork.example");
92
+ assert.equal(new URL(once.url).origin, "https://fork.example");
93
+ // --local ignores it: the QR is the daemon's own.
94
+ const local = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST, local: true, app: "fork.example" });
95
+ assert.equal(new URL(local.url).host, `${TS_HOST}:${daemon.port}`);
96
+ db.prepare("DELETE FROM meta WHERE key = 'config.app_domain'").run();
97
+ });
98
+ it("meldLink falls back to the shipped default for a reply that names no app host", () => {
99
+ // A remote brane from before appHost was part of `--connection`'s reply.
100
+ const url = meldLink({ daemonHost: "h.ts.net:8443", scheme: "https", useLocal: false }, "tok");
101
+ assert.equal(new URL(url).host, "brane.organelle.dev");
102
+ const named = meldLink({ daemonHost: "h.ts.net:8443", scheme: "https", useLocal: false, appHost: "fork.example" }, "tok");
103
+ assert.equal(new URL(named).host, "fork.example");
104
+ });
105
+ it("does not flag tlsDegraded when advertising a custom --host", () => {
106
+ // A custom host that isn't the Tailscale hostname isn't a degraded TLS
107
+ // case — the operator chose to advertise something else.
108
+ setTls(db, false);
109
+ const r = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST, host: "192.168.1.5" });
110
+ assert.equal(r.tlsDegraded, false);
111
+ assert.equal(r.isLoopbackFallback, false);
112
+ });
113
+ });
114
+ describe("cleanAppHost", () => {
115
+ it("takes a bare host, or the host of a URL, lower-cased, port kept", () => {
116
+ assert.equal(cleanAppHost("brane.organelle.dev"), "brane.organelle.dev");
117
+ assert.equal(cleanAppHost(" Main.Brane.Organelle.dev "), "main.brane.organelle.dev");
118
+ assert.equal(cleanAppHost("https://fork.example/"), "fork.example");
119
+ assert.equal(cleanAppHost("http://localhost:8081/claude-abc?x=1#y"), "localhost:8081");
120
+ assert.equal(cleanAppHost("fork.example/path"), "fork.example");
121
+ });
122
+ });
123
+ describe("statusAppDomain", () => {
124
+ it("reports the config reduced to a host, as meld reads it, so Isolate and the QR agree", () => {
125
+ const db = createTestDb();
126
+ // With a cert, so the QR is the shared app's and names the host.
127
+ setTls(db, true);
128
+ assert.equal(statusAppDomain(db), "brane.organelle.dev");
129
+ // Stored as a URL by `brane config`: the app must not build https://https://…
130
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('config.app_domain', 'https://staging.example/')").run();
131
+ assert.equal(statusAppDomain(db), "staging.example");
132
+ assert.equal(statusAppDomain(db), buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST }).appHost);
133
+ // Blanked: means the default, on both sides, never a QR for https:///.
134
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('config.app_domain', ' ')").run();
135
+ assert.equal(statusAppDomain(db), "brane.organelle.dev");
136
+ const blank = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST });
137
+ assert.equal(blank.appHost, "brane.organelle.dev");
138
+ assert.equal(new URL(blank.url).host, "brane.organelle.dev");
139
+ assert.equal(buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST, app: "" }).appHost, "brane.organelle.dev");
140
+ db.close();
141
+ });
142
+ });
143
+ //# sourceMappingURL=meld.test.js.map
@@ -0,0 +1,482 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { BURST_S, COVER_MARGIN, TAP_EDGE, CRITICAL_PRESSURE, MAX_SPEED, MAX_SPLOTCHES, MAX_SPLOTCH_RADIUS, RISE, SPLOTCH_TEXELS, TAP_RADIUS, WASH_DELAY_S, WASH_SPEED, WOBBLE, adopt, autoDue, covers, emptyFilm, frontSpeed, invade, packSplotches, reach, sdAt, shift, step, tap, touching, washing, } from "../shared/membrane-invasion.js";
4
+ // What the operator asked for: a tap's color spreads "much more slowly and
5
+ // (seemingly) with struggle" (claude-1cwe8u9p:49); "one splotch should die
6
+ // out, and two that touch should prevail" (:58, :62); splotches oblong and
7
+ // irregular, with no limit a hand runs into (:61); and what prevails washes
8
+ // back into the slow upward flow, in its colors, rather than freezing into
9
+ // a patchwork (:73, :74, :77). These pin outcomes — who heals, who washes
10
+ // in, what never happens — not the exact curves, which are tuning (:63).
11
+ function mulberry32(a) {
12
+ return () => {
13
+ a |= 0;
14
+ a = (a + 0x6d2b79f5) | 0;
15
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
16
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
17
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
18
+ };
19
+ }
20
+ const BOX = [];
21
+ for (let i = 0; i < 20; i++)
22
+ for (let j = 0; j < 20; j++)
23
+ BOX.push([-0.45 + (0.9 * i) / 19, -0.45 + (0.9 * j) / 19]);
24
+ const DT = 1 / 30;
25
+ /** Just beside the latest splotch, in direction (dx, dy): outside its outline
26
+ * by a little, and near enough that the tap's own spot overlaps it. A tap
27
+ * inside the outline would recolor it instead (claude-u6qlei0j:4). */
28
+ const beside = (dx, dy, s) => (film) => {
29
+ const at = s ?? film.splotches.at(-1);
30
+ const n = Math.hypot(dx, dy), d = reach(at, at.x + dx, at.y + dy) + 0.04;
31
+ return [at.x + (dx / n) * d, at.y + (dy / n) * d];
32
+ };
33
+ /** Run taps [time, x, y] (or [time, where]) at 30 fps for up to `seconds`,
34
+ * the way the engine does — adopting a wash once it covers the glyph — and
35
+ * check every step that no front jumps and no splotch outgrows its cap
36
+ * until it washes. */
37
+ function run(taps, seconds, seed = 1, similar) {
38
+ const rng = mulberry32(seed);
39
+ let film = emptyFilm(), next = 0, washedIn = null, healedAt = null;
40
+ for (let t = 0; t < seconds; t += DT) {
41
+ while (next < taps.length && taps[next][0] <= t) {
42
+ const spec = taps[next];
43
+ const [x, y] = spec.length === 3 ? [spec[1], spec[2]] : spec[1](film);
44
+ film = tap(film, x, y, rng).film;
45
+ next++;
46
+ }
47
+ const prev = film;
48
+ film = step(film, DT, similar);
49
+ for (const s of film.splotches) {
50
+ const was = prev.splotches.find((p) => p.phase === s.phase);
51
+ const g = film.groups.find((g) => g.id === s.group);
52
+ assert.ok(Math.abs(s.r - was.r) <= (Math.max(MAX_SPEED, WASH_SPEED) + WOBBLE) * DT + 1e-12, "a front never jumps");
53
+ if (!washing(g, film.clock))
54
+ assert.ok(s.r <= MAX_SPLOTCH_RADIUS + 1e-12, "a splotch outgrows its cap only by washing");
55
+ }
56
+ const won = film.groups.find((g) => washing(g, film.clock) && covers(film, g.id, BOX));
57
+ if (won) {
58
+ washedIn ??= t;
59
+ film = adopt(film, won.id);
60
+ }
61
+ if (next >= taps.length && film.splotches.length === 0 && healedAt === null && washedIn === null)
62
+ healedAt = t;
63
+ }
64
+ return { film, washedIn, healedAt };
65
+ }
66
+ /** Over several seeds, since every splotch's outline is random. */
67
+ const SEEDS = [1, 2, 3, 4, 5];
68
+ describe("a single tap", () => {
69
+ it("turns a spot at once where it lands", () => {
70
+ const { film, started } = tap(emptyFilm(), 0.1, -0.2, mulberry32(1));
71
+ assert.ok(started, "a first tap starts a group, for which the caller draws a scheme");
72
+ assert.deepEqual([film.splotches[0].x, film.splotches[0].y, film.splotches[0].r], [0.1, -0.2, TAP_RADIUS]);
73
+ assert.ok(sdAt(film.splotches[0], 0.1, -0.2) < 0, "its own centre is inside it");
74
+ });
75
+ it("dies out when it touches nothing, and never washes in", () => {
76
+ for (const seed of SEEDS) {
77
+ const o = run([[0, 0, 0]], 150, seed);
78
+ assert.equal(o.washedIn, null, `seed ${seed}`);
79
+ assert.ok(o.healedAt !== null && o.healedAt < 90, `seed ${seed}: healed at ${o.healedAt}`);
80
+ }
81
+ });
82
+ });
83
+ describe("two that touch prevail, and wash in", () => {
84
+ it("when a burst's taps overlap: the film becomes their colors in tens of seconds", () => {
85
+ for (const seed of SEEDS) {
86
+ const o = run([[0, 0, 0], [0.5, beside(1, 0)]], 120, seed);
87
+ assert.ok(o.washedIn !== null && o.washedIn > WASH_DELAY_S && o.washedIn < 30, `seed ${seed}: ${o.washedIn}`);
88
+ assert.equal(o.film.splotches.length, 0, "and then it is just the film again");
89
+ }
90
+ });
91
+ it("from anywhere in the glyph, top or bottom", () => {
92
+ for (const y of [-0.35, 0.35]) {
93
+ const o = run([[0, -0.05, y], [0.5, beside(1, 0)]], 120);
94
+ assert.ok(o.washedIn !== null && o.washedIn < 40, `from y ${y}: ${o.washedIn}`);
95
+ }
96
+ });
97
+ it("but not when a burst's taps are too far apart ever to meet", () => {
98
+ for (const seed of SEEDS) {
99
+ const o = run([[0, -0.4, -0.4], [0.5, 0.4, 0.4]], 300, seed);
100
+ assert.equal(o.washedIn, null, `seed ${seed}`);
101
+ assert.equal(o.film.splotches.length, 0);
102
+ }
103
+ });
104
+ it("nor when they overlap but came from bursts with different colors", () => {
105
+ for (const seed of SEEDS) {
106
+ const rng = mulberry32(seed);
107
+ let film = tap(emptyFilm(), 0, 0, rng).film;
108
+ for (let t = 0; t < BURST_S + 2; t += DT)
109
+ film = step(film, DT);
110
+ // A second color, tapped elsewhere and then moved onto the first: no
111
+ // tap can put two colors together (beside joins), but a front can grow
112
+ // into a neighbor's ground.
113
+ const b = tap(film, 0.4, 0.4, rng);
114
+ film = { ...b.film, splotches: b.film.splotches.map((s) => (s.group === b.started.id ? { ...s, x: 0.06, y: 0 } : s)) };
115
+ assert.equal(film.groups.length, 2, `seed ${seed}: a second color`);
116
+ assert.ok(touching(film.splotches[0], film.splotches[1]), `seed ${seed}: they overlap`);
117
+ let washed = false;
118
+ for (let t = 0; t < 300; t += DT) {
119
+ film = step(film, DT);
120
+ washed ||= film.groups.some((g) => washing(g, film.clock));
121
+ }
122
+ assert.equal(washed, false, `seed ${seed}`);
123
+ assert.equal(film.splotches.length, 0);
124
+ }
125
+ });
126
+ it("across bursts, when their colors are similar", () => {
127
+ assert.notEqual(run([[0, 0, 0], [BURST_S + 2, 0.1, 0]], 120, 1, () => true).washedIn, null);
128
+ });
129
+ it("struggles first: the wash starts WASH_DELAY_S after the splotches connect", () => {
130
+ const rng = mulberry32(1);
131
+ let film = tap(emptyFilm(), 0, 0, rng).film;
132
+ film = tap(film, 0.1, 0, rng).film;
133
+ film = step(film, DT);
134
+ const g = film.groups[0];
135
+ assert.ok(g.washAt !== null);
136
+ assert.ok(Math.abs(g.washAt - (DT + WASH_DELAY_S)) < 1e-9);
137
+ assert.equal(washing(g, film.clock), false);
138
+ assert.equal(washing(g, g.washAt), true);
139
+ });
140
+ it("rises: a washing outline reaches farther up than down", () => {
141
+ const rng = mulberry32(3);
142
+ let film = tap(emptyFilm(), 0, 0, rng).film;
143
+ film = tap(film, ...beside(1, 0)(film), rng).film;
144
+ for (let t = 0; t < WASH_DELAY_S + 3; t += DT)
145
+ film = step(film, DT);
146
+ const s = film.splotches[0];
147
+ assert.equal(s.rise, RISE);
148
+ const round = { ...s, shape: { angle: 0, aspect: 1, lobes: [0, 0, 0], phases: [0, 0, 0] } };
149
+ assert.ok(Math.abs(reach(round, 0, -1) / reach(round, 0, 1) - RISE) < 1e-9, "y is down: up is -y");
150
+ });
151
+ });
152
+ describe("preview, then commit (claude-1cwe8u9p:86)", () => {
153
+ it("a tap beside a splotch, however much later, adds to its color and washes it in", () => {
154
+ for (const seed of SEEDS) {
155
+ const rng = mulberry32(seed);
156
+ let r = tap(emptyFilm(), 0, 0, rng);
157
+ let film = r.film;
158
+ const liked = r.film.groups[0].id;
159
+ for (let t = 0; t < 8; t += DT)
160
+ film = step(film, DT); // look at it a while
161
+ const s = film.splotches[0];
162
+ r = tap(film, s.x + reach(s, 1, 0) + 0.04, s.y, rng); // like it: tap just beside it
163
+ assert.equal(r.started, null, `seed ${seed}: no new color`);
164
+ assert.equal(r.recolored, null, `seed ${seed}: not a recolor`);
165
+ film = r.film;
166
+ assert.equal(film.splotches.length, 2, `seed ${seed}: a second splotch`);
167
+ assert.equal(film.splotches.at(-1).group, liked);
168
+ let washedIn = null;
169
+ for (let t = 0; t < 60 && washedIn === null; t += DT) {
170
+ film = step(film, DT);
171
+ if (film.groups.some((g) => g.id === liked && washing(g, film.clock) && covers(film, g.id, BOX)))
172
+ washedIn = t;
173
+ }
174
+ assert.ok(washedIn !== null && washedIn < 40, `seed ${seed}: ${washedIn}`);
175
+ }
176
+ });
177
+ it("a later tap on bare film is a new color to preview", () => {
178
+ const rng = mulberry32(1);
179
+ let film = tap(emptyFilm(), -0.3, -0.3, rng).film;
180
+ for (let t = 0; t < BURST_S + 1; t += DT)
181
+ film = step(film, DT);
182
+ assert.ok(tap(film, 0.35, 0.35, rng).started);
183
+ });
184
+ it("the topmost splotch under the finger decides", () => {
185
+ const rng = mulberry32(2);
186
+ let film = tap(emptyFilm(), 0, 0, rng).film; // A, beneath
187
+ for (let t = 0; t < BURST_S + 1; t += DT)
188
+ film = step(film, DT);
189
+ const b = tap(film, 0.4, 0.4, rng); // B, elsewhere
190
+ film = b.film;
191
+ film = { ...film, splotches: film.splotches.map((s) => (s.group === b.started.id ? { ...s, x: 0, y: 0 } : s)) };
192
+ for (let t = 0; t < BURST_S + 1; t += DT)
193
+ film = step(film, DT);
194
+ const r = tap(film, 0, 0, rng);
195
+ assert.equal(r.recolored?.id, b.started.id, "B paints over A there, so the tap is B's");
196
+ assert.equal(r.started, null);
197
+ });
198
+ });
199
+ describe("on a splotch recolors it; beside it joins it (claude-u6qlei0j:4, :7)", () => {
200
+ it("a tap on the spot just tapped: the same splotches, another color, no new area or pressure", () => {
201
+ for (const seed of SEEDS) {
202
+ const rng = mulberry32(seed);
203
+ let film = tap(emptyFilm(), 0.1, 0.1, rng).film;
204
+ const id = film.groups[0].id;
205
+ film = step(film, 1);
206
+ const r = tap(film, 0.1, 0.1, rng);
207
+ assert.equal(r.recolored?.id, id, `seed ${seed}`);
208
+ assert.equal(r.started, null);
209
+ assert.equal(r.film.splotches.length, 1);
210
+ assert.equal(r.film.groups[0].pressure, film.groups[0].pressure);
211
+ }
212
+ });
213
+ it("can be repeated at any pace, and the splotch still heals alone", () => {
214
+ for (const seed of SEEDS) {
215
+ const o = run([[0, 0, 0], [0.5, 0, 0], [3, 0, 0], [7, 0, 0]], 150, seed);
216
+ assert.equal(o.washedIn, null, `seed ${seed}`);
217
+ assert.ok(o.healedAt !== null, `seed ${seed}`);
218
+ }
219
+ });
220
+ it("a tap on the edge is beside, not on: a finger meaning more of this lands there too", () => {
221
+ for (const seed of SEEDS) {
222
+ const rng = mulberry32(seed);
223
+ let film = tap(emptyFilm(), 0, 0, rng).film;
224
+ film = step(film, 1);
225
+ const s = film.splotches[0];
226
+ const edge = reach(s, 1, 0);
227
+ const inside = tap(film, edge - TAP_EDGE / 2, 0, rng);
228
+ assert.equal(inside.recolored, null, `seed ${seed}`);
229
+ assert.equal(inside.started, null);
230
+ assert.equal(inside.film.splotches.length, 2, "joined");
231
+ const deeper = tap(film, edge - TAP_EDGE * 2, 0, rng);
232
+ assert.equal(deeper.recolored?.id, s.group, `seed ${seed}: recolored`);
233
+ }
234
+ });
235
+ });
236
+ describe("a wash is closed (claude-u6qlei0j:3)", () => {
237
+ /** Tap, then commit beside it, then step until the wash is under way and big. */
238
+ function midWash(rng) {
239
+ let film = tap(emptyFilm(), 0, 0, rng).film;
240
+ film = step(film, 1);
241
+ const s = film.splotches[0];
242
+ film = tap(film, s.x + reach(s, 1, 0) + 0.04, s.y, rng).film;
243
+ for (let t = 0; t < 60 && !film.groups.some((g) => washing(g, film.clock)); t += DT)
244
+ film = step(film, DT);
245
+ for (let t = 0; t < 3; t += DT)
246
+ film = step(film, DT);
247
+ assert.ok(film.groups.some((g) => washing(g, film.clock)), "washing");
248
+ assert.ok(film.splotches.some((s) => sdAt(s, 0, 0) < -TAP_EDGE), "and the tap point is well inside it");
249
+ return film;
250
+ }
251
+ it("a tap during a wash is a new color over it, not a recolor or a join, and outlives the wash", () => {
252
+ for (const seed of SEEDS) {
253
+ const rng = mulberry32(seed);
254
+ let film = midWash(rng);
255
+ const wash = film.groups.find((g) => washing(g, film.clock));
256
+ const r = tap(film, 0, 0, rng);
257
+ assert.ok(r.started, `seed ${seed}: a new color`);
258
+ assert.equal(r.recolored, null);
259
+ assert.notEqual(r.started.id, wash.id);
260
+ film = r.film;
261
+ let t = 0;
262
+ for (; t < 60 && !covers(film, wash.id, BOX); t += DT)
263
+ film = step(film, DT);
264
+ assert.ok(t < 60, `seed ${seed}: the wash still finishes`);
265
+ assert.ok(film.groups.some((g) => g.id === r.started.id), `seed ${seed}: the new color is still there when it does`);
266
+ film = adopt(film, wash.id);
267
+ assert.ok(film.groups.some((g) => g.id === r.started.id), `seed ${seed}: and after`);
268
+ }
269
+ });
270
+ it("a quick tap after the one that committed does not join the wash either", () => {
271
+ const rng = mulberry32(3);
272
+ const film = midWash(rng);
273
+ // The committing tap was seconds ago, but make sure the burst rule alone
274
+ // would have joined: pretend it was just now.
275
+ const wash = film.groups.find((g) => washing(g, film.clock));
276
+ const recent = { ...film, groups: film.groups.map((g) => (g.id === wash.id ? { ...g, lastTap: film.clock } : g)) };
277
+ assert.ok(tap(recent, 0.4, 0.4, rng).started, "a new group, not the wash's");
278
+ });
279
+ it("the automatic change is closed too", () => {
280
+ const rng = mulberry32(1);
281
+ let film = invade(emptyFilm(), 0, 0, rng).film;
282
+ for (let t = 0; t < 5; t += DT)
283
+ film = step(film, DT);
284
+ assert.ok(sdAt(film.splotches[0], 0, 0) < -TAP_EDGE, "the tap lands inside it");
285
+ const r = tap(film, 0, 0, rng);
286
+ assert.ok(r.started, "a new color over it");
287
+ assert.equal(r.film.groups.length, 2);
288
+ });
289
+ });
290
+ describe("splotches are not circles", () => {
291
+ it("each has its own stretched, lobed outline", () => {
292
+ const rng = mulberry32(4);
293
+ let film = emptyFilm();
294
+ for (let i = 0; i < 6; i++)
295
+ film = tap(film, 0, 0, rng).film;
296
+ const shapes = film.splotches.map((s) => s.shape);
297
+ assert.ok(shapes.every((sh) => sh.aspect >= 1.2 && sh.aspect <= 2.4));
298
+ assert.equal(new Set(shapes.map((sh) => sh.angle)).size, shapes.length, "no two alike");
299
+ // Reach differs by direction: long one way, short the other.
300
+ const s = film.splotches[0];
301
+ const reaches = Array.from({ length: 36 }, (_, k) => reach(s, s.x + Math.cos(k / 36 * 2 * Math.PI), s.y + Math.sin(k / 36 * 2 * Math.PI)));
302
+ assert.ok(Math.max(...reaches) > 1.3 * Math.min(...reaches), `${Math.min(...reaches)}..${Math.max(...reaches)}`);
303
+ });
304
+ it("reach and sdAt agree: the boundary point in any direction is on the outline, risen or not", () => {
305
+ const rng = mulberry32(8);
306
+ const flat = tap(emptyFilm(), 0.1, 0.05, rng).film.splotches[0];
307
+ for (const s of [flat, { ...flat, rise: 2.4 }]) {
308
+ for (let k = 0; k < 24; k++) {
309
+ const a = (k / 24) * 2 * Math.PI, dx = Math.cos(a), dy = Math.sin(a);
310
+ const rr = reach(s, s.x + dx, s.y + dy);
311
+ assert.ok(Math.abs(sdAt(s, s.x + dx * rr, s.y + dy * rr)) < 1e-9);
312
+ }
313
+ }
314
+ });
315
+ it("touching follows the outlines, not the centres' distance alone", () => {
316
+ const rng = mulberry32(2);
317
+ const base = tap(emptyFilm(), 0, 0, rng).film.splotches[0];
318
+ const long = { ...base, r: 0.15, shape: { angle: 0, aspect: 2.4, lobes: [0, 0, 0], phases: [0, 0, 0] } };
319
+ const other = { ...long, phase: 7 };
320
+ // Along the long axis they reach ~0.23 each; across it, ~0.10.
321
+ assert.equal(touching(long, { ...other, x: 0.4, y: 0 }), true);
322
+ assert.equal(touching(long, { ...other, x: 0, y: 0.4 }), false);
323
+ });
324
+ });
325
+ describe("no patchwork", () => {
326
+ it("bursts that prevail one after another each become the film in turn, leaving nothing behind", () => {
327
+ const o = run([[0, -0.2, 0], [0.4, -0.1, 0], [8, 0.2, 0.2], [8.4, 0.3, 0.2]], 200);
328
+ assert.notEqual(o.washedIn, null);
329
+ assert.equal(o.film.splotches.length, 0, "no stain outlives its wash");
330
+ });
331
+ });
332
+ describe("no limits a hand will meet", () => {
333
+ it("a tap always lands, even at the table's limit", () => {
334
+ const rng = mulberry32(1);
335
+ let film = emptyFilm();
336
+ for (let i = 0; i < MAX_SPLOTCHES + 10; i++) {
337
+ film = tap(film, (i % 8) * 0.1 - 0.4, Math.floor(i / 8) * 0.1 - 0.4, rng).film;
338
+ assert.equal(film.splotches.at(-1).x, (i % 8) * 0.1 - 0.4, `tap ${i} landed`);
339
+ // Shrunk, so the next tap on the grid is never on one of these.
340
+ film = { ...film, splotches: film.splotches.map((s) => ({ ...s, r: 0.001 })) };
341
+ }
342
+ assert.equal(film.splotches.length, MAX_SPLOTCHES);
343
+ });
344
+ it("at the limit, the smallest unanchored splotch gives way", () => {
345
+ const rng = mulberry32(1);
346
+ let film = emptyFilm();
347
+ for (let i = 0; i < MAX_SPLOTCHES; i++) {
348
+ film = tap(film, 0, 0, rng).film;
349
+ film = { ...film, splotches: film.splotches.map((s) => ({ ...s, r: 0.001 })) }; // never on the last
350
+ }
351
+ film = { ...film, splotches: film.splotches.map((s, i) => ({ ...s, r: 0.1 + i * 0.001, anchoredAt: i === 0 ? 0.1 : null })) };
352
+ const anchored = film.splotches[0], smallestFree = film.splotches[1];
353
+ film = tap(film, 0.45, -0.45, rng).film; // far from all of them
354
+ assert.ok(film.splotches.some((s) => s.phase === anchored.phase), "the anchored one, though smallest, stays");
355
+ assert.ok(!film.splotches.some((s) => s.phase === smallestFree.phase), "the smallest unanchored one gave way");
356
+ });
357
+ it("taps within BURST_S join one group, one scheme; a later tap starts another", () => {
358
+ const rng = mulberry32(1);
359
+ let r = tap(emptyFilm(), 0, 0, rng);
360
+ let film = step(r.film, 1);
361
+ r = tap(film, 0.1, 0.1, rng);
362
+ assert.equal(r.started, null);
363
+ assert.equal(r.film.groups.length, 1);
364
+ film = r.film;
365
+ for (let t = 0; t < BURST_S + 0.5; t += DT)
366
+ film = step(film, DT);
367
+ assert.ok(tap(film, 0.3, 0.3, rng).started);
368
+ });
369
+ });
370
+ describe("no global discontinuities (claude-1cwe8u9p:81)", () => {
371
+ it("adopting a wash drops the groups beneath it, which it hides, and keeps those above", () => {
372
+ const rng = mulberry32(1);
373
+ let film = tap(emptyFilm(), -0.3, 0, rng).film; // older: beneath
374
+ for (let t = 0; t < BURST_S + 0.5; t += DT)
375
+ film = step(film, DT);
376
+ const r = invade(film, 0, 0, rng); // the wash
377
+ film = r.film;
378
+ for (let t = 0; t < BURST_S + 0.5; t += DT)
379
+ film = step(film, DT);
380
+ film = tap(film, 0.3, 0, rng).film; // newer: above
381
+ const [older, , newer] = film.groups;
382
+ const after = adopt(film, r.started.id);
383
+ assert.deepEqual(after.groups.map((g) => g.id), [newer.id]);
384
+ assert.ok(after.splotches.every((s) => s.group === newer.id));
385
+ assert.ok(!after.groups.some((g) => g.id === older.id));
386
+ });
387
+ it("composites by group, oldest at the bottom, whatever order the splotches came in", () => {
388
+ const rng = mulberry32(2);
389
+ let film = tap(emptyFilm(), 0, 0, rng).film; // group A
390
+ for (let t = 0; t < BURST_S + 0.5; t += DT)
391
+ film = step(film, DT);
392
+ film = tap(film, 0.2, 0, rng).film; // group B
393
+ const [a] = film.groups;
394
+ // A later tap joining the older group A (as a similar-scheme burst might).
395
+ film = { ...film, splotches: [...film.splotches, { ...film.splotches[0], x: 0.4, phase: 99 }] };
396
+ const out = packSplotches(film, (id) => (id === a.id ? 1 : 2));
397
+ const rowsInOrder = [0, 1, 2].map((i) => out[i * SPLOTCH_TEXELS * 4 + 3]);
398
+ assert.deepEqual(rowsInOrder, [1, 1, 2], "both of A's splotches beneath B's");
399
+ });
400
+ it("a wash is only adopted once it covers the glyph by more than its fronts can wander", () => {
401
+ assert.ok(COVER_MARGIN >= 0.16 * 0.4 + 0.012, "dithering plus edge");
402
+ });
403
+ it("the automatic change grows from nothing, with no tap to explain a spot", () => {
404
+ const r = invade(emptyFilm(), 0.1, 0.1, mulberry32(3));
405
+ assert.ok(r.film.splotches[0].r <= 0.01);
406
+ });
407
+ });
408
+ describe("the automatic change", () => {
409
+ it("wins from anywhere in the glyph, in tens of seconds, and then leaves the film", () => {
410
+ for (const [x, y] of [[0, 0], [0.4, 0.4], [-0.4, 0.3]]) {
411
+ const rng = mulberry32(7);
412
+ let film = invade(emptyFilm(), x, y, rng).film;
413
+ const id = film.groups[0].id;
414
+ let t = 0;
415
+ for (; t < 90 && !covers(film, id, BOX); t += DT)
416
+ film = step(film, DT);
417
+ assert.ok(t > 5 && t < 45, `from (${x}, ${y}): ${t.toFixed(1)} s`);
418
+ assert.equal(adopt(film, id).splotches.length, 0);
419
+ }
420
+ });
421
+ it("happens once at a time", () => {
422
+ const rng = mulberry32(1);
423
+ const film = invade(emptyFilm(), 0, 0, rng).film;
424
+ assert.equal(invade(film, 0.2, 0.2, rng), null);
425
+ });
426
+ });
427
+ describe("returning after an absence (claude-xaj5pb9b:18)", () => {
428
+ it("the automatic change is due on the first frame back, however still the film's clock stood", () => {
429
+ // Ten seconds of frames, then the tab is hidden: no frames are drawn, so
430
+ // the film's clock stands at ten while the wall clock runs on past the
431
+ // hold. What the operator sees on return is the invasion planting at once.
432
+ let film = emptyFilm();
433
+ for (let t = 0; t < 10; t += DT)
434
+ film = step(film, DT);
435
+ const holdUntil = 60;
436
+ assert.ok(film.clock < holdUntil);
437
+ assert.ok(!autoDue(film, holdUntil, 10));
438
+ assert.ok(autoDue(film, holdUntil, 95));
439
+ // Planted, it is under way, and no second one is due until it is done.
440
+ const r = invade(film, 0, 0, mulberry32(1));
441
+ assert.ok(r);
442
+ assert.ok(!autoDue(r.film, holdUntil, 95));
443
+ assert.ok(!autoDue(r.film, holdUntil, 1e6));
444
+ });
445
+ });
446
+ describe("frontSpeed", () => {
447
+ it("holds still at critical pressure, advances above it, retreats more slowly below", () => {
448
+ assert.equal(frontSpeed(CRITICAL_PRESSURE), 0);
449
+ assert.equal(frontSpeed(10), MAX_SPEED);
450
+ assert.ok(frontSpeed(0) < 0 && -frontSpeed(0) < MAX_SPEED);
451
+ });
452
+ });
453
+ describe("packSplotches", () => {
454
+ it("lays each splotch out as the shader reads it, padded with zero radius", () => {
455
+ const { film } = tap(emptyFilm(), 0.25, -0.125, mulberry32(3));
456
+ const s = film.splotches[0];
457
+ const out = packSplotches(film, () => 5);
458
+ assert.equal(out.length, MAX_SPLOTCHES * SPLOTCH_TEXELS * 4);
459
+ const f = Math.fround;
460
+ assert.deepEqual([...out.slice(0, 16)], [
461
+ 0.25, -0.125, f(TAP_RADIUS), 5,
462
+ f(s.shape.angle), f(s.shape.aspect), f(s.shape.lobes[0]), f(s.shape.lobes[1]),
463
+ f(s.shape.lobes[2]), f(s.shape.phases[0]), f(s.shape.phases[1]), f(s.shape.phases[2]),
464
+ 1, 0, 0, 0,
465
+ ]);
466
+ assert.ok(out.slice(16).every((v) => v === 0));
467
+ });
468
+ });
469
+ describe("a drag (claude-u6qlei0j:23)", () => {
470
+ it("moves every splotch by the same distance and changes nothing else", () => {
471
+ const rng = mulberry32(1);
472
+ let film = tap(emptyFilm(), 0, 0, rng).film;
473
+ film = step(film, 1);
474
+ film = tap(film, 0.3, -0.2, rng).film;
475
+ const moved = shift(film, 0.1, -0.05);
476
+ assert.deepEqual(moved.splotches.map((s) => [s.x, s.y]), film.splotches.map((s) => [s.x + 0.1, s.y - 0.05]));
477
+ assert.deepEqual(moved.splotches.map(({ x, y, ...rest }) => rest), film.splotches.map(({ x, y, ...rest }) => rest));
478
+ assert.deepEqual({ ...moved, splotches: [] }, { ...film, splotches: [] });
479
+ assert.equal(shift(film, 0, 0), film);
480
+ });
481
+ });
482
+ //# sourceMappingURL=membrane-invasion.test.js.map