OverviewFilesCommitsIssuesDocsReleasesPeers

hypercore-kb / hyper-social / hyper-daemon / test / daemons.test.js

423 lines · javascript ref:
import test from 'brittle'
import { crosspost, matchFilter, formatForTarget, stripRoutingTags, PLATFORM_LIMITS } from '../daemons/crosspost.js'
import {
  summarize, extractTopics, extractKeyMessages, extractParticipants,
  getTimeRange, formatSummary, formatDuration, tokenize
} from '../daemons/summarize.js'

// ---------------------------------------------------------------------------
// Crosspost tests
// ---------------------------------------------------------------------------

test('crosspost - match by source platform', (t) => {
  const msg = { source: 'keet', text: 'Hello #public world', author: 'alice' }
  const config = {
    rules: [
      { from: 'keet', to: 'mastodon', filter: { tags: ['public'] } }
    ]
  }

  const actions = crosspost.process(msg, config)
  t.is(actions.length, 1)
  t.is(actions[0].target, 'mastodon')
})

test('crosspost - no match when source differs', (t) => {
  const msg = { source: 'discord', text: 'Hello #public world', author: 'alice' }
  const config = {
    rules: [
      { from: 'keet', to: 'mastodon', filter: { tags: ['public'] } }
    ]
  }

  const actions = crosspost.process(msg, config)
  t.is(actions.length, 0)
})

test('crosspost - match by tag filter', (t) => {
  const msg = { source: 'keet', text: 'Check this out #public', author: 'bob' }
  const result = matchFilter(msg, { tags: ['public'] })
  t.ok(result)
})

test('crosspost - no match when tag absent', (t) => {
  const msg = { source: 'keet', text: 'This is private stuff', author: 'bob' }
  const result = matchFilter(msg, { tags: ['public'] })
  t.absent(result)
})

test('crosspost - match by type filter', (t) => {
  const msg = { source: 'keet', text: 'Image post', type: 'image', author: 'alice' }
  const result = matchFilter(msg, { type: 'image' })
  t.ok(result)
})

test('crosspost - no match when type differs', (t) => {
  const msg = { source: 'keet', text: 'Text post', type: 'text', author: 'alice' }
  const result = matchFilter(msg, { type: 'image' })
  t.absent(result)
})

test('crosspost - match by author filter', (t) => {
  const msg = { source: 'keet', text: 'Hello', author: 'alice' }
  const result = matchFilter(msg, { author: 'alice' })
  t.ok(result)
})

test('crosspost - no match when author differs', (t) => {
  const msg = { source: 'keet', text: 'Hello', author: 'bob' }
  const result = matchFilter(msg, { author: 'alice' })
  t.absent(result)
})

test('crosspost - match by pattern filter', (t) => {
  const msg = { source: 'keet', text: 'Check https://example.com for details', author: 'alice' }
  const result = matchFilter(msg, { pattern: 'https?://\\S+' })
  t.ok(result)
})

test('crosspost - empty filter matches everything', (t) => {
  const msg = { source: 'mastodon', text: 'Anything goes', author: 'carol' }
  const result = matchFilter(msg, {})
  t.ok(result)
})

test('crosspost - format for mastodon trims to 500 chars', (t) => {
  const longText = 'A'.repeat(600)
  const msg = { source: 'keet', text: longText, author: 'alice' }
  const formatted = formatForTarget(msg, 'mastodon')
  t.ok(formatted.length <= PLATFORM_LIMITS.mastodon)
  t.ok(formatted.includes('...'))
  t.ok(formatted.includes('[via keet/alice]'))
})

test('crosspost - format for mastodon with short text', (t) => {
  const msg = { source: 'keet', text: 'Short post', author: 'alice' }
  const formatted = formatForTarget(msg, 'mastodon')
  t.ok(formatted.length <= PLATFORM_LIMITS.mastodon)
  t.ok(formatted.includes('[via keet/alice]'))
  t.ok(formatted.includes('Short post'))
})

test('crosspost - format for keet adds attribution', (t) => {
  const msg = { source: 'mastodon', text: 'Hello from the fediverse', author: 'bob' }
  const formatted = formatForTarget(msg, 'keet')
  t.ok(formatted.includes('[via mastodon/bob]'))
  t.ok(formatted.includes('Hello from the fediverse'))
})

test('crosspost - format for irc trims and uses angle brackets', (t) => {
  const msg = { source: 'keet', text: 'Hello IRC', author: 'alice' }
  const formatted = formatForTarget(msg, 'irc')
  t.ok(formatted.includes('<alice@keet>'))
  t.ok(formatted.includes('Hello IRC'))
})

test('crosspost - format for discord uses markdown bold', (t) => {
  const msg = { source: 'keet', text: 'Hey discord', author: 'alice' }
  const formatted = formatForTarget(msg, 'discord')
  t.ok(formatted.includes('**alice**'))
  t.ok(formatted.includes('Hey discord'))
})

test('crosspost - format for matrix uses brackets', (t) => {
  const msg = { source: 'keet', text: 'Hello matrix', author: 'alice' }
  const formatted = formatForTarget(msg, 'matrix')
  t.ok(formatted.includes('[keet]'))
  t.ok(formatted.includes('<alice>'))
})

test('crosspost - multiple rules produce multiple actions', (t) => {
  const msg = { source: 'keet', text: 'Broadcast #public', author: 'alice' }
  const config = {
    rules: [
      { from: 'keet', to: 'mastodon', filter: { tags: ['public'] } },
      { from: 'keet', to: 'discord', filter: { tags: ['public'] } },
      { from: 'keet', to: 'matrix', filter: { tags: ['public'] } }
    ]
  }

  const actions = crosspost.process(msg, config)
  t.is(actions.length, 3)
  t.is(actions[0].target, 'mastodon')
  t.is(actions[1].target, 'discord')
  t.is(actions[2].target, 'matrix')
})

test('crosspost - empty rules produce no actions', (t) => {
  const msg = { source: 'keet', text: 'Hello', author: 'alice' }
  const config = { rules: [] }

  const actions = crosspost.process(msg, config)
  t.is(actions.length, 0)
})

test('crosspost - metadata includes crosspost info', (t) => {
  const msg = { source: 'keet', text: 'Cross me #public', author: 'alice', timestamp: 12345 }
  const config = {
    rules: [
      { from: 'keet', to: 'mastodon', filter: { tags: ['public'] } }
    ]
  }

  const actions = crosspost.process(msg, config)
  t.is(actions[0].metadata.crosspostedFrom, 'keet')
  t.is(actions[0].metadata.originalAuthor, 'alice')
  t.is(actions[0].metadata.originalTimestamp, 12345)
  t.alike(actions[0].metadata.rule, { from: 'keet', to: 'mastodon' })
})

test('crosspost - stripRoutingTags removes #public from output', (t) => {
  const result = stripRoutingTags('Hello #public world', ['public'])
  t.is(result, 'Hello world')
})

test('crosspost - routing tags stripped in formatted output', (t) => {
  const msg = { source: 'keet', text: 'Important #public announcement', author: 'alice' }
  const rule = { from: 'keet', to: 'keet', filter: { tags: ['public'] } }
  const formatted = formatForTarget(msg, 'keet', rule)
  t.absent(formatted.includes('#public'))
  t.ok(formatted.includes('Important'))
  t.ok(formatted.includes('announcement'))
})

test('crosspost - contains filter works', (t) => {
  const msg = { source: 'keet', text: 'secret word: banana', author: 'alice' }
  t.ok(matchFilter(msg, { contains: 'banana' }))
  t.absent(matchFilter(msg, { contains: 'orange' }))
})

// ---------------------------------------------------------------------------
// Summarize tests
// ---------------------------------------------------------------------------

function makeMessages (count, opts = {}) {
  const messages = []
  const authors = opts.authors || ['alice', 'bob', 'carol']
  const baseTime = opts.baseTime || 1700000000000

  for (let i = 0; i < count; i++) {
    messages.push({
      text: opts.textFn ? opts.textFn(i) : `Message number ${i} about hypercore and distributed systems`,
      author: authors[i % authors.length],
      timestamp: baseTime + (i * 60000), // 1 min apart
      type: 'text'
    })
  }
  return messages
}

test('summarize - extract topics from messages', (t) => {
  const messages = [
    { text: 'hypercore is great for distributed data', author: 'alice' },
    { text: 'I love hypercore and hyperbee', author: 'bob' },
    { text: 'hypercore makes P2P easy', author: 'carol' },
    { text: 'hyperbee is built on hypercore', author: 'alice' },
    { text: 'distributed systems with hypercore', author: 'bob' }
  ]

  const topics = extractTopics(messages, { minCount: 2 })
  t.ok(topics.length > 0)

  // hypercore should be the top topic (appears in all 5)
  t.is(topics[0].word, 'hypercore')
  t.is(topics[0].count, 5)
})

test('summarize - extract key messages with questions', (t) => {
  const messages = [
    { text: 'Hello', author: 'alice' },
    { text: 'How does hypercore replication work?', author: 'bob' },
    { text: 'ok', author: 'carol' },
    { text: 'Check https://docs.holepunch.to for details', author: 'alice' },
    { text: 'cool', author: 'bob' }
  ]

  const keyMsgs = extractKeyMessages(messages)
  t.ok(keyMsgs.length >= 2)

  // The question and the link should be picked up
  const texts = keyMsgs.map(m => m.text)
  t.ok(texts.some(t => t.includes('?')))
  t.ok(texts.some(t => t.includes('https://')))
})

test('summarize - extract key messages with links', (t) => {
  const messages = [
    { text: 'hi', author: 'alice' },
    { text: 'See https://example.com/docs', author: 'bob' },
    { text: 'thanks', author: 'carol' }
  ]

  const keyMsgs = extractKeyMessages(messages)
  t.ok(keyMsgs.length >= 1)
  t.ok(keyMsgs[0].text.includes('https://'))
})

test('summarize - participant list sorted by message count', (t) => {
  const messages = [
    { text: 'a', author: 'alice' },
    { text: 'b', author: 'alice' },
    { text: 'c', author: 'alice' },
    { text: 'd', author: 'bob' },
    { text: 'e', author: 'carol' }
  ]

  const participants = extractParticipants(messages)
  t.is(participants.length, 3)
  t.is(participants[0].name, 'alice')
  t.is(participants[0].count, 3)
  t.is(participants[1].name, 'bob')
  t.is(participants[1].count, 1)
})

test('summarize - time range calculation', (t) => {
  const messages = [
    { text: 'first', author: 'alice', timestamp: 1000 },
    { text: 'second', author: 'bob', timestamp: 2000 },
    { text: 'third', author: 'carol', timestamp: 5000 }
  ]

  const range = getTimeRange(messages)
  t.is(range.start, 1000)
  t.is(range.end, 5000)
  t.is(range.durationMs, 4000)
})

test('summarize - time range with no timestamps', (t) => {
  const messages = [
    { text: 'first', author: 'alice' },
    { text: 'second', author: 'bob' }
  ]

  const range = getTimeRange(messages)
  t.is(range.start, 0)
  t.is(range.end, 0)
  t.is(range.durationMs, 0)
})

test('summarize - summary format includes all sections', (t) => {
  const messages = makeMessages(20, {
    textFn: (i) => {
      if (i === 5) return 'How does hypercore replication work?'
      if (i === 10) return 'Check https://docs.holepunch.to for info'
      return `Discussing hypercore distributed database systems for message ${i}`
    }
  })

  const result = summarize.process(messages)
  t.ok(result.summary.includes('## Conversation Summary'))
  t.ok(result.summary.includes('### Participants'))
  t.ok(result.summary.includes('### Key Messages'))
  t.is(result.messageCount, 20)
  t.ok(result.participants.length > 0)
})

test('summarize - minimum message threshold', (t) => {
  const messages = makeMessages(5) // below default minMessages of 10

  const result = summarize.process(messages)
  t.is(result.summary, '')
  t.ok(result.belowThreshold)
  t.is(result.messageCount, 5)
  // Participants still extracted even below threshold
  t.ok(result.participants.length > 0)
})

test('summarize - empty input handling', (t) => {
  const result = summarize.process([])
  t.is(result.summary, '')
  t.is(result.messageCount, 0)
  t.is(result.participants.length, 0)
  t.is(result.topics.length, 0)
  t.is(result.keyMessages.length, 0)
})

test('summarize - null input handling', (t) => {
  const result = summarize.process(null)
  t.is(result.summary, '')
  t.is(result.messageCount, 0)
})

test('summarize - window size limits messages', (t) => {
  const messages = makeMessages(100)

  const result = summarize.process(messages, { windowSize: 20 })
  t.is(result.messageCount, 20)
})

test('summarize - tokenize strips stop words', (t) => {
  const tokens = tokenize('the quick brown fox jumps over the lazy dog')
  t.absent(tokens.includes('the'))
  t.absent(tokens.includes('over'))
  t.ok(tokens.includes('quick'))
  t.ok(tokens.includes('brown'))
  t.ok(tokens.includes('fox'))
})

test('summarize - tokenize strips URLs', (t) => {
  const tokens = tokenize('check https://example.com for details')
  t.absent(tokens.some(t => t.includes('https')))
  t.absent(tokens.some(t => t.includes('example')))
})

test('summarize - formatDuration readable output', (t) => {
  t.is(formatDuration(30000), 'less than a minute')
  t.is(formatDuration(60000), '1 minute')
  t.is(formatDuration(120000), '2 minutes')
  t.is(formatDuration(3600000), '1 hour')
  t.is(formatDuration(7200000), '2 hours')
  t.is(formatDuration(90000000), '1 day')
})

test('summarize - messages with various content types', (t) => {
  const messages = makeMessages(15, {
    textFn: (i) => {
      switch (i % 5) {
        case 0: return 'Is hypercore better than IPFS?'
        case 1: return 'Read https://docs.holepunch.to/hypercore'
        case 2: return 'The `hypercore.append()` method is key'
        case 3: return 'I think hypercore is amazing!'
        case 4: return 'ok'
      }
    }
  })

  const keyMsgs = extractKeyMessages(messages)
  t.ok(keyMsgs.length > 0)

  // Questions should be highest priority (score 3)
  t.ok(keyMsgs[0].text.includes('?'))
})

test('summarize - topics minCount threshold', (t) => {
  const messages = [
    { text: 'hypercore is cool', author: 'alice' },
    { text: 'hypercore rocks', author: 'bob' },
    { text: 'randomword', author: 'carol' }
  ]

  // minCount=3 means hypercore (2 occurrences) won't pass
  const topics = extractTopics(messages, { minCount: 3 })
  t.is(topics.length, 0)

  // minCount=2 means hypercore passes
  const topics2 = extractTopics(messages, { minCount: 2 })
  t.ok(topics2.length > 0)
  t.is(topics2[0].word, 'hypercore')
})

test('summarize - config override', (t) => {
  const messages = makeMessages(20)

  const result = summarize.process(messages, {
    includeParticipants: false,
    includeTopics: false
  })

  t.is(result.participants.length, 0)
  t.is(result.topics.length, 0)
  // Key messages still extracted
  t.ok(result.messageCount > 0)
})