char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somnotify a finished turn even while its conversation is open, if the window lost focusc823fd4

main
4.7 KiB133 linesraw
1import { assertEquals } from "@std/assert";
2import { withDom } from "@berry/util/dom.ts";
3import {
4  announcements,
5  type Notice,
6  notificationsAvailable,
7  notificationsOn,
8  setNotifications,
9} from "./notifications.ts";
10
11/** What a page's browser shows. */
12const shown: FakeNotification[] = [];
13
14class FakeNotification {
15  static permission: NotificationPermission = "granted";
16  static requestPermission = () => Promise.resolve("granted" as const);
17  onclick: (() => void) | null = null;
18  constructor(readonly title: string, readonly options: { body?: string; tag?: string }) {
19    shown.push(this);
20  }
21  close() {}
22}
23
24/** A page whose browser grants notifications, keeping what it shows. */
25function withNotifications(test: () => Promise<void> | void) {
26  shown.length = 0;
27  return withDom(() => test(), { globals: { Notification: FakeNotification } });
28}
29
30function setUp(options: { active?: () => string } = {}) {
31  const heard: Notice[] = [];
32  const update = announcements({
33    active: options.active ?? (() => ""),
34    name: (tid) => `conversation ${tid}`,
35    speak: (notice) => heard.push(notice),
36  });
37  return { heard, update };
38}
39
40function hidePage(hidden: boolean) {
41  Object.defineProperty(document, "visibilityState", {
42    configurable: true,
43    get: () => hidden ? "hidden" : "visible",
44  });
45}
46
47function focusPage(focused: boolean) {
48  Object.defineProperty(document, "hasFocus", { configurable: true, value: () => focused });
49}
50
51Deno.test("a conversation that stops answering is announced by name, with its answer", async () => {
52  await withNotifications(async () => {
53    assertEquals(await setNotifications(true), true);
54    const { heard, update } = setUp();
55    update({ a: { busy: true, unread: false } });
56    update({ a: { busy: false, unread: true, reply: "the tests pass" } });
57    assertEquals(heard, [
58      { tid: "a", title: "conversation a", body: "the tests pass" },
59    ]);
60  });
61});
62
63Deno.test("the conversation in view is not announced while someone is looking at it", async () => {
64  await withNotifications(async () => {
65    hidePage(false);
66    focusPage(true);
67    await setNotifications(true);
68    const { heard, update } = setUp({ active: () => "a" });
69    update({ a: { busy: true, unread: false } });
70    update({ a: { busy: false, unread: true, reply: "done" } });
71    assertEquals(heard, []);
72
73    // the window is still on screen, but the reader has moved on to another one
74    focusPage(false);
75    update({ a: { busy: true, unread: false } });
76    update({ a: { busy: false, unread: true, reply: "done" } });
77    assertEquals(heard.map((notice) => notice.tid), ["a"]);
78
79    hidePage(true);
80    focusPage(true);
81    update({ a: { busy: true, unread: false } });
82    update({ a: { busy: false, unread: true, reply: "done" } });
83    assertEquals(heard.map((notice) => notice.tid), ["a", "a"]);
84  });
85});
86
87Deno.test("turns still running, and turns returning control without an answer, are not announced", async () => {
88  await withNotifications(async () => {
89    await setNotifications(true);
90    const { heard, update } = setUp();
91    update({ a: { busy: true, unread: false } });
92    update({ a: { busy: true, unread: false } });
93    // an aborted turn settles with nothing new to read
94    update({ a: { busy: false, unread: false } });
95    update({ b: { busy: false, unread: true, reply: "already here" } });
96    assertEquals(heard, []);
97  });
98});
99
100Deno.test("nothing is announced until the switch is on", async () => {
101  await withNotifications(() => {
102    const { heard, update } = setUp();
103    update({ a: { busy: true, unread: false } });
104    update({ a: { busy: false, unread: true, reply: "done" } });
105    assertEquals(heard, []);
106  });
107});
108
109Deno.test("a page without the API leaves announcements off rather than throwing", async () => {
110  await withDom(async () => {
111    assertEquals(notificationsAvailable(), false);
112    assertEquals(await setNotifications(true), false);
113    assertEquals(notificationsOn(), false);
114    // a flag left on from another page's choice must not reach a missing Notification here
115    localStorage.setItem("berry.notifications", "on");
116    const update = announcements({ active: () => "", name: (tid) => tid });
117    update({ a: { busy: true, unread: false } });
118    update({ a: { busy: false, unread: true, reply: "done" } });
119  });
120});
121
122Deno.test("a clicked notification opens its conversation", async () => {
123  await withNotifications(async () => {
124    await setNotifications(true);
125    const update = announcements({ active: () => "", name: (tid) => tid });
126    update({ a: { busy: true, unread: false } });
127    update({ a: { busy: false, unread: true, reply: "done" } });
128    assertEquals(shown.length, 1);
129    assertEquals(shown[0].options.tag, "a");
130    shown[0].onclick?.();
131    assertEquals(location.hash, "#/a");
132  });
133});