CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m5s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m52s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m7s
Rule 27 — E2's other half. The backend can author posts; this is what makes
that visible and adjustable.
**The honesty marker.** A chip on every synthetic post's card: "grouped by
FabledCurator", titled with what it was built from ("Grouped from 4 Discord
messages"). This chip is the only thing standing between "FC assembled this"
and the card reading as something the artist authored, so it keys off nothing
but the flag, and it states the member count rather than just disclosing that
grouping happened — a claim you can check beats a claim you're asked to trust.
A synthetic post has no title on purpose (inventing one is the one place this
feature could put words in a creator's mouth), and the untitled fallback would
otherwise have printed the internal key: "Post fc-drop:99887766". It now names
the post for what it is. There's a test for that specifically.
**The tuning card.** Ingestion & filters gets a Discord-drop-grouping tile:
the switch, the distance cut and the drop window, each with the sentence that
tells the operator which one to reach for. The window's copy says outright
that it is the setting doing most of the work — without it, everything an
artist ever drew of one character collapses into a single post.
Both directions are pinned in postCard.spec.js, including the one that
actually matters: an ordinary post is never marked. Also covered — a post dict
composed before these fields existed degrades to unmarked rather than throwing
on `synthesis.message_count`.
Also fixes the ruff UP017 that failed the lint job on 73eeb7a (timezone.utc →
datetime.UTC, the convention everywhere else in this repo). The integration
suite on that SHA was green: all 12 grouping tests passed and migration 0092
applied cleanly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
140 lines
6.0 KiB
JavaScript
140 lines
6.0 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('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] })
|
|
})
|
|
})
|