CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 18s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m22s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m38s
CI and images / smoke-web (push) Successful in 59s
CI and images / promote (push) Skipped
The linked drop's images and the piece's variants join the teaser's rail,
after its own images, each with a corner badge. The meta line counts them
apart ("+N from Discord"), so a Patreon card never reads as though the creator
posted Discord's files there. The text of each referenced post sits under
the teaser's body.
If FC made the link, the card says so, names the matching working name and
offers Undo. If a person accepted it, it offers Unlink and makes no claim
about FC. Both dismiss the pair. The kept row then stops the next sweep from
linking it straight back.
The announcement settings card gets the two windows: how close a drop must be
before its own card is left out of the feed, and how far to reach for
variants.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
293 lines
12 KiB
JavaScript
293 lines
12 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 { usePostsStore } from '../../src/stores/posts.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] })
|
|
})
|
|
})
|
|
|
|
// #4402. A teaser is a pointer, so its card shows what it points at — by
|
|
// REFERENCE. These pin the honesty half as hard as the feature half: the
|
|
// referenced images are marked as Discord's, and a link FC made by itself says
|
|
// so and offers the undo the operator chose over a silent merge.
|
|
describe('the unified card', () => {
|
|
const UNIFIED = {
|
|
links: [{ association_id: 7, post_id: 42, linked_by: 'fc', token: '0-k' }],
|
|
thumbnails: [
|
|
{ image_id: 200, thumbnail_url: '/d0', post_id: 42, role: 'drop' },
|
|
{ image_id: 201, thumbnail_url: '/v0', post_id: 40, role: 'variant' },
|
|
],
|
|
variant_count: 1,
|
|
texts: [
|
|
{ post_id: 42, role: 'drop', date: now, text: '@everyone the full set' },
|
|
{ post_id: 40, role: 'variant', date: now, text: 'wip, feedback welcome' },
|
|
],
|
|
}
|
|
const TEASER = {
|
|
...BASE,
|
|
description_plain: 'Full set in the server',
|
|
thumbnails: [{ image_id: 10, thumbnail_url: '/a' }],
|
|
associations: [{ id: 7, role: 'announces', post_id: 42 }],
|
|
unified: UNIFIED,
|
|
}
|
|
|
|
it('shows the drop and its variants beside the teaser, marked as references', () => {
|
|
const w = mountComponent(PostCard, { props: { post: TEASER }, pinia: freshPinia() })
|
|
const refs = w.findAll('.fc-post-card__rail-cell--ref')
|
|
expect(refs).toHaveLength(2)
|
|
// Counted apart from the teaser's own, so the card never reads as though
|
|
// the creator put Discord's files on Patreon.
|
|
expect(w.text()).toContain('1 image')
|
|
expect(w.text()).toContain('+2 from Discord')
|
|
})
|
|
|
|
it('carries the text of everything it unifies', () => {
|
|
const w = mountComponent(PostCard, { props: { post: TEASER }, pinia: freshPinia() })
|
|
expect(w.text()).toContain('@everyone the full set')
|
|
expect(w.text()).toContain('wip, feedback welcome')
|
|
})
|
|
|
|
it('says FC made the link, on what, and offers an undo', () => {
|
|
const w = mountComponent(PostCard, { props: { post: TEASER }, pinia: freshPinia() })
|
|
expect(w.text()).toContain('Linked by FabledCurator')
|
|
expect(w.text()).toContain('0-k')
|
|
expect(w.find('.fc-post-card__undo').text()).toBe('Undo')
|
|
// The old text link would say the same thing twice.
|
|
expect(w.text()).not.toContain('The full set is in Discord')
|
|
})
|
|
|
|
it('never claims FC made a link a person accepted', () => {
|
|
const post = {
|
|
...TEASER,
|
|
unified: { ...UNIFIED, links: [{ ...UNIFIED.links[0], linked_by: 'operator' }] },
|
|
}
|
|
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
|
|
expect(w.text()).not.toContain('Linked by FabledCurator')
|
|
expect(w.find('.fc-post-card__undo').text()).toBe('Unlink')
|
|
})
|
|
|
|
it('undo dismisses that one link', async () => {
|
|
const pinia = freshPinia()
|
|
const store = usePostsStore()
|
|
const spy = vi.spyOn(store, 'unlink').mockResolvedValue()
|
|
const w = mountComponent(PostCard, { props: { post: TEASER }, pinia })
|
|
await w.find('.fc-post-card__undo').trigger('click')
|
|
await flushPromises()
|
|
expect(spy).toHaveBeenCalledWith(1, 7)
|
|
})
|
|
|
|
it('arrows through the teaser first, then what it references', async () => {
|
|
const pinia = freshPinia()
|
|
const openSpy = vi.spyOn(useModalStore(), 'open').mockResolvedValue()
|
|
const w = mountComponent(PostCard, { props: { post: TEASER }, pinia })
|
|
await w.find('.fc-post-card__hero').trigger('click')
|
|
await flushPromises()
|
|
expect(openSpy).toHaveBeenCalledWith(10, { playlistIds: [10, 200, 201] })
|
|
})
|
|
|
|
it('an ordinary post is untouched', () => {
|
|
const w = mountComponent(PostCard, {
|
|
props: { post: { ...BASE, unified: null } }, pinia: freshPinia(),
|
|
})
|
|
expect(w.find('.fc-post-card__unified').exists()).toBe(false)
|
|
expect(w.text()).not.toContain('from Discord')
|
|
})
|
|
})
|