CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m6s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m59s
Build images / promote (push) Skipped
CI / integration (push) Failing after 2m7s
A synthetic post is no longer sealed at creation. A creator who adds two more variants the next day extends the existing post, its body grows with the new messages, and no rival post appears. That is what makes chat capture read as content trickling in rather than as a stream of separate arrivals. The sweep now runs two passes per source and the ORDER is load-bearing: offer new messages to still-open groups BEFORE founding new ones, because whichever runs first claims a message. E3's three named problems, each answered rather than discovered later: **Bridging.** A candidate near two groups joins NEITHER. Nearest-wins would silently make an arbitrary choice between two posts the operator may already have seen; merging them is worse still, because a merge rewrites history and anything pointing at the absorbed post dangles. Leaving it to found its own group is the recoverable failure. AMBIGUITY_MARGIN is a module constant and deliberately not a setting — it is not a quality dial anyone would tune toward a better feed, and exposing it would invite turning it to zero, which is exactly the silent arbitrary choice it prevents. **Re-surfacing without thrashing.** A grouping has two dates, and which one orders the feed is a real decision, so the feed orders by neither directly. Ordering by when the drop STARTED buries a group that grows a week later under a week of other posts — defeating the point of keeping it open. Ordering by every growth lets a group gaining one image a day live permanently at the top, so chat out-competes authored posts for the front page — the opposite of "post pacing stays front and centre". Instead `resurfaced_at` moves only when growth clears BOTH a minimum-images bar and a cooldown, so a drip-feed updates in place and a genuine second wave resurfaces exactly once. It is NULL on every ordinary post, so the sort key COALESCEs through it without moving anything that is not a grouping. **Reopening forever.** Groups close after a quiet period — artists reuse characters for years, and a group left open indefinitely will eventually absorb something it shouldn't. Openness is DERIVED, not stored: a group is open if it grew (or started) within the window. Lowering the setting closes old groups and raising it reopens them, with nothing to repair either way; a stored closed_at would have needed a sweep to set it and a repair path to ever change the policy. Rule 89 is satisfied structurally rather than by a parallel mechanism: celery_signals writes a TaskRun for every task, which already supplies duration, the 5-minute stalled-run recovery, and retention pruning. What this step owed on top of that was a wall-clock limit (present) and idempotence — re-running the joiner adds nothing, asserted directly rather than left to the unique (image, post) constraint to catch. Two bugs fixed in the writing, one of which my own test would have hit: * `assign_to_group` sorted bare (distance, Post) tuples, which falls through to comparing Posts when two distances tie — and a perfectly symmetric bridge, the exact case the function exists for, would have raised TypeError instead of declining to choose. Now keyed on the distance alone. * The cursor was still built from `post_date or downloaded_at` while the ORDER BY had gained `resurfaced_at`. Two expressions that disagree at a page boundary don't error, they silently skip or repeat rows; both sites now go through one `_post_sort_value`, and a test pages through one row at a time to prove the walk matches the whole list. Image linking is now one shared helper rather than written twice, because creation and joining would otherwise be free to drift on exactly the detail (which post owns the image) that makes a grouping reversible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
170 lines
7.1 KiB
JavaScript
170 lines
7.1 KiB
JavaScript
// @vitest-environment happy-dom
|
|
import { describe, it, expect, vi } from 'vitest'
|
|
import { flushPromises } from '@vue/test-utils'
|
|
|
|
import PostCard from '../../src/components/posts/PostCard.vue'
|
|
import { useModalStore } from '../../src/stores/modal.js'
|
|
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
|
|
|
const now = new Date().toISOString()
|
|
const BASE = {
|
|
id: 1, external_post_id: 'P1',
|
|
post_title: '<strong>Hello World</strong>',
|
|
post_url: 'https://x/1', post_date: now, downloaded_at: now,
|
|
artist: { id: 1, name: 'Sabu', slug: 'sabu' },
|
|
source: { id: 1, platform: 'subscribestar' },
|
|
thumbnails: [], thumbnails_more: 0, attachments: [],
|
|
}
|
|
|
|
describe('PostCard', () => {
|
|
it('renders the HTML-stripped title and the artist', () => {
|
|
const post = { ...BASE, description_plain: 'a description' }
|
|
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
|
|
const t = w.text()
|
|
expect(t).toContain('Hello World') // plain title
|
|
expect(t).not.toContain('<strong>') // tags stripped
|
|
expect(t).toContain('Sabu') // artist (RouterLink slot)
|
|
})
|
|
|
|
it('shows a "Show more" toggle only when the description is truncated', () => {
|
|
const truncated = mountComponent(PostCard, {
|
|
props: { post: { ...BASE, description_plain: 'lots of text…', description_truncated: true } },
|
|
pinia: freshPinia(),
|
|
})
|
|
expect(truncated.text()).toContain('Show more')
|
|
|
|
const full = mountComponent(PostCard, {
|
|
props: { post: { ...BASE, description_plain: 'short', description_truncated: false } },
|
|
pinia: freshPinia(),
|
|
})
|
|
expect(full.text()).not.toContain('Show more')
|
|
})
|
|
|
|
// #388 E2. The honesty marker is the only thing standing between "FC
|
|
// assembled this" and the card reading as something the artist authored, so
|
|
// these pin BOTH directions: it appears when the flag is set, and — the one
|
|
// that actually matters — it is absent on every ordinary post.
|
|
describe('synthetic posts', () => {
|
|
const SYNTH = {
|
|
...BASE,
|
|
post_title: null,
|
|
synthesized_by: 'discord_drop',
|
|
synthesis: { message_count: 4, member_post_ids: [1, 2, 3, 4] },
|
|
}
|
|
|
|
it('says FC grouped it, and what from', () => {
|
|
const w = mountComponent(PostCard, { props: { post: SYNTH }, pinia: freshPinia() })
|
|
expect(w.find('.fc-post-card__synthetic').exists()).toBe(true)
|
|
expect(w.text()).toContain('grouped by FabledCurator')
|
|
expect(w.text()).toContain('Grouped from 4 Discord messages')
|
|
})
|
|
|
|
it('never marks an ordinary post', () => {
|
|
const w = mountComponent(PostCard, { props: { post: BASE }, pinia: freshPinia() })
|
|
expect(w.find('.fc-post-card__synthetic').exists()).toBe(false)
|
|
expect(w.text()).not.toContain('grouped by FabledCurator')
|
|
})
|
|
|
|
it('does not leak the internal drop key as a title', () => {
|
|
// post_title is NULL on purpose (inventing one would put words in a
|
|
// creator's mouth), so the untitled fallback must not print
|
|
// `fc-drop:<message id>` the way it does for a real untitled post.
|
|
const w = mountComponent(PostCard, {
|
|
props: { post: { ...SYNTH, external_post_id: 'fc-drop:99887766' } },
|
|
pinia: freshPinia(),
|
|
})
|
|
expect(w.text()).not.toContain('fc-drop:')
|
|
})
|
|
|
|
it('degrades to unmarked when the field is absent entirely', () => {
|
|
// A post dict composed before the field existed must read as "not
|
|
// synthetic" rather than throw on `synthesis.message_count`.
|
|
const { synthesized_by: _drop, synthesis: _also, ...legacy } = SYNTH
|
|
const w = mountComponent(PostCard, { props: { post: legacy }, pinia: freshPinia() })
|
|
expect(w.find('.fc-post-card__synthetic').exists()).toBe(false)
|
|
})
|
|
|
|
it('reports growth separately from the post date', () => {
|
|
// The drop's own date is its identity; growth is news about it. "From
|
|
// Tuesday, gained images this morning" is the trickling-in signal, and
|
|
// collapsing the two would erase it.
|
|
const w = mountComponent(PostCard, {
|
|
props: {
|
|
post: { ...SYNTH, last_grew_at: new Date(Date.now() - 3600e3).toISOString() },
|
|
},
|
|
pinia: freshPinia(),
|
|
})
|
|
expect(w.text()).toContain('updated 1h ago')
|
|
})
|
|
|
|
it('says nothing about growth on a grouping that has not grown', () => {
|
|
// An "updated" label that is always there teaches you to ignore it.
|
|
const w = mountComponent(PostCard, {
|
|
props: { post: { ...SYNTH, last_grew_at: null } },
|
|
pinia: freshPinia(),
|
|
})
|
|
expect(w.text()).not.toContain('updated')
|
|
})
|
|
|
|
it('never claims an ordinary post grew, even if the field leaks in', () => {
|
|
const w = mountComponent(PostCard, {
|
|
props: { post: { ...BASE, last_grew_at: new Date().toISOString() } },
|
|
pinia: freshPinia(),
|
|
})
|
|
expect(w.text()).not.toContain('updated')
|
|
})
|
|
|
|
it('singularises a one-message drop', () => {
|
|
const w = mountComponent(PostCard, {
|
|
props: { post: { ...SYNTH, synthesis: { message_count: 1 } } },
|
|
pinia: freshPinia(),
|
|
})
|
|
expect(w.text()).toContain('Grouped from 1 Discord message')
|
|
expect(w.text()).not.toContain('1 Discord messages')
|
|
})
|
|
})
|
|
|
|
const thumbs = (n) =>
|
|
Array.from({ length: n }, (_, i) => ({ image_id: 100 + i, thumbnail_url: `/t${i}` }))
|
|
|
|
it('fills the rail to full width (5 cells, no overflow) for a 6-image post', () => {
|
|
const post = { ...BASE, description_plain: 'd', thumbnails: thumbs(6), thumbnails_more: 0 }
|
|
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
|
|
// hero + 5 rail thumbnails, no "+N" tile.
|
|
expect(w.findAll('.fc-post-card__rail-cell')).toHaveLength(5)
|
|
expect(w.find('.fc-post-card__rail-more').exists()).toBe(false)
|
|
// The grid spans the hero width via a column count == cells shown.
|
|
expect(w.find('.fc-post-card__rail').attributes('style')).toContain('--fc-rail-cols: 5')
|
|
})
|
|
|
|
it('reserves the last cell for a "+N" overflow tile when there are more images', () => {
|
|
const post = { ...BASE, description_plain: 'd', thumbnails: thumbs(6), thumbnails_more: 4 }
|
|
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
|
|
// 4 thumbnails + 1 overflow tile == 5 columns; tile counts every hidden image.
|
|
expect(w.findAll('.fc-post-card__rail-cell')).toHaveLength(4)
|
|
const more = w.find('.fc-post-card__rail-more')
|
|
expect(more.exists()).toBe(true)
|
|
expect(more.text()).toBe('+5')
|
|
expect(w.find('.fc-post-card__rail').attributes('style')).toContain('--fc-rail-cols: 5')
|
|
})
|
|
|
|
it('clicking an image opens the post-scoped modal (never expands the card)', async () => {
|
|
const pinia = freshPinia()
|
|
const modal = useModalStore()
|
|
const openSpy = vi.spyOn(modal, 'open').mockResolvedValue()
|
|
const post = {
|
|
...BASE,
|
|
description_plain: 'd',
|
|
thumbnails: [
|
|
{ image_id: 10, thumbnail_url: '/a' },
|
|
{ image_id: 11, thumbnail_url: '/b' },
|
|
],
|
|
thumbnails_more: 0,
|
|
}
|
|
const w = mountComponent(PostCard, { props: { post }, pinia })
|
|
await w.find('.fc-post-card__hero').trigger('click')
|
|
await flushPromises()
|
|
expect(openSpy).toHaveBeenCalledWith(10, { playlistIds: [10, 11] })
|
|
})
|
|
})
|