Bluesky linkifies nothing by itself. A URL in the text of a record is plain text unless a richtext facet says which bytes of the post are a link and where they point, so our posts carried URLs no one could press. Each link now travels on the Post as an output.Link with the offsets of the text it occupies, and the bluesky output turns those into app.bsky.richtext.facet#link. The offsets are UTF-8 byte offsets, not character counts: a facet measured in characters slides off the URL as soon as any Japanese text precedes it, and underlines the wrong words. Scanning moved from preview.go to findLinks in main.go, which now reports every link with its offsets rather than just the first one; the preview card still goes to the first, which is the one a reader meets first. X is unaffected, as it linkifies URLs itself. The facet feature only gains its lexicon type when marshalled, so the test checks the encoded record rather than the struct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
4.7 KiB
Go
153 lines
4.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"image"
|
|
"image/color"
|
|
"image/jpeg"
|
|
"math/rand"
|
|
"strings"
|
|
"testing"
|
|
"tweetdistributor/output"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// noiseJPEG returns a JPEG image of random noise, which compresses poorly
|
|
// and reliably exceeds maxImageBytes at large dimensions.
|
|
func noiseJPEG(t *testing.T, width, height int) []byte {
|
|
t.Helper()
|
|
rng := rand.New(rand.NewSource(1))
|
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
|
for y := 0; y < height; y++ {
|
|
for x := 0; x < width; x++ {
|
|
img.Set(x, y, color.RGBA{uint8(rng.Intn(256)), uint8(rng.Intn(256)), uint8(rng.Intn(256)), 255})
|
|
}
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func TestFindLinks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
want []output.Link
|
|
}{
|
|
{
|
|
// The offsets are byte offsets, so the multibyte prefix counts
|
|
// for more than the three characters it looks like.
|
|
"after multibyte text",
|
|
"みてね https://example.com/article",
|
|
[]output.Link{{URL: "https://example.com/article", ByteStart: 10, ByteEnd: 37}},
|
|
},
|
|
{
|
|
"trailing punctuation is outside the link",
|
|
"これ→https://example.com/x。",
|
|
[]output.Link{{URL: "https://example.com/x", ByteStart: 9, ByteEnd: 30}},
|
|
},
|
|
{
|
|
"several",
|
|
"https://one.example https://two.example",
|
|
[]output.Link{
|
|
{URL: "https://one.example", ByteStart: 0, ByteEnd: 19},
|
|
{URL: "https://two.example", ByteStart: 20, ByteEnd: 39},
|
|
},
|
|
},
|
|
{"no link", "ただのつぶやき", nil},
|
|
{"scheme only", "http:// と書いただけ", nil},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := findLinks(tt.content)
|
|
if len(got) != len(tt.want) {
|
|
t.Fatalf("findLinks(%q) returned %d links, want %d", tt.content, len(got), len(tt.want))
|
|
}
|
|
for i, link := range got {
|
|
if link != tt.want[i] {
|
|
t.Errorf("link %d = %+v, want %+v", i, link, tt.want[i])
|
|
}
|
|
// A facet pointing at the wrong bytes marks up the wrong text.
|
|
if slice := tt.content[link.ByteStart:link.ByteEnd]; slice != link.URL {
|
|
t.Errorf("bytes [%d:%d] are %q, want %q", link.ByteStart, link.ByteEnd, slice, link.URL)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTweetLength(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
want int
|
|
}{
|
|
{"no link", "こんにちは", 5},
|
|
{"long link", "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLabcdefghijklmnop", urlLength},
|
|
{"short link", "https://a.jp", urlLength},
|
|
{"link with text", "みてね " + strings.Repeat("x", 10) + " https://youtu.be/dQw4w9WgXcQ", 4 + 10 + 1 + urlLength},
|
|
{"two links", "https://a.jp https://b.jp", urlLength*2 + 1},
|
|
{"trailing punctuation still counts", "https://a.jp。", urlLength + 1},
|
|
{"multibyte path", "https://ja.wikipedia.org/wiki/日本語", urlLength},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := tweetLength(tt.content); got != tt.want {
|
|
t.Errorf("tweetLength(%q) = %d, want %d", tt.content, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTweetLengthAcceptsMessageOnlyLinksMakeTooLong(t *testing.T) {
|
|
// A message that the raw rune count would reject but Twitter accepts.
|
|
content := strings.Repeat("あ", 100) + " https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120s"
|
|
|
|
if raw := utf8.RuneCountInString(content); raw <= maxTweetLength {
|
|
t.Fatalf("test message should be %d raw characters, got %d", maxTweetLength+1, raw)
|
|
}
|
|
if got := tweetLength(content); got > maxTweetLength {
|
|
t.Errorf("tweetLength = %d, want it to fit within %d", got, maxTweetLength)
|
|
}
|
|
}
|
|
|
|
func TestShrinkImageLarge(t *testing.T) {
|
|
data := noiseJPEG(t, 3000, 3000)
|
|
if len(data) <= maxImageBytes {
|
|
t.Fatalf("test image should exceed maxImageBytes, got %d", len(data))
|
|
}
|
|
|
|
shrunk, err := shrinkImage(output.Image{Data: data, ContentType: "image/jpeg", Filename: "DSC_7277.jpg"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(shrunk.Data) > maxImageBytes {
|
|
t.Errorf("shrunk image is %d bytes, want <= %d", len(shrunk.Data), maxImageBytes)
|
|
}
|
|
if shrunk.ContentType != "image/jpeg" {
|
|
t.Errorf("ContentType = %q, want image/jpeg", shrunk.ContentType)
|
|
}
|
|
if shrunk.Filename != "DSC_7277.jpg" {
|
|
t.Errorf("Filename = %q, want DSC_7277.jpg", shrunk.Filename)
|
|
}
|
|
if _, _, err := image.Decode(bytes.NewReader(shrunk.Data)); err != nil {
|
|
t.Errorf("shrunk image is not decodable: %s", err)
|
|
}
|
|
}
|
|
|
|
func TestShrinkImageSmallPassthrough(t *testing.T) {
|
|
data := noiseJPEG(t, 100, 100)
|
|
img := output.Image{Data: data, ContentType: "image/png", Filename: "small.png"}
|
|
shrunk, err := shrinkImage(img)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(shrunk.Data, img.Data) || shrunk.ContentType != img.ContentType || shrunk.Filename != img.Filename {
|
|
t.Error("small image should pass through unchanged")
|
|
}
|
|
}
|