fathom-topics.test.ts
Language TypeScript6.3 KB150 lines
/*** Meeting action items, bucketed by topic.** They render in a pop-out box, organised into buckets by topic, with one button* per topic, rather than as one long list at the bottom of the page.** Two things are worth holding here. The first is that the classifier is* conservative: Fathom has no topic field, so these buckets are keyword rules a* person wrote, and a rule that catches the wrong thing is worse than one that* catches nothing. Uncategorized is honest, a wrong bucket is not. The second* is the gate: buttons are derived from the viewer's own items, so they cannot* advertise a meeting they were not in.*/import { describe, expect, it } from 'vitest'import {FATHOM_TOPICS,UNCATEGORIZED,bucketByTopic,topicFor,topicSlug,} from '../../../config/fathom-topics.js'const item = (text: string, meeting = 'Impromptu Zoom Meeting') => ({ text, meeting })describe('topicFor', () => {it('reads the item text', () => {expect(topicFor('upload the logo refs to Drive', '')).toBe('Creative & design')expect(topicFor('pull the winback numbers', '')).toBe('Outreach & winback')})it('falls back to the meeting title when the item says nothing categorical', () => {// "send it over" in a Design Review is a creative item even though its// own words carry no signal. This is most of why the title is matched too.expect(topicFor('send it over', 'Design Review w/ Nora')).toBe('Creative & design')})it('MATCHES WHOLE WORDS ONLY', () => {// The trap this avoids, and the reason matchesFilter refuses substrings// too: "ai" lives inside email, detail, again, chair; "comp" inside// complete, company, compare. Substring matching would put half the board// in the wrong bucket and look like it was working.expect(topicFor('reply to the email', '')).not.toBe('AI & automation')expect(topicFor('detail the plan', '')).not.toBe('AI & automation')expect(topicFor('complete the writeup', '')).not.toBe('People & leadership')// And still matches when it really is the word.expect(topicFor('have AI draft it', '')).toBe('AI & automation')expect(topicFor('ai-generated copy', '')).toBe('AI & automation')})it('is case-insensitive', () => {expect(topicFor('Upload The LOGO', '')).toBe('Creative & design')})it('sends anything unmatched to Uncategorized rather than guessing', () => {// A default that stands in for knowledge has to be visible. This// bucket IS the report on the keyword map's coverage.expect(topicFor('circle back with Jordan', 'Impromptu Zoom Meeting')).toBe(UNCATEGORIZED)})it('resolves a tie by list order, which is the human decision', () => {// "logo" (Creative) and "campaign" (Outreach) both match; Creative is// listed first, so it wins. Moving a topic up changes what its neighbours// catch: that is why the config is a list and not a map.const creativeFirst = FATHOM_TOPICS.findIndex((t) => t.label === 'Creative & design')const outreachSecond = FATHOM_TOPICS.findIndex((t) => t.label === 'Outreach & winback')expect(creativeFirst).toBeLessThan(outreachSecond)expect(topicFor('logo for the campaign', '')).toBe('Creative & design')})})describe('bucketByTopic', () => {const items = [item('upload the logo'),item('resize the banner images'),item('pull the winback numbers'),item('circle back with Jordan'),item('ask Claude to draft it'),]it('puts every item in exactly one bucket, so the counts add up', () => {// A number and the list under it must be able to account for each// other. The page prints this sum, so it has to be true.const buckets = bucketByTopic(items)expect(buckets.reduce((n, b) => n + b.items.length, 0)).toBe(items.length)const seen = buckets.flatMap((b) => b.items.map((i) => i.text))expect(new Set(seen).size).toBe(items.length)})it('omits topics with nothing in them', () => {// A button for an empty bucket is a control that does nothing, and on a// per-person surface it would also advertise topics from meetings this// viewer was not in.const buckets = bucketByTopic([item('upload the logo')])expect(buckets).toHaveLength(1)expect(buckets[0]!.label).toBe('Creative & design')})it('orders buckets as the config does, with Uncategorized last', () => {const labels = bucketByTopic(items).map((b) => b.label)expect(labels[labels.length - 1]).toBe(UNCATEGORIZED)const configOrder = FATHOM_TOPICS.map((t) => t.label)const positions = labels.slice(0, -1).map((l) => configOrder.indexOf(l))expect(positions).toEqual([...positions].sort((a, b) => a - b))})it('returns nothing for nothing, never an empty Uncategorized bucket', () => {expect(bucketByTopic([])).toEqual([])})it('gives every bucket an attribute-safe slug', () => {// The slug becomes a data-topic value and an element id. An ampersand or a// space in either would break the pairing between button and panel.for (const b of bucketByTopic(items)) {expect(b.slug).toMatch(/^[a-z0-9-]+$/)}expect(topicSlug('Partner: Northgate')).toBe('partner-northgate')expect(topicSlug('!!!')).toBe('topic')})it('keeps distinct labels on distinct slugs', () => {const slugs = FATHOM_TOPICS.map((t) => topicSlug(t.label))expect(new Set(slugs).size).toBe(slugs.length)})})describe('the keyword map stays conservative', () => {it('has no keyword short enough to catch words at random', () => {// Two-letter keywords are the ones that go wrong even with whole-word// matching, because they are also initials and abbreviations. "ai" is the// deliberate exception and is why whole-word matching exists at all.for (const t of FATHOM_TOPICS) {for (const k of t.keywords) {expect(k.length, `${t.label}: "${k}" is too short to be safe`).toBeGreaterThanOrEqual(k === 'ai' ? 2 : 3,)}}})it('lists no keyword twice across topics, which would make order silently matter more', () => {const seen = new Map<string, string>()for (const t of FATHOM_TOPICS) {for (const k of t.keywords) {expect(seen.has(k), `"${k}" is in both ${seen.get(k)} and ${t.label}`).toBe(false)seen.set(k, t.label)}}})})