2026-07-13 17:47:00 +07:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestTSFRoundTrip(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
path := filepath.Join(dir, "f.tsf")
|
|
|
|
|
original := &FramesContainer{
|
2026-07-13 22:54:23 +07:00
|
|
|
ColorFrames: [][]byte{{100, 101, 102}, {110, 120, 130}},
|
|
|
|
|
FPS: 29.97,
|
2026-07-13 17:47:00 +07:00
|
|
|
}
|
|
|
|
|
if err := writeTSF(path, original); err != nil {
|
|
|
|
|
t.Fatalf("writeTSF: %v", err)
|
|
|
|
|
}
|
|
|
|
|
fc, err := loadTSF(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("loadTSF: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if fc.FPS != 29.97 {
|
|
|
|
|
t.Errorf("fps = %v", fc.FPS)
|
|
|
|
|
}
|
2026-07-13 22:54:23 +07:00
|
|
|
if len(fc.ColorFrames) != 2 {
|
|
|
|
|
t.Fatalf("frames = %d color", len(fc.ColorFrames))
|
2026-07-13 17:47:00 +07:00
|
|
|
}
|
2026-07-13 22:54:23 +07:00
|
|
|
if string(fc.ColorFrames[0]) != string([]byte{100, 101, 102}) {
|
|
|
|
|
t.Errorf("color frame0 = %v", fc.ColorFrames[0])
|
2026-07-13 17:47:00 +07:00
|
|
|
}
|
2026-07-13 22:54:23 +07:00
|
|
|
if string(fc.ColorFrames[1]) != string([]byte{110, 120, 130}) {
|
|
|
|
|
t.Errorf("color frame1 = %v", fc.ColorFrames[1])
|
2026-07-13 17:47:00 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTSFInvalid(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
path := filepath.Join(dir, "bad.tsf")
|
|
|
|
|
|
2026-07-13 23:01:48 +07:00
|
|
|
if err := os.WriteFile(path, []byte("not a tsf file"), 0o644); err != nil {
|
|
|
|
|
t.Fatalf("WriteFile: %v", err)
|
|
|
|
|
}
|
2026-07-13 17:47:00 +07:00
|
|
|
if _, err := loadTSF(path); err == nil {
|
|
|
|
|
t.Error("expected error for garbage input")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Valid container but no frames.
|
2026-07-13 23:01:48 +07:00
|
|
|
if err := writeTSF(path, &FramesContainer{FPS: 30}); err != nil {
|
|
|
|
|
t.Fatalf("writeTSF: %v", err)
|
|
|
|
|
}
|
2026-07-13 17:47:00 +07:00
|
|
|
if _, err := loadTSF(path); err == nil {
|
|
|
|
|
t.Error("expected error for empty frames")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Valid container but fps <= 0.
|
2026-07-13 23:01:48 +07:00
|
|
|
if err := writeTSF(path, &FramesContainer{ColorFrames: [][]byte{{1}}, FPS: 0}); err != nil {
|
|
|
|
|
t.Fatalf("writeTSF: %v", err)
|
|
|
|
|
}
|
2026-07-13 17:47:00 +07:00
|
|
|
if _, err := loadTSF(path); err == nil {
|
|
|
|
|
t.Error("expected error for fps<=0")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Truncated payload.
|
2026-07-13 23:01:48 +07:00
|
|
|
if err := writeTSF(path, &FramesContainer{ColorFrames: [][]byte{{1, 2, 3, 4}}, FPS: 30}); err != nil {
|
|
|
|
|
t.Fatalf("writeTSF: %v", err)
|
|
|
|
|
}
|
|
|
|
|
raw, err := os.ReadFile(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("ReadFile: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(path, raw[:len(raw)-2], 0o644); err != nil {
|
|
|
|
|
t.Fatalf("WriteFile: %v", err)
|
|
|
|
|
}
|
2026-07-13 17:47:00 +07:00
|
|
|
if _, err := loadTSF(path); err == nil {
|
|
|
|
|
t.Error("expected error for truncated file")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLoadConfigDefaults(t *testing.T) {
|
|
|
|
|
t.Setenv("HOST", "")
|
|
|
|
|
t.Setenv("PORT", "")
|
|
|
|
|
t.Setenv("PLAYBACK_MODE", "")
|
|
|
|
|
t.Setenv("LOGIN_DELAY", "")
|
|
|
|
|
cfg := loadConfig()
|
|
|
|
|
if cfg.Host != "0.0.0.0" {
|
|
|
|
|
t.Errorf("host = %q", cfg.Host)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Port != 22 {
|
|
|
|
|
t.Errorf("port = %d", cfg.Port)
|
|
|
|
|
}
|
|
|
|
|
if cfg.PlaybackMode != PlaybackLoop {
|
|
|
|
|
t.Errorf("playbackMode = %q", cfg.PlaybackMode)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Charset != "detailed" {
|
|
|
|
|
t.Errorf("charset = %q", cfg.Charset)
|
|
|
|
|
}
|
|
|
|
|
if cfg.LoginDelay != 1500*time.Millisecond {
|
|
|
|
|
t.Errorf("loginDelay = %v", cfg.LoginDelay)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLoadConfigClamping(t *testing.T) {
|
|
|
|
|
t.Setenv("PORT", "999999")
|
|
|
|
|
t.Setenv("BRIGHTNESS_THRESHOLD", "-5")
|
|
|
|
|
cfg := loadConfig()
|
|
|
|
|
if cfg.Port != 65535 {
|
|
|
|
|
t.Errorf("port clamp = %d", cfg.Port)
|
|
|
|
|
}
|
|
|
|
|
if cfg.BrightnessThreshold != 0 {
|
|
|
|
|
t.Errorf("brightness clamp = %d", cfg.BrightnessThreshold)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLoadConfigInvalidFallsBack(t *testing.T) {
|
|
|
|
|
t.Setenv("PORT", "not-a-number")
|
|
|
|
|
t.Setenv("INVERT", "yes-please")
|
|
|
|
|
t.Setenv("PLAYBACK_MODE", "shuffle")
|
|
|
|
|
cfg := loadConfig()
|
|
|
|
|
if cfg.Port != 22 {
|
|
|
|
|
t.Errorf("port = %d, want default 22", cfg.Port)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Invert {
|
|
|
|
|
t.Error("invert should fall back to false")
|
|
|
|
|
}
|
|
|
|
|
if cfg.PlaybackMode != PlaybackLoop {
|
|
|
|
|
t.Errorf("playbackMode = %q, want default loop", cfg.PlaybackMode)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestEnvDurationMs(t *testing.T) {
|
|
|
|
|
t.Setenv("D", "250")
|
|
|
|
|
if got := envDurationMs("D", time.Second); got != 250*time.Millisecond {
|
|
|
|
|
t.Errorf("250 = %v, want 250ms", got)
|
|
|
|
|
}
|
|
|
|
|
t.Setenv("D", "-10")
|
|
|
|
|
if got := envDurationMs("D", time.Second); got != 0 {
|
|
|
|
|
t.Errorf("negative = %v, want 0", got)
|
|
|
|
|
}
|
|
|
|
|
t.Setenv("D", "banana")
|
|
|
|
|
if got := envDurationMs("D", time.Second); got != time.Second {
|
|
|
|
|
t.Errorf("invalid = %v, want fallback 1s", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPlaybackModeRandom(t *testing.T) {
|
|
|
|
|
t.Setenv("PLAYBACK_MODE", "RaNdOm")
|
|
|
|
|
if loadConfig().PlaybackMode != PlaybackRandom {
|
|
|
|
|
t.Error("expected random")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestResolveCharset(t *testing.T) {
|
|
|
|
|
if got := resolveCharset("blocks"); got != " ░▒▓█" {
|
|
|
|
|
t.Errorf("blocks preset = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := resolveCharset("XYZ"); got != "XYZ" {
|
|
|
|
|
t.Errorf("custom ramp = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := resolveCharset(""); !strings.HasPrefix(got, " .") {
|
|
|
|
|
t.Errorf("default = %q", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestFrameToAscii(t *testing.T) {
|
|
|
|
|
// Below threshold -> first ramp char; full brightness -> last.
|
|
|
|
|
opts := asciiOptions{brightnessThreshold: 40, charset: "standard"}
|
|
|
|
|
ramp := []rune(resolveCharset("standard"))
|
|
|
|
|
out := []rune(frameToAscii([]byte{0, 255}, opts))
|
|
|
|
|
if out[0] != ramp[0] {
|
|
|
|
|
t.Errorf("dark px = %q, want %q", out[0], ramp[0])
|
|
|
|
|
}
|
|
|
|
|
if out[1] != ramp[len(ramp)-1] {
|
|
|
|
|
t.Errorf("bright px = %q, want %q", out[1], ramp[len(ramp)-1])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestFrameToAsciiInvert(t *testing.T) {
|
|
|
|
|
opts := asciiOptions{brightnessThreshold: 40, charset: "standard", invert: true}
|
|
|
|
|
ramp := []rune(resolveCharset("standard"))
|
|
|
|
|
out := []rune(frameToAscii([]byte{255}, opts))
|
|
|
|
|
if out[0] != ramp[0] {
|
|
|
|
|
t.Errorf("inverted bright = %q, want %q", out[0], ramp[0])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConnectionTracker(t *testing.T) {
|
|
|
|
|
tr := newConnectionTracker()
|
|
|
|
|
tr.increment("1.2.3.4")
|
|
|
|
|
tr.increment("1.2.3.4")
|
|
|
|
|
if !tr.hasReachedLimits("1.2.3.4", 2, 100) {
|
|
|
|
|
t.Error("expected per-ip limit reached")
|
|
|
|
|
}
|
|
|
|
|
tr.decrement("1.2.3.4")
|
|
|
|
|
tr.decrement("1.2.3.4")
|
|
|
|
|
if tr.totalCount() != 0 {
|
|
|
|
|
t.Errorf("total = %d", tr.totalCount())
|
|
|
|
|
}
|
|
|
|
|
if tr.hasReachedLimits("1.2.3.4", 2, 100) {
|
|
|
|
|
t.Error("should be cleared")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestClampDimension(t *testing.T) {
|
|
|
|
|
if clampDimension(0, 100) != 1 {
|
|
|
|
|
t.Error("floor")
|
|
|
|
|
}
|
|
|
|
|
if clampDimension(500, 100) != 100 {
|
|
|
|
|
t.Error("ceil")
|
|
|
|
|
}
|
|
|
|
|
if clampDimension(50, 100) != 50 {
|
|
|
|
|
t.Error("passthrough")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestParseDimsPtyReq(t *testing.T) {
|
|
|
|
|
// "xterm" + cols=100 rows=40 + widthpx + heightpx
|
|
|
|
|
payload := []byte{
|
|
|
|
|
0, 0, 0, 5, 'x', 't', 'e', 'r', 'm',
|
|
|
|
|
0, 0, 0, 100,
|
|
|
|
|
0, 0, 0, 40,
|
|
|
|
|
0, 0, 0, 0,
|
|
|
|
|
0, 0, 0, 0,
|
|
|
|
|
}
|
|
|
|
|
cols, rows, ok := parseDims(payload)
|
|
|
|
|
if !ok || cols != 100 || rows != 40 {
|
|
|
|
|
t.Errorf("parseDims = %d,%d,%v", cols, rows, ok)
|
|
|
|
|
}
|
2026-07-13 22:54:23 +07:00
|
|
|
|
|
|
|
|
term, ok := parsePtyTerm(payload)
|
|
|
|
|
if !ok || term != "xterm" {
|
|
|
|
|
t.Errorf("parsePtyTerm = %q,%v", term, ok)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDetectColorTier(t *testing.T) {
|
|
|
|
|
cases := map[string]colorTier{
|
|
|
|
|
"": colorTierNone,
|
|
|
|
|
"dumb": colorTierNone,
|
|
|
|
|
"vt100": colorTierNone,
|
|
|
|
|
"linux": colorTierNone,
|
|
|
|
|
"xterm": colorTierTrueColor,
|
|
|
|
|
"xterm-256color": colorTier256,
|
|
|
|
|
"screen-256color": colorTier256,
|
|
|
|
|
"tmux-256color": colorTier256,
|
|
|
|
|
"xterm-direct": colorTierTrueColor,
|
|
|
|
|
"xterm-kitty": colorTierTrueColor,
|
|
|
|
|
}
|
|
|
|
|
for term, want := range cases {
|
|
|
|
|
if got := detectColorTier(term); got != want {
|
|
|
|
|
t.Errorf("detectColorTier(%q) = %d, want %d", term, got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestQuantize256(t *testing.T) {
|
|
|
|
|
if got := quantize256(0, 0, 0); got != 16 {
|
|
|
|
|
t.Errorf("black = %d, want 16", got)
|
|
|
|
|
}
|
|
|
|
|
if got := quantize256(255, 255, 255); got != 231 {
|
|
|
|
|
t.Errorf("white = %d, want 231", got)
|
|
|
|
|
}
|
2026-07-13 17:47:00 +07:00
|
|
|
}
|