mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

fathom-topics.test.ts

Language TypeScript6.3 KB150 lines

apps/dashboard/test/fathom-topics.test.ts

  1. /**
  2. * Meeting action items, bucketed by topic.
  3. *
  4. * They render in a pop-out box, organised into buckets by topic, with one button
  5. * per topic, rather than as one long list at the bottom of the page.
  6. *
  7. * Two things are worth holding here. The first is that the classifier is
  8. * conservative: Fathom has no topic field, so these buckets are keyword rules a
  9. * person wrote, and a rule that catches the wrong thing is worse than one that
  10. * catches nothing. Uncategorized is honest, a wrong bucket is not. The second
  11. * is the gate: buttons are derived from the viewer's own items, so they cannot
  12. * advertise a meeting they were not in.
  13. */
  14. import { describe, expect, it } from 'vitest'
  15. import {
  16. FATHOM_TOPICS,
  17. UNCATEGORIZED,
  18. bucketByTopic,
  19. topicFor,
  20. topicSlug,
  21. } from '../../../config/fathom-topics.js'
  22. const item = (text: string, meeting = 'Impromptu Zoom Meeting') => ({ text, meeting })
  23. describe('topicFor', () => {
  24. it('reads the item text', () => {
  25. expect(topicFor('upload the logo refs to Drive', '')).toBe('Creative & design')
  26. expect(topicFor('pull the winback numbers', '')).toBe('Outreach & winback')
  27. })
  28. it('falls back to the meeting title when the item says nothing categorical', () => {
  29. // "send it over" in a Design Review is a creative item even though its
  30. // own words carry no signal. This is most of why the title is matched too.
  31. expect(topicFor('send it over', 'Design Review w/ Nora')).toBe('Creative & design')
  32. })
  33. it('MATCHES WHOLE WORDS ONLY', () => {
  34. // The trap this avoids, and the reason matchesFilter refuses substrings
  35. // too: "ai" lives inside email, detail, again, chair; "comp" inside
  36. // complete, company, compare. Substring matching would put half the board
  37. // in the wrong bucket and look like it was working.
  38. expect(topicFor('reply to the email', '')).not.toBe('AI & automation')
  39. expect(topicFor('detail the plan', '')).not.toBe('AI & automation')
  40. expect(topicFor('complete the writeup', '')).not.toBe('People & leadership')
  41. // And still matches when it really is the word.
  42. expect(topicFor('have AI draft it', '')).toBe('AI & automation')
  43. expect(topicFor('ai-generated copy', '')).toBe('AI & automation')
  44. })
  45. it('is case-insensitive', () => {
  46. expect(topicFor('Upload The LOGO', '')).toBe('Creative & design')
  47. })
  48. it('sends anything unmatched to Uncategorized rather than guessing', () => {
  49. // A default that stands in for knowledge has to be visible. This
  50. // bucket IS the report on the keyword map's coverage.
  51. expect(topicFor('circle back with Jordan', 'Impromptu Zoom Meeting')).toBe(UNCATEGORIZED)
  52. })
  53. it('resolves a tie by list order, which is the human decision', () => {
  54. // "logo" (Creative) and "campaign" (Outreach) both match; Creative is
  55. // listed first, so it wins. Moving a topic up changes what its neighbours
  56. // catch: that is why the config is a list and not a map.
  57. const creativeFirst = FATHOM_TOPICS.findIndex((t) => t.label === 'Creative & design')
  58. const outreachSecond = FATHOM_TOPICS.findIndex((t) => t.label === 'Outreach & winback')
  59. expect(creativeFirst).toBeLessThan(outreachSecond)
  60. expect(topicFor('logo for the campaign', '')).toBe('Creative & design')
  61. })
  62. })
  63. describe('bucketByTopic', () => {
  64. const items = [
  65. item('upload the logo'),
  66. item('resize the banner images'),
  67. item('pull the winback numbers'),
  68. item('circle back with Jordan'),
  69. item('ask Claude to draft it'),
  70. ]
  71. it('puts every item in exactly one bucket, so the counts add up', () => {
  72. // A number and the list under it must be able to account for each
  73. // other. The page prints this sum, so it has to be true.
  74. const buckets = bucketByTopic(items)
  75. expect(buckets.reduce((n, b) => n + b.items.length, 0)).toBe(items.length)
  76. const seen = buckets.flatMap((b) => b.items.map((i) => i.text))
  77. expect(new Set(seen).size).toBe(items.length)
  78. })
  79. it('omits topics with nothing in them', () => {
  80. // A button for an empty bucket is a control that does nothing, and on a
  81. // per-person surface it would also advertise topics from meetings this
  82. // viewer was not in.
  83. const buckets = bucketByTopic([item('upload the logo')])
  84. expect(buckets).toHaveLength(1)
  85. expect(buckets[0]!.label).toBe('Creative & design')
  86. })
  87. it('orders buckets as the config does, with Uncategorized last', () => {
  88. const labels = bucketByTopic(items).map((b) => b.label)
  89. expect(labels[labels.length - 1]).toBe(UNCATEGORIZED)
  90. const configOrder = FATHOM_TOPICS.map((t) => t.label)
  91. const positions = labels.slice(0, -1).map((l) => configOrder.indexOf(l))
  92. expect(positions).toEqual([...positions].sort((a, b) => a - b))
  93. })
  94. it('returns nothing for nothing, never an empty Uncategorized bucket', () => {
  95. expect(bucketByTopic([])).toEqual([])
  96. })
  97. it('gives every bucket an attribute-safe slug', () => {
  98. // The slug becomes a data-topic value and an element id. An ampersand or a
  99. // space in either would break the pairing between button and panel.
  100. for (const b of bucketByTopic(items)) {
  101. expect(b.slug).toMatch(/^[a-z0-9-]+$/)
  102. }
  103. expect(topicSlug('Partner: Northgate')).toBe('partner-northgate')
  104. expect(topicSlug('!!!')).toBe('topic')
  105. })
  106. it('keeps distinct labels on distinct slugs', () => {
  107. const slugs = FATHOM_TOPICS.map((t) => topicSlug(t.label))
  108. expect(new Set(slugs).size).toBe(slugs.length)
  109. })
  110. })
  111. describe('the keyword map stays conservative', () => {
  112. it('has no keyword short enough to catch words at random', () => {
  113. // Two-letter keywords are the ones that go wrong even with whole-word
  114. // matching, because they are also initials and abbreviations. "ai" is the
  115. // deliberate exception and is why whole-word matching exists at all.
  116. for (const t of FATHOM_TOPICS) {
  117. for (const k of t.keywords) {
  118. expect(k.length, `${t.label}: "${k}" is too short to be safe`).toBeGreaterThanOrEqual(
  119. k === 'ai' ? 2 : 3,
  120. )
  121. }
  122. }
  123. })
  124. it('lists no keyword twice across topics, which would make order silently matter more', () => {
  125. const seen = new Map<string, string>()
  126. for (const t of FATHOM_TOPICS) {
  127. for (const k of t.keywords) {
  128. expect(seen.has(k), `"${k}" is in both ${seen.get(k)} and ${t.label}`).toBe(false)
  129. seen.set(k, t.label)
  130. }
  131. }
  132. })
  133. })