Vellum/internal/engine/engine_test.go
mithral fd377a7446 Offer transcoding as a resolve engine, as the last resort
Fourth and final step of ADR-008's server half. When nothing the client
declared can direct-play a file and this host can re-encode, resolve now
answers playable with engine "transcode". The client sees a string it already
understands, so the frozen JSON contract did not change.

The ordering is the whole point and is asserted directly: transcoding sits
after the direct-play loop, so a file that plays directly never pays for it.
Re-encoding something a client could already decode would burn the GPU and
lose quality for nothing, which is the failure mode ADR-008 rejects by
choosing direct play first. Verified red-green - hoisting the check above the
loop fails with "direct play must always win".

The capability is resolved once at boot and passed in, not read inside a
request: detection spawns ffmpeg to prove the encoder works, and that must
never happen on the request path. It is also passed rather than read from the
transcode package so the tests can drive both answers without hardware.

TranscodeID is deliberately not a registry entry. Every other engine is
something a client declares; this one is the server's own capability, so it
is not advertised in /playback/engines and a client must not name it.

On a host with no usable encoder canTranscode is false and every answer is
byte-identical to what it was before any of this existed - which is this
deployment, and is why ADR-002 still holds for it.
2026-10-12 12:03:41 -04:00

277 lines
8.3 KiB
Go

package engine
import (
"encoding/json"
"strings"
"testing"
)
// TestRegistryHasBuiltins pins the engines a client can name out of the box.
func TestRegistryHasBuiltins(t *testing.T) {
for _, id := range []string{HTML5ID, "mpv", "vlc", "mdk"} {
if _, ok := Get(id); !ok {
t.Errorf("engine %q is not registered", id)
}
}
if _, ok := Get("nope"); ok {
t.Error("Get returned an unknown engine")
}
// Get is case/space tolerant so a client's casing never matters.
if _, ok := Get(" HTML5 "); !ok {
t.Error("Get should trim and lower-case the id")
}
}
// TestHTML5BaselineIsHonest guards the measured facts: the browser baseline must
// NOT claim HEVC or mpeg4, which Chrome cannot decode, and must not claim a
// container it does not handle.
func TestHTML5BaselineIsHonest(t *testing.T) {
html5, _ := Get(HTML5ID)
if contains(html5.VCodecs, "hevc") || contains(html5.VCodecs, "h265") {
t.Error("html5 baseline claims HEVC, which browsers cannot decode")
}
if contains(html5.VCodecs, "mpeg4") {
t.Error("html5 baseline claims mpeg4, which browsers cannot decode")
}
if !contains(html5.VCodecs, "h264") || !contains(html5.VCodecs, "vp9") || !contains(html5.VCodecs, "av1") {
t.Errorf("html5 baseline is missing a measured-playable codec: %v", html5.VCodecs)
}
if !contains(html5.Containers, "mkv") {
t.Error("html5 baseline must accept mkv (h264-in-mkv plays)")
}
if contains(html5.Containers, "avi") {
t.Error("html5 baseline must not claim avi")
}
}
// TestEngineJSONMatchesContract pins the frozen wire shape (web/src/lib/api.ts).
func TestEngineJSONMatchesContract(t *testing.T) {
e, _ := Get(HTML5ID)
b, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatal(err)
}
for _, key := range []string{"id", "name", "kind", "containers", "vcodecs", "acodecs"} {
if _, ok := raw[key]; !ok {
t.Errorf("engine JSON is missing %q", key)
}
}
if len(raw) != 6 {
t.Errorf("engine JSON has %d keys, want exactly 6: %s", len(raw), b)
}
}
func TestResolve(t *testing.T) {
tests := []struct {
name string
facts []string
engines []string
playable bool
engine string // expected engine id, or "" for nil
reason string
msgSubstr string // must appear in Message when not playable
// canTranscode is whether the server itself can re-encode (ADR-008).
canTranscode bool
}{
{
name: "mp4 h264 aac plays in html5",
facts: Facts("mp4", "h264", "aac"),
engines: []string{"html5"},
playable: true,
engine: "html5",
},
{
name: "mkv h264 plays despite the container",
facts: Facts("mkv", "h264", "aac"),
engines: []string{"html5"},
playable: true,
engine: "html5",
},
{
name: "mp4 hevc aac is not playable",
facts: Facts("mp4", "hevc", "aac"),
engines: []string{"html5"},
playable: false,
reason: "vcodec:hevc",
msgSubstr: "HEVC",
},
{
name: "avi mpeg4 is a container blocker",
facts: Facts("avi", "mpeg4", "mp3"),
engines: []string{"html5"},
playable: false,
reason: "container:avi",
msgSubstr: "AVI",
},
{
name: "unsupported audio codec",
facts: Facts("mp4", "h264", "dts"),
engines: []string{"html5"},
playable: false,
reason: "acodec:dts",
msgSubstr: "DTS",
},
{
name: "audio-only mp3 is playable",
facts: Facts("mp3", "", "mp3"),
engines: []string{"html5"},
playable: true,
engine: "html5",
},
{
name: "audio-only flac is playable",
facts: Facts("flac", "", "flac"),
engines: []string{"html5"},
playable: true,
engine: "html5",
},
{
name: "audio-only dts is not playable",
facts: Facts("m4a", "", "dts"),
engines: []string{"html5"},
playable: false,
reason: "acodec:dts",
msgSubstr: "DTS",
},
{
name: "empty engine list falls back to html5 baseline",
facts: Facts("mp4", "h264", "aac"),
engines: nil,
playable: true,
engine: "html5",
},
{
name: "empty engine list still rejects hevc",
facts: Facts("mp4", "hevc", "aac"),
engines: nil,
playable: false,
reason: "vcodec:hevc",
msgSubstr: "mpv",
},
{
name: "native engine changes the answer for hevc",
facts: Facts("mp4", "hevc", "aac"),
engines: []string{"html5", "mpv"},
playable: true,
engine: "mpv",
},
{
name: "native engine handles avi",
facts: Facts("avi", "mpeg4", "mp3"),
engines: []string{"html5", "vlc"},
playable: true,
engine: "vlc",
},
{
name: "client ordering wins among capable engines",
facts: Facts("mkv", "h264", "aac"),
engines: []string{"mpv", "html5"},
playable: true,
engine: "mpv",
},
{
name: "capable engine beats an earlier incapable one",
facts: Facts("mp4", "hevc", "aac"),
engines: []string{"html5", "vlc", "mpv"},
playable: true,
engine: "vlc",
},
{
name: "unknown engines are skipped, reason uses fallback",
facts: Facts("mp4", "hevc", "aac"),
engines: []string{"bogus"},
playable: false,
reason: "vcodec:hevc",
msgSubstr: "mpv",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Resolve(tt.facts, tt.engines, tt.canTranscode)
if got.Playable != tt.playable {
t.Fatalf("playable = %v, want %v (reason %q)", got.Playable, tt.playable, got.Reason)
}
if tt.playable {
if got.Engine == nil || *got.Engine != tt.engine {
t.Errorf("engine = %v, want %q", got.Engine, tt.engine)
}
if got.Reason != "" || got.Message != "" {
t.Errorf("playable result carried reason=%q message=%q", got.Reason, got.Message)
}
return
}
if got.Engine != nil {
t.Errorf("engine = %q, want null", *got.Engine)
}
if got.Reason != tt.reason {
t.Errorf("reason = %q, want %q", got.Reason, tt.reason)
}
if tt.msgSubstr != "" && !strings.Contains(got.Message, tt.msgSubstr) {
t.Errorf("message = %q, want it to mention %q", got.Message, tt.msgSubstr)
}
if strings.Contains(strings.ToLower(got.Message), "you ") {
t.Errorf("message blames the user: %q", got.Message)
}
})
}
}
// TestResolutionJSONEngineNull pins that a non-playable answer serialises
// engine as JSON null, exactly as PlaybackResolution declares.
func TestResolutionJSONEngineNull(t *testing.T) {
got := Resolve(Facts("mp4", "hevc", "aac"), []string{"html5"}, false)
b, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
var raw struct {
Playable bool `json:"playable"`
Engine json.RawMessage `json:"engine"`
Reason string `json:"reason"`
Message string `json:"message"`
}
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatal(err)
}
if string(raw.Engine) != "null" {
t.Errorf("engine = %s, want null", raw.Engine)
}
if raw.Playable || raw.Reason != "vcodec:hevc" || raw.Message == "" {
t.Errorf("unexpected resolution JSON: %s", b)
}
}
// A file nothing can direct-play is still playable when the SERVER can re-encode
// (ADR-008). The ordering matters more than the feature: transcoding is the last
// resort, so a file that plays directly must never pay for it.
func TestResolveTranscodeIsTheLastResort(t *testing.T) {
hevc := Facts("mp4", "hevc", "aac")
// Nothing the client declared can decode it, so the server re-encodes.
got := Resolve(hevc, []string{"html5"}, true)
if !got.Playable || got.Engine == nil || *got.Engine != TranscodeID {
t.Errorf("Resolve(hevc, html5, canTranscode) = %+v, want playable via %q", got, TranscodeID)
}
// A file the client CAN decode gets direct play, never a transcode. This is
// the assertion that keeps a working file from being re-encoded for nothing.
got = Resolve(Facts("mp4", "h264", "aac"), []string{"html5"}, true)
if !got.Playable || got.Engine == nil || *got.Engine == TranscodeID {
t.Errorf("a direct-playable file chose %v; direct play must always win", got.Engine)
}
// With no capability the answer is exactly what it was before ADR-008: the
// blocker, with a machine-readable reason for the UI.
got = Resolve(hevc, []string{"html5"}, false)
if got.Playable {
t.Errorf("Resolve(hevc, html5, no transcode) = playable; want the blocker")
}
if got.Reason == "" || got.Message == "" {
t.Errorf("want a reason and a message when nothing can play it, got %+v", got)
}
}