camera 即時推論開不了根因:vendor decoder-only ffmpeg --disable-everything 沒 enable 任何 input device → macOS avfoundation 認不得。 - WP-1 macOS:ffmpeg rebuild 加 --enable-avfoundation + --enable-indev=avfoundation (--disable-autodetect 會靜默 disable、兩行要一起帶)。-list_devices 列出相機、 +22KB、LGPL-safe。sha 已核對。 - WP-2:buildCaptureArgs 補 Linux v4l2 分支(原誤落 avfoundation default 必壞)+ ListFFmpegDevices Linux glob /dev/video*。四路明確 case + 回歸鎖。 reviewer WP-1(0C/0M/0m) + WP-2(0C/0M/2m) 通過。Windows dshow / Linux v4l2 實機驗待機器。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
193 lines
5.2 KiB
Go
193 lines
5.2 KiB
Go
package camera
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// TestBuildCaptureArgsForOS 驗證三平台各自用正確的 ffmpeg input device(indev):
|
||
// macOS→avfoundation、Windows→dshow、Linux→v4l2。這是 ADR-020 的核心修復:
|
||
// 原本 Linux 會誤落 default 分支用 avfoundation → 必壞。
|
||
func TestBuildCaptureArgsForOS(t *testing.T) {
|
||
const (
|
||
width = 640
|
||
height = 480
|
||
fps = 30
|
||
index = 0
|
||
)
|
||
|
||
tests := []struct {
|
||
name string
|
||
goos string
|
||
cameraName string
|
||
// wantInputFlag 是預期的 "-f <indev>" 值
|
||
wantIndev string
|
||
// wantInputArg 是 "-i" 後面的值
|
||
wantInputArg string
|
||
}{
|
||
{
|
||
name: "macOS uses avfoundation with index:none",
|
||
goos: "darwin",
|
||
wantIndev: "avfoundation",
|
||
wantInputArg: "0:none",
|
||
},
|
||
{
|
||
name: "Windows uses dshow with video=name",
|
||
goos: "windows",
|
||
cameraName: "Integrated Camera",
|
||
wantIndev: "dshow",
|
||
wantInputArg: "video=Integrated Camera",
|
||
},
|
||
{
|
||
name: "Linux uses v4l2 with /dev/video path",
|
||
goos: "linux",
|
||
wantIndev: "v4l2",
|
||
wantInputArg: "/dev/video0",
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
args := buildCaptureArgsForOS(tt.goos, index, tt.cameraName, width, height, fps)
|
||
|
||
gotIndev := valueAfterFlag(args, "-f") // 第一個 -f 是 input format
|
||
if gotIndev != tt.wantIndev {
|
||
t.Errorf("indev = %q, want %q\nargs: %v", gotIndev, tt.wantIndev, args)
|
||
}
|
||
|
||
gotInput := valueAfterFlag(args, "-i")
|
||
if gotInput != tt.wantInputArg {
|
||
t.Errorf("input = %q, want %q\nargs: %v", gotInput, tt.wantInputArg, args)
|
||
}
|
||
|
||
// 三平台後段皆須為 image2pipe / mjpeg,MJPEG pipe 架構共用。
|
||
if !containsSeq(args, "-f", "image2pipe") {
|
||
t.Errorf("missing image2pipe output, args: %v", args)
|
||
}
|
||
if !containsSeq(args, "-vcodec", "mjpeg") {
|
||
t.Errorf("missing mjpeg vcodec, args: %v", args)
|
||
}
|
||
if args[len(args)-1] != "-" {
|
||
t.Errorf("last arg should be stdout '-', got %q", args[len(args)-1])
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestBuildCaptureArgsForOS_LinuxIndexToDevicePath 驗證 Linux 的 cameraIndex 正確
|
||
// 對應到 /dev/video<N> 節點路徑。
|
||
func TestBuildCaptureArgsForOS_LinuxIndexToDevicePath(t *testing.T) {
|
||
for _, idx := range []int{0, 1, 2, 10} {
|
||
args := buildCaptureArgsForOS("linux", idx, "", 640, 480, 30)
|
||
want := "/dev/video" + itoa(idx)
|
||
if got := valueAfterFlag(args, "-i"); got != want {
|
||
t.Errorf("index %d → input %q, want %q", idx, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestBuildCaptureArgsForOS_LinuxNotAVFoundation 是 ADR-020 的回歸鎖:Linux 絕不能
|
||
// 用 avfoundation(macOS 專用)。若未來有人把 Linux case 拿掉、讓它落回 default,
|
||
// 這個測試會抓到。
|
||
func TestBuildCaptureArgsForOS_LinuxNotAVFoundation(t *testing.T) {
|
||
args := buildCaptureArgsForOS("linux", 0, "", 640, 480, 30)
|
||
joined := strings.Join(args, " ")
|
||
if strings.Contains(joined, "avfoundation") {
|
||
t.Fatalf("Linux must NOT use avfoundation (macOS-only), args: %v", args)
|
||
}
|
||
if !strings.Contains(joined, "v4l2") {
|
||
t.Fatalf("Linux must use v4l2, args: %v", args)
|
||
}
|
||
}
|
||
|
||
// TestParseV4L2Devices 驗證 /dev/video* 路徑清單解析成 CameraInfo,且依 index 數值
|
||
// 排序(video10 排在 video2 之後、而非字典序)。
|
||
func TestParseV4L2Devices(t *testing.T) {
|
||
paths := []string{
|
||
"/dev/video10",
|
||
"/dev/video2",
|
||
"/dev/video0",
|
||
"/dev/video-not-a-number", // 應被略過
|
||
"/dev/videoX", // 應被略過
|
||
}
|
||
|
||
got := parseV4L2Devices(paths)
|
||
|
||
wantIndexes := []int{0, 2, 10}
|
||
if len(got) != len(wantIndexes) {
|
||
t.Fatalf("got %d devices, want %d: %+v", len(got), len(wantIndexes), got)
|
||
}
|
||
for i, want := range wantIndexes {
|
||
if got[i].Index != want {
|
||
t.Errorf("device[%d].Index = %d, want %d", i, got[i].Index, want)
|
||
}
|
||
if got[i].Name != "/dev/video"+itoa(want) {
|
||
t.Errorf("device[%d].Name = %q, want /dev/video%d", i, got[i].Name, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestParseV4L2Devices_Empty 驗證無裝置時回 nil/空。
|
||
func TestParseV4L2Devices_Empty(t *testing.T) {
|
||
if got := parseV4L2Devices(nil); len(got) != 0 {
|
||
t.Errorf("expected no devices, got %+v", got)
|
||
}
|
||
}
|
||
|
||
// TestV4L2Index 驗證從路徑取 index。
|
||
func TestV4L2Index(t *testing.T) {
|
||
cases := map[string]int{
|
||
"/dev/video0": 0,
|
||
"/dev/video12": 12,
|
||
"/dev/video": -1,
|
||
"/dev/videoab": -1,
|
||
"videoX": -1,
|
||
}
|
||
for path, want := range cases {
|
||
if got := v4l2Index(path); got != want {
|
||
t.Errorf("v4l2Index(%q) = %d, want %d", path, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- helpers ---
|
||
|
||
// valueAfterFlag 回傳 args 中第一個等於 flag 的元素的下一個值。
|
||
func valueAfterFlag(args []string, flag string) string {
|
||
for i := 0; i < len(args)-1; i++ {
|
||
if args[i] == flag {
|
||
return args[i+1]
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// containsSeq 判斷 args 是否包含連續的 a b 兩個元素。
|
||
func containsSeq(args []string, a, b string) bool {
|
||
for i := 0; i < len(args)-1; i++ {
|
||
if args[i] == a && args[i+1] == b {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func itoa(n int) string {
|
||
if n == 0 {
|
||
return "0"
|
||
}
|
||
neg := n < 0
|
||
if neg {
|
||
n = -n
|
||
}
|
||
var buf []byte
|
||
for n > 0 {
|
||
buf = append([]byte{byte('0' + n%10)}, buf...)
|
||
n /= 10
|
||
}
|
||
if neg {
|
||
buf = append([]byte{'-'}, buf...)
|
||
}
|
||
return string(buf)
|
||
}
|