package generator import ( "strings" "testing" "mxl-pattern-generator/assets" "golang.org/x/image/font" ) func testFace(t *testing.T, size float64) font.Face { t.Helper() face, err := NewFace(assets.JetBrainsMono, size) if err != nil { t.Fatalf("LoadFace: %v", err) } t.Cleanup(func() { face.Close() }) return face } func TestNewTextOverlay(t *testing.T) { const frameW, frameH = 1920, 1080 o, err := NewTextOverlay("AB", frameW, frameH, 0, 0, "", testFace(t, 48)) if err != nil { t.Fatalf("NewTextOverlay: %v", err) } if o.w%6 != 0 { t.Fatalf("w=%d not a multiple of 6", o.w) } if o.x%6 != 0 { t.Fatalf("x=%d not a multiple of 6", o.x) } if o.x != 0 || o.y != 0 { t.Fatalf("position = (%d,%d), want (0,0)", o.x, o.y) } if o.blocks == nil || len(o.blocks) != o.h*(o.w/6)*16 { t.Fatalf("pre-packed tile has wrong size") } if o.cov[2*o.w+2] != 0 { t.Fatalf("padding pixel (2,2) covered: %d", o.cov[2*o.w+2]) } nonzero, full := 0, 0 for _, c := range o.cov { if c > 0 { nonzero++ } if c == 255 { full++ } } if nonzero < 100 { t.Fatalf("too few covered pixels: %d", nonzero) } if full == 0 { t.Fatalf("no fully covered (255) pixels") } // Text without descenders ("AB") must leave the bottom quarter empty; // text with a descender ("Ag") must have coverage there. for _, tc := range []struct { text string wantAny bool }{ {"AB", false}, {"Ag", true}, } { o, err := NewTextOverlay(tc.text, frameW, frameH, 0, 0, "", testFace(t, 48)) if err != nil { t.Fatalf("NewTextOverlay(%q): %v", tc.text, err) } any := false for row := o.h * 3 / 4; row < o.h; row++ { for col := 0; col < o.w; col++ { if o.cov[row*o.w+col] > 0 { any = true } } } if any != tc.wantAny { t.Fatalf("%q: coverage in bottom quarter = %v, want %v", tc.text, any, tc.wantAny) } } } func TestNewTextOverlayPositioning(t *testing.T) { const frameW, frameH = 1920, 1080 face := testFace(t, 48) t.Run("explicit", func(t *testing.T) { o, err := NewTextOverlay("AB", frameW, frameH, 120, 48, "", face) if err != nil { t.Fatalf("NewTextOverlay: %v", err) } if o.x != 120 || o.y != 48 { t.Fatalf("position = (%d,%d), want (120,48)", o.x, o.y) } }) for _, tc := range []struct { name string pos string want func(*TextOverlay) bool }{ {name: "top center", pos: "tc", want: func(o *TextOverlay) bool { return o.x%6 == 0 && absInt((o.x+o.w/2)-frameW/2) <= 3 && o.y == 0 }}, {name: "center", pos: "cc", want: func(o *TextOverlay) bool { return o.x%6 == 0 && absInt((o.x+o.w/2)-frameW/2) <= 3 && o.y == (frameH-o.h)/2 }}, {name: "bottom right", pos: "br", want: func(o *TextOverlay) bool { return o.x+o.w == frameW && o.y+o.h == frameH }}, } { t.Run(tc.name, func(t *testing.T) { o, err := NewTextOverlay("AB", frameW, frameH, 0, 0, tc.pos, face) if err != nil { t.Fatalf("NewTextOverlay: %v", err) } if !tc.want(o) { t.Fatalf("unexpected %s position: box=(%d,%d %dx%d)", tc.pos, o.x, o.y, o.w, o.h) } }) } } func TestNewTextOverlayRejectsInvalidPosition(t *testing.T) { const frameW, frameH = 1920, 1080 face := testFace(t, 48) tests := []struct { name string x, y int wantErrSub string }{ {name: "unaligned x", x: 7, y: 0, wantErrSub: "divisible by 6"}, {name: "negative x", x: -6, y: 0, wantErrSub: "must not be negative"}, {name: "right overflow", x: 1902, y: 0, wantErrSub: "does not fit"}, {name: "bottom overflow", x: 0, y: 1070, wantErrSub: "does not fit"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, err := NewTextOverlay("AB", frameW, frameH, tc.x, tc.y, "", face) if err == nil || !strings.Contains(err.Error(), tc.wantErrSub) { t.Fatalf("error = %v, want substring %q", err, tc.wantErrSub) } }) } } func absInt(v int) int { if v < 0 { return -v } return v } func TestTextOverlayApplyV210(t *testing.T) { const frameW, frameH = 1920, 1080 o, err := NewTextOverlay("MXL", frameW, frameH, 0, 0, "", testFace(t, 48)) if err != nil { t.Fatalf("NewTextOverlay: %v", err) } frame := make([]byte, V210FrameSize(frameW, frameH)) for i := range frame { frame[i] = 0x5A // marker: untouched regions must survive } if err := o.ApplyV210(frame); err != nil { t.Fatalf("ApplyV210: %v", err) } // Every box pixel: Y from coverage, neutral chroma at even columns. for row := 0; row < o.h; row++ { for col := 0; col < o.w; col++ { wantY := uint32(64 + (int(o.cov[row*o.w+col])*876+127)/255) yc, cb, cr := sampleV210(frame, frameW, o.x+col, o.y+row) if yc != wantY { t.Fatalf("box pixel (%d,%d): Y=%d, want %d", o.x+col, o.y+row, yc, wantY) } if (o.x+col)%2 == 0 && (cb != 512 || cr != 512) { t.Fatalf("box pixel (%d,%d): Cb=%d Cr=%d, want 512/512", o.x+col, o.y+row, cb, cr) } } } // Blocks just outside the box (right edge and below) must be untouched. for _, p := range [][2]int{ {o.x + o.w, o.y}, // right of box, block-aligned {o.x, o.y + o.h}, // below box {o.x + o.w, o.y + o.h}, // corner {100, 1000}, // far away } { off := p[1]*V210LineSize(frameW) + p[0]/6*16 for i := 0; i < 16; i++ { if frame[off+i] != 0x5A { t.Fatalf("block at (%d,%d) modified outside the box", p[0], p[1]) } } } }