@company-semantics/contracts 58.0.0 → 58.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/resource-keys.test.ts +30 -0
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +33 -1
- package/src/chat/README.md +15 -4
- package/src/chat/__tests__/proactive-kind.test.ts +51 -0
- package/src/chat/index.ts +9 -0
- package/src/chat/proactive-kind.ts +51 -0
- package/src/chat/schemas.ts +92 -1
- package/src/chat/types.ts +19 -1
- package/src/index.ts +110 -0
- package/src/message-parts/README.md +5 -0
- package/src/message-parts/__tests__/suggested-replies.test.ts +52 -0
- package/src/message-parts/__tests__/wire.test.ts +48 -0
- package/src/message-parts/index.ts +8 -0
- package/src/message-parts/suggested-replies.ts +48 -0
- package/src/message-parts/types.ts +7 -1
- package/src/message-parts/wire.ts +26 -0
- package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +1 -0
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +1 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +207 -0
- package/src/notifications/__tests__/fixtures.ts +9 -0
- package/src/notifications/__tests__/org-invite.test.ts +75 -0
- package/src/notifications/__tests__/render-snapshot.test.ts +8 -0
- package/src/notifications/kinds/org-invite.ts +27 -12
- package/src/notifications/payloads.ts +7 -0
- package/src/org/README.md +38 -0
- package/src/org/__tests__/canonical-facts.test.ts +118 -0
- package/src/org/__tests__/structure-inference.test.ts +392 -0
- package/src/org/__tests__/structure-provenance.test.ts +187 -0
- package/src/org/canonical-facts.ts +94 -1
- package/src/org/index.ts +54 -0
- package/src/org/schemas.ts +23 -0
- package/src/org/structure-inference.ts +521 -0
- package/src/proactive/README.md +125 -0
- package/src/proactive/__tests__/README.md +56 -0
- package/src/proactive/__tests__/chat-templates.test.ts +167 -0
- package/src/proactive/__tests__/compile-fixtures.ts +110 -0
- package/src/proactive/__tests__/vocabulary.test.ts +279 -0
- package/src/proactive/classes.ts +125 -0
- package/src/proactive/composer.ts +104 -0
- package/src/proactive/facts.ts +87 -0
- package/src/proactive/index.ts +52 -0
- package/src/proactive/kinds.ts +127 -0
- package/src/proactive/plan.ts +79 -0
- package/src/proactive/registry.ts +71 -0
- package/src/proactive/surfaces.ts +59 -0
- package/src/proactive/templates/README.md +58 -0
- package/src/proactive/templates/index.ts +32 -0
- package/src/proactive/templates/morning-brief.ts +77 -0
- package/src/proactive/templates/org-became-shared.ts +54 -0
- package/src/resource-key-types.ts +9 -0
- package/src/resource-keys.ts +2 -0
- package/src/user-notifications/README.md +10 -0
- package/src/user-notifications/kinds.ts +28 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# proactive/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Locks the claims `../README.md` and ADR-CONTRACTS-142 make that the compiler
|
|
6
|
+
cannot.
|
|
7
|
+
|
|
8
|
+
- `vocabulary.test.ts` — the load-bearing one is
|
|
9
|
+
`plan surfaces agree with the class table`, the ONE consistency the type
|
|
10
|
+
system cannot express (the ids PRESENT on a kind's plan agree with the
|
|
11
|
+
surfaces `CLASS_SURFACES[plan.class]` DECLARES, and `inboxKind` is always
|
|
12
|
+
there). Also: `CLASS_SURFACES` is total
|
|
13
|
+
over `PROACTIVE_PRESENTATION_CLASSES` and every badge names a surface its
|
|
14
|
+
class has; `bornRead` matches the doctrine table row for row;
|
|
15
|
+
`PROACTIVE_EVENT_KINDS` is total over `PROACTIVE_EVENT_KIND_IDS`; every
|
|
16
|
+
`inboxKind` is a real `USER_NOTIFICATION_KINDS` member in the `proactive.`
|
|
17
|
+
domain and never the kind string itself; every `chatTemplate` a kind names
|
|
18
|
+
reaches a composer; no kind sets `emailKind`; and composers are pure (same
|
|
19
|
+
facts, byte-identical output across two calls).
|
|
20
|
+
- `compile-fixtures.ts` — the negative typecheck fixtures. A COMPILED module,
|
|
21
|
+
not a `*.test.ts`: `src/tsconfig.json` excludes test files and vitest does
|
|
22
|
+
not typecheck, so a `@ts-expect-error` written in a test would never be
|
|
23
|
+
checked. Here `tsc -b` checks each directive, and an unused one (a claim that
|
|
24
|
+
stopped being true) fails `pnpm typecheck` with TS2578. The four claims: a
|
|
25
|
+
`notice` naming a `bannerType`, an `announcement` omitting one, an
|
|
26
|
+
`explained` (and, by the same union member, a `briefing`) naming one, and a
|
|
27
|
+
registry that is not total (a kind with no plan, and a plan for a non-kind). `vocabulary.test.ts` imports the fixtures
|
|
28
|
+
and shows the runtime class-table check rejects the same plans.
|
|
29
|
+
- `chat-templates.test.ts` — the pushed-chat composer registry in practice:
|
|
30
|
+
every template in `PROACTIVE_CHAT_TEMPLATES` has a composer whose `template`
|
|
31
|
+
equals its key (the type proves totality; this proves no entry is an
|
|
32
|
+
accidental `undefined`), the org-became-shared prose NAMES THE RELOCATION and
|
|
33
|
+
every one of the five sections that leave My settings for Org settings, the
|
|
34
|
+
morning brief PERSONALISES THROUGH ITS FACTS (two recipients of one
|
|
35
|
+
occurrence get different prose from one composer), chips stay within
|
|
36
|
+
`PROACTIVE_CHAT_MAX_REPLIES`, and composers emit content — no markup, no
|
|
37
|
+
hrefs.
|
|
38
|
+
|
|
39
|
+
## Invariants
|
|
40
|
+
|
|
41
|
+
- These assert VOCABULARY and SHAPE, never behaviour. Anything needing a
|
|
42
|
+
database, a clock or an authority gate belongs in backend's
|
|
43
|
+
`src/proactive/__tests__/`.
|
|
44
|
+
- Tests iterate the REAL exported arrays and registries rather than restating
|
|
45
|
+
them. A hand-copied list drifts and starts passing vacuously.
|
|
46
|
+
- A composer is exercised through the registry (`PROACTIVE_CHAT_COMPOSERS`),
|
|
47
|
+
not by importing its module directly, so a template that is written but not
|
|
48
|
+
registered fails here rather than in the backend.
|
|
49
|
+
|
|
50
|
+
## Public API
|
|
51
|
+
|
|
52
|
+
None — test-only.
|
|
53
|
+
|
|
54
|
+
## Dependencies
|
|
55
|
+
|
|
56
|
+
`vitest` and the sibling modules under test.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { PROACTIVE_CHAT_MAX_REPLIES } from "../composer";
|
|
3
|
+
import type { ProactiveChatMessage } from "../composer";
|
|
4
|
+
import type { ProactiveChatFacts } from "../facts";
|
|
5
|
+
import { PROACTIVE_CHAT_TEMPLATES } from "../surfaces";
|
|
6
|
+
import type { ProactiveChatTemplate } from "../surfaces";
|
|
7
|
+
import { PROACTIVE_CHAT_COMPOSERS } from "../templates/index";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One representative facts fixture per template. Total over
|
|
11
|
+
* `ProactiveChatTemplate`, so a template added without a fixture fails to
|
|
12
|
+
* compile here — the registry-wide tests below then cover it for free.
|
|
13
|
+
*/
|
|
14
|
+
const FACTS: { readonly [T in ProactiveChatTemplate]: ProactiveChatFacts[T] } =
|
|
15
|
+
{
|
|
16
|
+
orgBecameShared: { orgName: "Acme", joinerDisplayName: "Ada" },
|
|
17
|
+
morningBrief: {
|
|
18
|
+
recipientDisplayName: "Ada",
|
|
19
|
+
orgName: "Acme",
|
|
20
|
+
orgLocalDateLabel: "Tuesday 25 August",
|
|
21
|
+
pendingActionItemCount: 2,
|
|
22
|
+
unreadNotificationCount: 5,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compose a template with its fixture. The registry is a union of composers
|
|
28
|
+
* whose `compose` parameters differ per template, and TypeScript cannot
|
|
29
|
+
* correlate `PROACTIVE_CHAT_COMPOSERS[t]` with `FACTS[t]` through a variable
|
|
30
|
+
* `t` — the cast is the one place the correlation is asserted, and the
|
|
31
|
+
* fixture record above is what keeps it honest.
|
|
32
|
+
*/
|
|
33
|
+
function composeFixture(template: ProactiveChatTemplate): ProactiveChatMessage {
|
|
34
|
+
const compose = PROACTIVE_CHAT_COMPOSERS[template].compose as (
|
|
35
|
+
facts: ProactiveChatFacts[ProactiveChatTemplate],
|
|
36
|
+
) => ProactiveChatMessage;
|
|
37
|
+
return compose(FACTS[template]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The pushed-chat templates: what the composer registry promises in practice
|
|
42
|
+
* and not just in type, and the one editorial claim the org-became-shared
|
|
43
|
+
* prose exists to make.
|
|
44
|
+
*/
|
|
45
|
+
describe("proactive chat templates", () => {
|
|
46
|
+
it("registry is total over PROACTIVE_CHAT_TEMPLATES in practice", () => {
|
|
47
|
+
// The type check proves totality at compile time; this proves no entry is
|
|
48
|
+
// an accidental `undefined` and that every registered composer names the
|
|
49
|
+
// key it sits under — the runtime half of "kind matches registry key".
|
|
50
|
+
for (const template of PROACTIVE_CHAT_TEMPLATES) {
|
|
51
|
+
const composer = PROACTIVE_CHAT_COMPOSERS[template];
|
|
52
|
+
expect(composer, template).toBeDefined();
|
|
53
|
+
expect(composer.template).toBe(template);
|
|
54
|
+
}
|
|
55
|
+
expect(Object.keys(PROACTIVE_CHAT_COMPOSERS).sort()).toEqual(
|
|
56
|
+
[...PROACTIVE_CHAT_TEMPLATES].sort(),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("org became shared template names the relocation", () => {
|
|
61
|
+
const msg = PROACTIVE_CHAT_COMPOSERS.orgBecameShared.compose({
|
|
62
|
+
orgName: "Acme",
|
|
63
|
+
joinerDisplayName: "Ada",
|
|
64
|
+
});
|
|
65
|
+
expect(msg.text).toContain("Org settings");
|
|
66
|
+
expect(msg.text).toContain("relocation");
|
|
67
|
+
expect(msg.replies.length).toBeLessThanOrEqual(PROACTIVE_CHAT_MAX_REPLIES);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("org became shared prose names every relocated section", () => {
|
|
71
|
+
// The five sections the app ADR `nav-posture-opens-on-invite-send` lists
|
|
72
|
+
// as leaving My settings. Naming some and not others is the "things going
|
|
73
|
+
// missing" reading this chat exists to prevent.
|
|
74
|
+
const msg = PROACTIVE_CHAT_COMPOSERS.orgBecameShared.compose({
|
|
75
|
+
orgName: "Acme",
|
|
76
|
+
joinerDisplayName: "Ada",
|
|
77
|
+
});
|
|
78
|
+
for (const section of [
|
|
79
|
+
"Integrations",
|
|
80
|
+
"Invite team members",
|
|
81
|
+
"AI Usage",
|
|
82
|
+
"Budget",
|
|
83
|
+
"Billing",
|
|
84
|
+
"My settings",
|
|
85
|
+
]) {
|
|
86
|
+
expect(msg.text, section).toContain(section);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("org became shared interpolates only the facts it was handed", () => {
|
|
91
|
+
const msg = PROACTIVE_CHAT_COMPOSERS.orgBecameShared.compose({
|
|
92
|
+
orgName: "Acme",
|
|
93
|
+
joinerDisplayName: "Ada",
|
|
94
|
+
});
|
|
95
|
+
expect(msg.title).toBe("Your workspace is now shared");
|
|
96
|
+
expect(msg.text).toContain("Ada accepted your invite");
|
|
97
|
+
expect(msg.text).toContain("Acme");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("chips carry a label and, when they differ, a full prompt", () => {
|
|
101
|
+
const msg = PROACTIVE_CHAT_COMPOSERS.orgBecameShared.compose({
|
|
102
|
+
orgName: "Acme",
|
|
103
|
+
joinerDisplayName: "Ada",
|
|
104
|
+
});
|
|
105
|
+
const ids = msg.replies.map((reply) => reply.id);
|
|
106
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
107
|
+
for (const reply of msg.replies) {
|
|
108
|
+
expect(reply.label.length).toBeGreaterThan(0);
|
|
109
|
+
if (reply.prompt !== undefined) {
|
|
110
|
+
expect(reply.prompt).not.toBe(reply.label);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("morning brief personalises through the facts, not the occurrence", () => {
|
|
116
|
+
// Two people in one org on one morning share an occurrence and receive
|
|
117
|
+
// different prose because they were handed different facts. The composer
|
|
118
|
+
// knows nothing about who it is composing for — the difference is entirely
|
|
119
|
+
// in what it was given.
|
|
120
|
+
const shared = { orgName: "Acme", orgLocalDateLabel: "Tuesday 25 August" };
|
|
121
|
+
const ada = PROACTIVE_CHAT_COMPOSERS.morningBrief.compose({
|
|
122
|
+
...shared,
|
|
123
|
+
recipientDisplayName: "Ada",
|
|
124
|
+
pendingActionItemCount: 1,
|
|
125
|
+
unreadNotificationCount: 3,
|
|
126
|
+
});
|
|
127
|
+
const grace = PROACTIVE_CHAT_COMPOSERS.morningBrief.compose({
|
|
128
|
+
...shared,
|
|
129
|
+
recipientDisplayName: "Grace",
|
|
130
|
+
pendingActionItemCount: 0,
|
|
131
|
+
unreadNotificationCount: 0,
|
|
132
|
+
});
|
|
133
|
+
expect(ada.title).toBe(grace.title);
|
|
134
|
+
expect(ada.text).toContain("Good morning, Ada.");
|
|
135
|
+
expect(ada.text).toContain("1 decision waiting on you");
|
|
136
|
+
expect(ada.text).toContain("3 unread notifications");
|
|
137
|
+
expect(grace.text).toContain("Good morning, Grace.");
|
|
138
|
+
expect(grace.text).toContain("Nothing is waiting on you");
|
|
139
|
+
expect(grace.text).not.toContain("0 ");
|
|
140
|
+
expect(ada.text).not.toBe(grace.text);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("composers emit content, never markup", () => {
|
|
144
|
+
for (const template of PROACTIVE_CHAT_TEMPLATES) {
|
|
145
|
+
const msg = composeFixture(template);
|
|
146
|
+
for (const value of [msg.title, msg.text]) {
|
|
147
|
+
expect(value, template).not.toMatch(/<[a-z!/]/i);
|
|
148
|
+
expect(value, template).not.toMatch(/https?:\/\//);
|
|
149
|
+
}
|
|
150
|
+
for (const reply of msg.replies) {
|
|
151
|
+
expect(reply.label, template).not.toMatch(/<[a-z!/]/i);
|
|
152
|
+
expect(reply.prompt ?? "", template).not.toMatch(/https?:\/\//);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("every composer stays within the chip budget", () => {
|
|
158
|
+
for (const template of PROACTIVE_CHAT_TEMPLATES) {
|
|
159
|
+
const msg = composeFixture(template);
|
|
160
|
+
expect(msg.replies.length, template).toBeLessThanOrEqual(
|
|
161
|
+
PROACTIVE_CHAT_MAX_REPLIES,
|
|
162
|
+
);
|
|
163
|
+
expect(msg.title.length, template).toBeGreaterThan(0);
|
|
164
|
+
expect(msg.text.length, template).toBeGreaterThan(0);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negative typecheck fixtures — the compile-error claims the proactive
|
|
3
|
+
* vocabulary makes (ADR-CONTRACTS-142), each pinned with `@ts-expect-error`.
|
|
4
|
+
*
|
|
5
|
+
* The directive is the proof. If a claim ever stops being true — say the plan
|
|
6
|
+
* union in `../plan` loosens `bannerType?: never` to a plain optional — the
|
|
7
|
+
* directive goes UNUSED and `pnpm typecheck` fails with TS2578. A claim that is
|
|
8
|
+
* only stated in a doc comment cannot fail that way.
|
|
9
|
+
*
|
|
10
|
+
* WHY A COMPILED MODULE AND NOT `vocabulary.test.ts`. `src/tsconfig.json`
|
|
11
|
+
* excludes every `*.test.ts` file and vitest does not typecheck, so a directive
|
|
12
|
+
* written inside a test file is never checked at all: an unused one would prove
|
|
13
|
+
* nothing and fail nothing. This file is not a `*.test.ts`, so `tsc -b`
|
|
14
|
+
* compiles it (the same placement as `../../notifications/__tests__/fixtures.ts`),
|
|
15
|
+
* and `vocabulary.test.ts` imports the fixtures to prove the RUNTIME half of
|
|
16
|
+
* each claim — that the class-table check rejects the same plans the compiler
|
|
17
|
+
* rejects.
|
|
18
|
+
*
|
|
19
|
+
* Each directive sits IMMEDIATELY above the line the compiler reports on: a
|
|
20
|
+
* wrongly-typed or excess property is reported at that property, a missing
|
|
21
|
+
* property at the initializer.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ProactiveEventKind } from "../kinds";
|
|
25
|
+
import type { ProactiveEventDefinition } from "../plan";
|
|
26
|
+
import { PROACTIVE_EVENT_KINDS } from "../registry";
|
|
27
|
+
|
|
28
|
+
/** The registry's declared shape, so the totality fixtures fit on one line. */
|
|
29
|
+
type ProactiveRegistry = Record<ProactiveEventKind, ProactiveEventDefinition>;
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// Plans the class table forbids
|
|
33
|
+
// =============================================================================
|
|
34
|
+
|
|
35
|
+
/** CLAIM 1 — a `notice` has no banner, so naming a `bannerType` is an error. */
|
|
36
|
+
const noticeNamingBanner: ProactiveEventDefinition = {
|
|
37
|
+
class: "notice",
|
|
38
|
+
inboxKind: "proactive.org_became_shared",
|
|
39
|
+
// @ts-expect-error a notice has no banner; its `bannerType` is typed `never`
|
|
40
|
+
bannerType: "first_member_joined",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** CLAIM 2 — an `announcement` claims a banner, so omitting one is an error. */
|
|
44
|
+
// @ts-expect-error an announcement MUST name the banner its class earns
|
|
45
|
+
const announcementOmittingBanner: ProactiveEventDefinition = {
|
|
46
|
+
class: "announcement",
|
|
47
|
+
chatTemplate: "orgBecameShared",
|
|
48
|
+
inboxKind: "proactive.org_became_shared",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** CLAIM 3 — `explained` has no banner either; naming one is an error. */
|
|
52
|
+
const explainedNamingBanner: ProactiveEventDefinition = {
|
|
53
|
+
class: "explained",
|
|
54
|
+
chatTemplate: "orgBecameShared",
|
|
55
|
+
inboxKind: "proactive.org_became_shared",
|
|
56
|
+
// @ts-expect-error explained has no banner; its `bannerType` is typed `never`
|
|
57
|
+
bannerType: "first_member_joined",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* CLAIM 3, for the class the second real kind uses — `briefing` shares the
|
|
62
|
+
* `explained` member, so `brief.morning` could not name a banner even if it
|
|
63
|
+
* tried. This is the compile-time proof that adding it needed no machinery.
|
|
64
|
+
*/
|
|
65
|
+
const briefingNamingBanner: ProactiveEventDefinition = {
|
|
66
|
+
class: "briefing",
|
|
67
|
+
chatTemplate: "morningBrief",
|
|
68
|
+
inboxKind: "proactive.brief_morning",
|
|
69
|
+
// @ts-expect-error a briefing has no banner; its `bannerType` is typed `never`
|
|
70
|
+
bannerType: "first_member_joined",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// =============================================================================
|
|
74
|
+
// Registries that are not total
|
|
75
|
+
// =============================================================================
|
|
76
|
+
|
|
77
|
+
/** CLAIM 4 — a kind with no plan: the registry is not total, so it is an error. */
|
|
78
|
+
// @ts-expect-error a registry missing a kind's plan does not satisfy the Record
|
|
79
|
+
const registryMissingKind = {} as const satisfies ProactiveRegistry;
|
|
80
|
+
|
|
81
|
+
/** CLAIM 4, other direction — a plan for a string that is not a kind. */
|
|
82
|
+
const registryNamingNonKind = {
|
|
83
|
+
...PROACTIVE_EVENT_KINDS,
|
|
84
|
+
// @ts-expect-error a plan for a non-kind is an excess property
|
|
85
|
+
"made.up": PROACTIVE_EVENT_KINDS["org.became_shared"],
|
|
86
|
+
} as const satisfies ProactiveRegistry;
|
|
87
|
+
|
|
88
|
+
// =============================================================================
|
|
89
|
+
// Exports — the runtime half lives in vocabulary.test.ts
|
|
90
|
+
// =============================================================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The plans above, typed as the union they failed to satisfy. At runtime they
|
|
94
|
+
* are ordinary objects, which is what lets the vocabulary test show that the
|
|
95
|
+
* class-table check rejects every one of them.
|
|
96
|
+
*/
|
|
97
|
+
export const PLAN_FIXTURES_THE_COMPILER_REJECTS: Readonly<
|
|
98
|
+
Record<string, ProactiveEventDefinition>
|
|
99
|
+
> = {
|
|
100
|
+
noticeNamingBanner,
|
|
101
|
+
announcementOmittingBanner,
|
|
102
|
+
explainedNamingBanner,
|
|
103
|
+
briefingNamingBanner,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The two ways a registry stops being total. */
|
|
107
|
+
export const REGISTRY_FIXTURES_THE_COMPILER_REJECTS = {
|
|
108
|
+
registryMissingKind,
|
|
109
|
+
registryNamingNonKind,
|
|
110
|
+
} as const;
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proactive vocabulary's invariants, as tests rather than prose
|
|
3
|
+
* (ADR-CONTRACTS-142).
|
|
4
|
+
*
|
|
5
|
+
* THE load-bearing test is `plan surfaces agree with the class table`. A kind
|
|
6
|
+
* carries ids and its surfaces come from its class, and nothing in the type
|
|
7
|
+
* system can check that the ids PRESENT on a plan match the surfaces its class
|
|
8
|
+
* DECLARES in `CLASS_SURFACES`. The union in `../plan` fixes that per member;
|
|
9
|
+
* this file checks the members against the table itself.
|
|
10
|
+
*
|
|
11
|
+
* The negative typecheck fixtures — the `@ts-expect-error` directives proving
|
|
12
|
+
* the four compile-error claims — live in `./compile-fixtures.ts`, a COMPILED
|
|
13
|
+
* module: `src/tsconfig.json` excludes `*.test.ts`, so a directive written here
|
|
14
|
+
* would never be checked, and an unused one would fail nothing. They are
|
|
15
|
+
* imported below so the runtime check is shown to reject exactly the plans the
|
|
16
|
+
* compiler rejects.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect } from "vitest";
|
|
19
|
+
import {
|
|
20
|
+
CLASS_SURFACES,
|
|
21
|
+
PROACTIVE_PRESENTATION_CLASSES,
|
|
22
|
+
bornRead,
|
|
23
|
+
} from "../classes";
|
|
24
|
+
import type { ClassSurfacePlan, ProactivePresentationClass } from "../classes";
|
|
25
|
+
import type { ProactiveChatMessage } from "../composer";
|
|
26
|
+
import type { ProactiveChatFacts } from "../facts";
|
|
27
|
+
import { PROACTIVE_EVENT_KIND_IDS } from "../kinds";
|
|
28
|
+
import type { ProactiveEventDefinition } from "../plan";
|
|
29
|
+
import { PROACTIVE_EVENT_KINDS } from "../registry";
|
|
30
|
+
import { PROACTIVE_CHAT_TEMPLATES } from "../surfaces";
|
|
31
|
+
import type { ProactiveChatTemplate } from "../surfaces";
|
|
32
|
+
import { PROACTIVE_CHAT_COMPOSERS } from "../templates/index";
|
|
33
|
+
import { USER_NOTIFICATION_KINDS } from "../../user-notifications/kinds";
|
|
34
|
+
import {
|
|
35
|
+
PLAN_FIXTURES_THE_COMPILER_REJECTS,
|
|
36
|
+
REGISTRY_FIXTURES_THE_COMPILER_REJECTS,
|
|
37
|
+
} from "./compile-fixtures";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The registry, read as the union it satisfies rather than as its literal
|
|
41
|
+
* shape. Every member of `ProactiveEventDefinition` carries every id field
|
|
42
|
+
* (the absent ones as optional `never`), so a plan can be asked for an id it
|
|
43
|
+
* does not have and answer `undefined` — which is what the checks below need.
|
|
44
|
+
*/
|
|
45
|
+
const REGISTRY_ENTRIES: ReadonlyArray<
|
|
46
|
+
readonly [kind: string, plan: ProactiveEventDefinition]
|
|
47
|
+
> = Object.entries(PROACTIVE_EVENT_KINDS);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The ONE check the compiler cannot do, as a predicate: the ids present on a
|
|
51
|
+
* plan agree with the surfaces its class earns, and the inbox id is always
|
|
52
|
+
* there. Applied positively to the real registry and negatively to the plans
|
|
53
|
+
* the compiler rejects, so the two halves are shown to agree.
|
|
54
|
+
*/
|
|
55
|
+
function planAgreesWithClassTable(plan: ProactiveEventDefinition): boolean {
|
|
56
|
+
const surfaces: ClassSurfacePlan = CLASS_SURFACES[plan.class];
|
|
57
|
+
return (
|
|
58
|
+
(plan.bannerType !== undefined) === surfaces.banner &&
|
|
59
|
+
(plan.chatTemplate !== undefined) === surfaces.chat &&
|
|
60
|
+
plan.inboxKind !== undefined &&
|
|
61
|
+
surfaces.inbox
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One facts factory per template, total over `ProactiveChatTemplate`. A
|
|
67
|
+
* FACTORY rather than a value so the purity test can hand a composer two
|
|
68
|
+
* structurally-equal but distinct objects — a composer that keyed anything on
|
|
69
|
+
* object identity would show up as a difference.
|
|
70
|
+
*/
|
|
71
|
+
const FRESH_FACTS: {
|
|
72
|
+
readonly [T in ProactiveChatTemplate]: () => ProactiveChatFacts[T];
|
|
73
|
+
} = {
|
|
74
|
+
orgBecameShared: () => ({ orgName: "Acme", joinerDisplayName: "Ada" }),
|
|
75
|
+
morningBrief: () => ({
|
|
76
|
+
recipientDisplayName: "Ada",
|
|
77
|
+
orgName: "Acme",
|
|
78
|
+
orgLocalDateLabel: "Tuesday 25 August",
|
|
79
|
+
pendingActionItemCount: 2,
|
|
80
|
+
unreadNotificationCount: 5,
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compose a template with fresh facts. The registry is a union of composers
|
|
86
|
+
* whose `compose` parameters differ per template, and TypeScript cannot
|
|
87
|
+
* correlate `PROACTIVE_CHAT_COMPOSERS[t]` with `FRESH_FACTS[t]` through a
|
|
88
|
+
* variable `t` — the cast is the one place the correlation is asserted, and
|
|
89
|
+
* the total factory record above is what keeps it honest.
|
|
90
|
+
*/
|
|
91
|
+
function composeFresh(template: ProactiveChatTemplate): ProactiveChatMessage {
|
|
92
|
+
const compose = PROACTIVE_CHAT_COMPOSERS[template].compose as (
|
|
93
|
+
facts: ProactiveChatFacts[ProactiveChatTemplate],
|
|
94
|
+
) => ProactiveChatMessage;
|
|
95
|
+
return compose(FRESH_FACTS[template]());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("the class table", () => {
|
|
99
|
+
it("is total over PROACTIVE_PRESENTATION_CLASSES in practice", () => {
|
|
100
|
+
// `satisfies Record<...>` proves totality at compile time; this proves no
|
|
101
|
+
// entry is an accidental `undefined` and that no key is a non-class.
|
|
102
|
+
for (const cls of PROACTIVE_PRESENTATION_CLASSES) {
|
|
103
|
+
expect(CLASS_SURFACES[cls], cls).toBeDefined();
|
|
104
|
+
}
|
|
105
|
+
expect(Object.keys(CLASS_SURFACES).sort()).toEqual(
|
|
106
|
+
[...PROACTIVE_PRESENTATION_CLASSES].sort(),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("every badge names a surface its class actually has", () => {
|
|
111
|
+
for (const cls of PROACTIVE_PRESENTATION_CLASSES) {
|
|
112
|
+
const surfaces: ClassSurfacePlan = CLASS_SURFACES[cls];
|
|
113
|
+
// Every class writes a durable row — `inbox` is literal true throughout.
|
|
114
|
+
expect(surfaces.inbox, `${cls} inbox`).toBe(true);
|
|
115
|
+
// The badge carrier must be a surface the class earns; the union makes
|
|
116
|
+
// the wrong pairing a compile error, and this is the same claim in
|
|
117
|
+
// practice.
|
|
118
|
+
expect(surfaces[surfaces.badge], `${cls} badge=${surfaces.badge}`).toBe(
|
|
119
|
+
true,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("states the born-read rule once, and it matches the doctrine table", () => {
|
|
125
|
+
// The table in `../classes`: the badge carrier is the attention carrier,
|
|
126
|
+
// every other surface the class HAS is the paper trail. Pinned as the
|
|
127
|
+
// concrete rows the doctrine names rather than as a restatement of the
|
|
128
|
+
// helper's body, so a rewrite of `bornRead` that changed a row fails here.
|
|
129
|
+
// A surface the class does not earn has no row to be born into — `chat`
|
|
130
|
+
// is absent for `notice` — so it is not asked.
|
|
131
|
+
const expected: Record<
|
|
132
|
+
ProactivePresentationClass,
|
|
133
|
+
{ readonly chat?: boolean; readonly inbox: boolean }
|
|
134
|
+
> = {
|
|
135
|
+
announcement: { chat: true, inbox: true },
|
|
136
|
+
explained: { chat: false, inbox: true },
|
|
137
|
+
briefing: { chat: false, inbox: true },
|
|
138
|
+
notice: { inbox: false },
|
|
139
|
+
};
|
|
140
|
+
for (const cls of PROACTIVE_PRESENTATION_CLASSES) {
|
|
141
|
+
const surfaces: ClassSurfacePlan = CLASS_SURFACES[cls];
|
|
142
|
+
const row = expected[cls];
|
|
143
|
+
expect(surfaces.chat, `${cls} has a chat`).toBe(row.chat !== undefined);
|
|
144
|
+
if (row.chat !== undefined) {
|
|
145
|
+
expect(bornRead(surfaces, "chat"), `${cls} chat`).toBe(row.chat);
|
|
146
|
+
}
|
|
147
|
+
expect(bornRead(surfaces, "inbox"), `${cls} inbox`).toBe(row.inbox);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("the kind registry", () => {
|
|
153
|
+
it("is total over PROACTIVE_EVENT_KIND_IDS in practice", () => {
|
|
154
|
+
for (const kind of PROACTIVE_EVENT_KIND_IDS) {
|
|
155
|
+
expect(PROACTIVE_EVENT_KINDS[kind], kind).toBeDefined();
|
|
156
|
+
}
|
|
157
|
+
expect(Object.keys(PROACTIVE_EVENT_KINDS).sort()).toEqual(
|
|
158
|
+
[...PROACTIVE_EVENT_KIND_IDS].sort(),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* THE load-bearing test. A kind carries ids; its surfaces come from its
|
|
164
|
+
* class. Nothing in the type system can check that the ids present match the
|
|
165
|
+
* surfaces the class declares, so it is checked here.
|
|
166
|
+
*/
|
|
167
|
+
it("plan surfaces agree with the class table", () => {
|
|
168
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
169
|
+
const surfaces: ClassSurfacePlan = CLASS_SURFACES[plan.class];
|
|
170
|
+
expect(plan.bannerType !== undefined, `${kind} bannerType`).toBe(
|
|
171
|
+
surfaces.banner,
|
|
172
|
+
);
|
|
173
|
+
expect(plan.chatTemplate !== undefined, `${kind} chatTemplate`).toBe(
|
|
174
|
+
surfaces.chat,
|
|
175
|
+
);
|
|
176
|
+
expect(plan.inboxKind, `${kind} inboxKind`).toBeDefined();
|
|
177
|
+
expect(planAgreesWithClassTable(plan), kind).toBe(true);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("every inbox kind is a real user notification kind", () => {
|
|
182
|
+
// Every class writes a durable row, so every plan names a row kind — and
|
|
183
|
+
// it must be one the inbox vocabulary actually has, or the backend writes
|
|
184
|
+
// a row no renderer owns.
|
|
185
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
186
|
+
expect(USER_NOTIFICATION_KINDS, kind).toContain(plan.inboxKind);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("names its inbox row in the proactive domain, never by the kind string", () => {
|
|
191
|
+
// Identical names across two unions is how one quietly becomes derived
|
|
192
|
+
// from the other (ADR-CONT-108). The producing domain is `proactive` —
|
|
193
|
+
// `src/proactive/` in the backend owns the renderer — matching the
|
|
194
|
+
// `companyMd.*` / `comment.*` convention.
|
|
195
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
196
|
+
expect(plan.inboxKind, kind).not.toBe(kind);
|
|
197
|
+
expect(plan.inboxKind, kind).toMatch(/^proactive\./);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("names kinds in {domain}.{type} form, matching the three unions", () => {
|
|
202
|
+
for (const kind of PROACTIVE_EVENT_KIND_IDS) {
|
|
203
|
+
expect(kind).toMatch(/^[a-z][a-zA-Z0-9]*\.[a-z][a-z0-9_]*$/);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("every chat template has a composer", () => {
|
|
208
|
+
// The composer registry is total over PROACTIVE_CHAT_TEMPLATES at compile
|
|
209
|
+
// time; this proves the id a KIND names reaches a real composer whose
|
|
210
|
+
// `template` is that id, so the backend cannot write an empty message.
|
|
211
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
212
|
+
if (plan.chatTemplate === undefined) continue;
|
|
213
|
+
const composer = PROACTIVE_CHAT_COMPOSERS[plan.chatTemplate];
|
|
214
|
+
expect(composer, kind).toBeDefined();
|
|
215
|
+
expect(composer.template, kind).toBe(plan.chatTemplate);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("sets no emailKind in this wave", () => {
|
|
220
|
+
// notify/'s INV-1: every notification needs an explicit trigger; nothing
|
|
221
|
+
// sends autonomously. A kind that also emailed needs INV-PROACTIVE-TRIGGER
|
|
222
|
+
// reconciled with INV-1 in an ADR — lifting this assertion IS that ADR's
|
|
223
|
+
// job, not a tidy-up.
|
|
224
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
225
|
+
expect(plan.emailKind, kind).toBeUndefined();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("the chat composers", () => {
|
|
231
|
+
it("composers are pure", () => {
|
|
232
|
+
// Same facts in, byte-identical message out — across two calls handed two
|
|
233
|
+
// distinct-but-equal facts objects. A clock, an env read, a counter or an
|
|
234
|
+
// identity-keyed cache would all surface as a difference here.
|
|
235
|
+
for (const template of PROACTIVE_CHAT_TEMPLATES) {
|
|
236
|
+
const first = composeFresh(template);
|
|
237
|
+
const second = composeFresh(template);
|
|
238
|
+
expect(JSON.stringify(first), template).toBe(JSON.stringify(second));
|
|
239
|
+
expect(first, template).toEqual(second);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("what the compiler rejects, the class-table check rejects too", () => {
|
|
245
|
+
it("rejects inconsistent plans at compile time", () => {
|
|
246
|
+
// The `@ts-expect-error` directives sit in ./compile-fixtures.ts, where
|
|
247
|
+
// `tsc -b` checks them. Here the same plans go through the runtime check:
|
|
248
|
+
// a notice naming a bannerType, an announcement omitting one, and an
|
|
249
|
+
// explained or a briefing naming one must all disagree with the class
|
|
250
|
+
// table.
|
|
251
|
+
const names = Object.keys(PLAN_FIXTURES_THE_COMPILER_REJECTS);
|
|
252
|
+
expect(names).toEqual([
|
|
253
|
+
"noticeNamingBanner",
|
|
254
|
+
"announcementOmittingBanner",
|
|
255
|
+
"explainedNamingBanner",
|
|
256
|
+
"briefingNamingBanner",
|
|
257
|
+
]);
|
|
258
|
+
for (const [name, plan] of Object.entries(
|
|
259
|
+
PLAN_FIXTURES_THE_COMPILER_REJECTS,
|
|
260
|
+
)) {
|
|
261
|
+
expect(planAgreesWithClassTable(plan), name).toBe(false);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("rejects a registry that is not total at compile time", () => {
|
|
266
|
+
// The fourth claim, both directions: a kind absent from the registry, and
|
|
267
|
+
// a plan for a string that is not a kind. The real registry passes the
|
|
268
|
+
// same two checks in `is total over PROACTIVE_EVENT_KIND_IDS in practice`.
|
|
269
|
+
const { registryMissingKind, registryNamingNonKind } =
|
|
270
|
+
REGISTRY_FIXTURES_THE_COMPILER_REJECTS;
|
|
271
|
+
const ids = new Set<string>(PROACTIVE_EVENT_KIND_IDS);
|
|
272
|
+
expect(
|
|
273
|
+
PROACTIVE_EVENT_KIND_IDS.some((kind) => !(kind in registryMissingKind)),
|
|
274
|
+
).toBe(true);
|
|
275
|
+
expect(
|
|
276
|
+
Object.keys(registryNamingNonKind).some((key) => !ids.has(key)),
|
|
277
|
+
).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
});
|