// The bsky profile projection. Pure, so all of this runs with no repo, no socket and no network.
import test from 'brittle'
import { planProfile, sameProfile, MAX_NAME_GRAPHEMES, MAX_DESCRIPTION_GRAPHEMES } from '../lib/profile.js'
const png = (oid) => ({ oid, mimeType: 'image/png', size: 100 })
test('a full profile projects every field', (t) => {
const p = planProfile({ name: 'ash', bio: 'downstairs device', avatar: png('a'), banner: png('b') })
t.ok(p.project)
t.is(p.record.$type, 'app.bsky.actor.profile')
t.is(p.record.displayName, 'ash')
t.is(p.record.description, 'downstairs device')
t.alike(Object.keys(p.images).sort(), ['avatar', 'banner'], 'both pictures named for upload')
t.is(p.losses.length, 0)
})
// ⛔ The same rule plan.js applies to a post: never publish something the author did not write.
test('an over-long field is OMITTED and reported, and the rest still goes', (t) => {
const p = planProfile({ name: 'x'.repeat(MAX_NAME_GRAPHEMES + 1), bio: 'fine' })
t.absent(p.record.displayName, 'the name is not truncated to fit')
t.is(p.record.description, 'fine', 'and the rest of the profile is unaffected')
t.ok(p.losses.some((l) => l.includes('display name omitted')), 'and the loss is named')
t.ok(p.project, 'a profile with one usable field is still worth writing')
const q = planProfile({ bio: 'y'.repeat(MAX_DESCRIPTION_GRAPHEMES + 1) })
t.absent(q.record.description)
t.ok(q.losses.some((l) => l.includes('description omitted')))
})
test('★ an emoji is ONE grapheme, not four bytes', (t) => {
const p = planProfile({ name: '🌮'.repeat(MAX_NAME_GRAPHEMES) })
t.ok(p.record.displayName, 'counted as graphemes — a byte count would reject this')
})
// ⛔ favela accepts gif and webp; bsky's lexicon does not.
test('a picture bsky will not take is omitted rather than converted', (t) => {
const p = planProfile({ name: 'ash', avatar: { oid: 'a', mimeType: 'image/webp', size: 10 } })
t.absent(p.images.avatar, 'not offered for upload')
t.ok(p.losses.some((l) => l.includes('avatar omitted')), 'and said so')
t.is(p.record.displayName, 'ash', 'the rest of the profile still projects')
})
// ⛔ A projection must never erase.
test('⛔ an empty profile projects NOTHING rather than an empty record', (t) => {
const p = planProfile({})
t.absent(p.project, 'writing {$type} alone would replace what is there with nothing')
const q = planProfile({ name: ' ', bio: '' })
t.absent(q.project, 'and whitespace is not content')
})
// ⛔ Reconciliation compares against the REPO, not a local note of what we last sent.
test('sameProfile compares blob refs by CID', (t) => {
const desired = { displayName: 'ash', avatar: { $type: 'blob', ref: { $link: 'bafk1' }, mimeType: 'image/png', size: 1 } }
const actual = { displayName: 'ash', avatar: { $type: 'blob', ref: { $link: 'bafk1' }, mimeType: 'image/png', size: 1 } }
t.ok(sameProfile(desired, actual), 'identical content-addressed picture is the same picture')
const changed = { displayName: 'ash', avatar: { $type: 'blob', ref: { $link: 'bafk2' }, mimeType: 'image/png', size: 1 } }
t.absent(sameProfile(desired, changed), 'a different CID is a different picture')
t.absent(sameProfile(desired, { displayName: 'ashatron' }), 'a changed name is a change')
t.absent(sameProfile(desired, null), 'nothing published yet is not "the same"')
})
test('adding a banner to a profile that had none is a change', (t) => {
const before = { displayName: 'ash' }
const after = { displayName: 'ash', banner: { ref: { $link: 'bafk9' } } }
t.absent(sameProfile(after, before), '⛔ or the first banner would never be written')
})
// ⛔ A bsky description is PLAIN TEXT and the bio is markdown.
test('the description is stripped, not handed over raw', (t) => {
const p = planProfile({ bio: 'the **dev** box, and a [link](https://example.com)' })
t.absent(p.record.description.includes('**'), '⛔ or the asterisks show on the live profile')
t.ok(p.record.description.includes('dev'), 'the words survive')
})
// ⛔ A profile picture caps at 1,000,000 — HALF the post-embed ceiling, and there was no gate at all.
test('an oversized profile picture is omitted rather than sent to be refused', (t) => {
const p = planProfile({ name: 'ash', avatar: { oid: 'a', mimeType: 'image/png', size: 1000001 } })
t.absent(p.images.avatar, 'not offered for upload')
t.ok(p.losses.some((l) => l.includes('profile limit')), 'and named')
const ok = planProfile({ name: 'ash', avatar: { oid: 'a', mimeType: 'image/png', size: 999999 } })
t.ok(ok.images.avatar, 'and one that fits still goes')
})