CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m1s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m11s
Rule 27 — E5's other half. The matcher can propose; this is where the operator
decides, and where an accepted link actually shows up.
**The review queue** (Settings → Ingestion & filters). Each proposal shows the
per-signal breakdown, not just the total: "why did it suggest this" is the
question the operator actually has, and a lone percentage cannot answer it. So
a row reads "72% · timing 95% · says so 60%", and the copy states outright that
a pair always needs two reasons — which is the property that stops a busy
posting day from producing false pairs.
The empty state says so explicitly. Nothing proposed is the EXPECTED state most
of the time, and an empty queue that looks like a failure invites turning the
threshold down until it produces noise.
**On the card**, both directions, and accepted links only: the teaser gets "The
full set is in Discord", the drop gets "Announced on Patreon". A pending
proposal is a question for the review queue, never a claim to render beside the
artwork — that distinction is the whole confirm-only design, so it is asserted
in the backend (only `linked` rows reach the payload) and again here.
One detail worth the comment it carries: the link's target is `{ query: {
post_id } }` with no name or path. In vue-router that means "the current route
with these query params", so it works identically from Latest and from Browse —
and, more usefully, the card never reaches for `useRoute()`, which it has no
other reason to know about and which is not available when it is mounted in a
test without a router.
Backend CI on 235393c was green: all 13 E5 tests and migration 0094.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
204 lines
8.2 KiB
JavaScript
204 lines
8.2 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('shows an accepted announcement link from either end', () => {
|
|
const teaser = mountComponent(PostCard, {
|
|
props: {
|
|
post: {
|
|
...BASE,
|
|
associations: [{ id: 7, role: 'announces', post_id: 42 }],
|
|
},
|
|
},
|
|
pinia: freshPinia(),
|
|
})
|
|
expect(teaser.text()).toContain('The full set is in Discord')
|
|
|
|
const drop = mountComponent(PostCard, {
|
|
props: {
|
|
post: {
|
|
...SYNTH,
|
|
associations: [{ id: 7, role: 'announced_by', post_id: 1 }],
|
|
},
|
|
},
|
|
pinia: freshPinia(),
|
|
})
|
|
expect(drop.text()).toContain('Announced on Patreon')
|
|
})
|
|
|
|
it('shows no link when there is no accepted association', () => {
|
|
// Pending proposals never reach the payload, so an empty list here is
|
|
// the normal case and must render as silence, not an empty row.
|
|
const w = mountComponent(PostCard, {
|
|
props: { post: { ...BASE, associations: [] } },
|
|
pinia: freshPinia(),
|
|
})
|
|
expect(w.find('.fc-post-card__assoc').exists()).toBe(false)
|
|
})
|
|
|
|
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] })
|
|
})
|
|
})
|