v0.5.0: manifests, filters, logging, docs
pipeline / build (push) Has been cancelled
pipeline / test (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 00:00:06 +02:00
parent 3d3c4a4742
commit 2e73d01b40
33 changed files with 7238 additions and 512 deletions
+39
View File
@@ -0,0 +1,39 @@
package manifest
import (
"encoding/json"
"os"
"sync"
)
type fileLogWriter struct {
mu sync.Mutex
f *os.File
}
func NewFileLogWriter(path string) (LogWriter, error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, err
}
return &fileLogWriter{f: f}, nil
}
func (w *fileLogWriter) Log(e LogEntry) {
data, _ := json.Marshal(e)
w.mu.Lock()
defer w.mu.Unlock()
if w.f != nil {
w.f.Write(data)
w.f.Write([]byte("\n"))
}
}
func (w *fileLogWriter) Close() {
w.mu.Lock()
defer w.mu.Unlock()
if w.f != nil {
w.f.Close()
w.f = nil
}
}
+139
View File
@@ -0,0 +1,139 @@
package manifest
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
)
type jsonlManifest struct {
mu sync.Mutex
entries map[string]Entry
path string
file *os.File
syncFunc func() error
}
var jsonlSaveHook func() error
func SetJSONLSaveHook(fn func() error) func() error {
old := jsonlSaveHook
jsonlSaveHook = fn
return old
}
func JSONLPath(dir string) string {
return filepath.Join(dir, "downloads.jsonl")
}
func LoadJSONL(dir string) *jsonlManifest {
m := &jsonlManifest{
entries: make(map[string]Entry),
path: JSONLPath(dir),
}
data, err := os.ReadFile(m.path)
if err != nil {
return m
}
type entryWithID struct {
ID string `json:"id"`
Filename string `json:"filename"`
Size int64 `json:"size"`
Cloud string `json:"cloud"`
Exported int64 `json:"exported"`
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var raw entryWithID
if json.Unmarshal([]byte(line), &raw) == nil && raw.ID != "" {
m.entries[raw.ID] = Entry{
Filename: raw.Filename,
Size: raw.Size,
Cloud: raw.Cloud,
Exported: raw.Exported,
}
}
}
return m
}
func (m *jsonlManifest) Has(id string) bool {
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.entries[id]
return ok
}
func (m *jsonlManifest) Add(id string, filename string, size int64, cloud string) {
m.mu.Lock()
defer m.mu.Unlock()
entry := newEntry(id, filename, size, cloud)
m.entries[id] = entry
if m.file != nil {
data, _ := json.Marshal(struct {
ID string `json:"id"`
Filename string `json:"filename"`
Size int64 `json:"size"`
Cloud string `json:"cloud"`
Exported int64 `json:"exported"`
}{ID: id, Filename: entry.Filename, Size: entry.Size, Cloud: entry.Cloud, Exported: entry.Exported})
m.file.Write(data)
m.file.Write([]byte("\n"))
}
}
func (m *jsonlManifest) Save() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.syncFunc != nil {
return m.syncFunc()
}
if jsonlSaveHook != nil {
return jsonlSaveHook()
}
if m.file != nil {
return m.file.Sync()
}
return nil
}
func (m *jsonlManifest) Close() {
m.mu.Lock()
defer m.mu.Unlock()
if m.file != nil {
m.file.Close()
m.file = nil
}
}
func (m *jsonlManifest) OpenAppend() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.file != nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(m.path), 0755); err != nil {
return err
}
f, err := os.OpenFile(m.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
m.file = f
return nil
}
func (m *jsonlManifest) Entries() map[string]Entry {
m.mu.Lock()
defer m.mu.Unlock()
out := make(map[string]Entry, len(m.entries))
for k, v := range m.entries {
out[k] = v
}
return out
}
+30
View File
@@ -0,0 +1,30 @@
package manifest
type LogEntry struct {
Timestamp int64 `json:"ts"`
Level string `json:"level"`
Event string `json:"event"`
AssetID string `json:"asset_id,omitempty"`
Album string `json:"album,omitempty"`
Filename string `json:"filename,omitempty"`
Size int64 `json:"size,omitempty"`
Cloud string `json:"cloud,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
Message string `json:"message,omitempty"`
}
type LogWriter interface {
Log(entry LogEntry)
Close()
}
type noopLogWriter struct{}
func (noopLogWriter) Log(LogEntry) { _ = struct{}{} }
func (noopLogWriter) Close() { _ = struct{}{} }
var NoopLogWriter LogWriter = noopLogWriter{}
func LogPath(dir string) string {
return dir + "/export.log"
}
+211
View File
@@ -0,0 +1,211 @@
package manifest
import (
"database/sql"
"os"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
func TestNoopLogWriter(t *testing.T) {
lw := NoopLogWriter
lw.Log(LogEntry{Event: "test"})
lw.Close()
noopLogWriter{}.Log(LogEntry{Event: "test"})
noopLogWriter{}.Close()
}
func TestNewSQLiteLogWriter(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
lw, err := NewSQLiteLogWriter(db)
if err != nil {
t.Fatal(err)
}
lw.Log(LogEntry{
Timestamp: 1700000000,
Level: "info",
Event: "export_done",
AssetID: "asset-1",
Album: "Favorites",
Filename: "photo.jpg",
Size: 1024,
Cloud: "local",
DurationMs: 500,
Message: "",
})
lw.Log(LogEntry{
Timestamp: 1700000001,
Level: "error",
Event: "export_fail",
AssetID: "asset-2",
Filename: "bad.jpg",
Message: "timeout",
})
lw.Close()
var count int
err = db.QueryRow(`SELECT COUNT(*) FROM logs`).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 2 {
t.Errorf("expected 2 log entries, got %d", count)
}
var event, level, assetID string
err = db.QueryRow(`SELECT event, level, asset_id FROM logs WHERE asset_id = 'asset-1'`).Scan(&event, &level, &assetID)
if err != nil {
t.Fatal(err)
}
if event != "export_done" || level != "info" || assetID != "asset-1" {
t.Errorf("unexpected row: event=%s level=%s asset_id=%s", event, level, assetID)
}
}
func TestSQLiteLogWriterNilDB(t *testing.T) {
w := &sqliteLogWriter{db: nil}
w.Log(LogEntry{Event: "test"})
w.Close()
}
func TestNewSQLiteLogWriterError(t *testing.T) {
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
if _, err := NewSQLiteLogWriter(db); err == nil {
t.Error("expected error for closed db")
}
}
func TestSQLiteLogWriterCloseConcrete(t *testing.T) {
(&sqliteLogWriter{}).Close()
}
func TestNewFileLogWriter(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "export.log")
lw, err := NewFileLogWriter(path)
if err != nil {
t.Fatal(err)
}
lw.Log(LogEntry{
Timestamp: 1700000000,
Level: "info",
Event: "export_done",
Filename: "photo.jpg",
Size: 2048,
})
lw.Close()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if len(data) == 0 {
t.Error("expected log data")
}
if data[len(data)-1] != '\n' {
t.Error("expected trailing newline")
}
}
func TestFileLogWriterClosed(t *testing.T) {
w := &fileLogWriter{f: nil}
w.Log(LogEntry{Event: "test"})
w.Close()
}
func TestNewFileLogWriterError(t *testing.T) {
_, err := NewFileLogWriter("/nonexistent/dir/export.log")
if err == nil {
t.Error("expected error for bad path")
}
}
func TestFileLogWriterDoubleClose(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "export.log")
lw, err := NewFileLogWriter(path)
if err != nil {
t.Fatal(err)
}
lw.Close()
lw.Close()
}
func TestLogPath(t *testing.T) {
p := LogPath("/tmp/out")
if p != "/tmp/out/export.log" {
t.Errorf("expected /tmp/out/export.log, got %s", p)
}
}
func TestOpenLogWriterSQLite(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
defer m.Close()
lw, err := OpenLogWriter(m, dir)
if err != nil {
t.Fatal(err)
}
lw.Log(LogEntry{Event: "test", Level: "info"})
lw.Close()
}
func TestOpenLogWriterJSONL(t *testing.T) {
dir := t.TempDir()
m := LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
defer m.Close()
lw, err := OpenLogWriter(m, dir)
if err != nil {
t.Fatal(err)
}
lw.Log(LogEntry{Event: "test", Level: "info"})
lw.Close()
if _, err := os.Stat(LogPath(dir)); os.IsNotExist(err) {
t.Error("expected export.log to exist")
}
}
func TestOpenLogWriterNilManifest(t *testing.T) {
dir := t.TempDir()
lw, err := OpenLogWriter(nil, dir)
if err != nil {
t.Fatal(err)
}
lw.Log(LogEntry{Event: "test", Level: "info"})
lw.Close()
if _, err := os.Stat(LogPath(dir)); os.IsNotExist(err) {
t.Error("expected export.log to exist for nil manifest")
}
}
+33
View File
@@ -0,0 +1,33 @@
package manifest
import "time"
type Entry struct {
ID string
Filename string
Size int64
Cloud string
Exported int64
}
type Manifest interface {
Has(id string) bool
Add(id string, filename string, size int64, cloud string)
Save() error
Close()
OpenAppend() error
}
type EntryReader interface {
Entries() map[string]Entry
}
func newEntry(id, filename string, size int64, cloud string) Entry {
return Entry{
ID: id,
Filename: filename,
Size: size,
Cloud: cloud,
Exported: time.Now().Unix(),
}
}
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
package manifest
import (
"fmt"
"os"
"strings"
)
type Format string
const (
FormatJSONL Format = "jsonl"
FormatSQLite Format = "sqlite"
)
func Open(dir string, format Format) (Manifest, error) {
jsonlPath := JSONLPath(dir)
sqlitePath := SQLitePath(dir)
jsonlExists := FileExists(jsonlPath)
sqliteExists := FileExists(sqlitePath)
switch {
case format == FormatJSONL && jsonlExists:
return LoadJSONL(dir), nil
case format == FormatSQLite && sqliteExists:
return LoadSQLite(dir)
case format == FormatJSONL && sqliteExists:
return ConvertFromSQLite(dir)
case format == FormatSQLite && jsonlExists:
return ConvertFromJSONL(dir)
default:
if format == FormatJSONL {
return LoadJSONL(dir), nil
}
return LoadSQLite(dir)
}
}
func ConvertFromJSONL(dir string) (Manifest, error) {
src := LoadJSONL(dir)
if err := src.OpenAppend(); err != nil {
return nil, fmt.Errorf("open jsonl for read: %w", err)
}
defer src.Close()
dst, _ := LoadSQLite(dir)
if err := dst.OpenAppend(); err != nil {
return nil, fmt.Errorf("open sqlite for write: %w", err)
}
for id, e := range src.Entries() {
dst.Add(id, e.Filename, e.Size, e.Cloud)
}
os.Remove(JSONLPath(dir))
return dst, nil
}
func ConvertFromSQLite(dir string) (Manifest, error) {
src, _ := LoadSQLite(dir)
if err := src.OpenAppend(); err != nil {
return nil, fmt.Errorf("open sqlite for read: %w", err)
}
defer src.Close()
dst := LoadJSONL(dir)
if err := dst.OpenAppend(); err != nil {
return nil, fmt.Errorf("open jsonl for write: %w", err)
}
for id, e := range src.Entries() {
dst.Add(id, e.Filename, e.Size, e.Cloud)
}
if err := dst.Save(); err != nil {
return nil, fmt.Errorf("save jsonl: %w", err)
}
os.Remove(SQLitePath(dir))
return dst, nil
}
func FileExists(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return !info.IsDir()
}
func ParseFormat(s string) (Format, error) {
switch strings.ToLower(s) {
case "jsonl", "json":
return FormatJSONL, nil
case "sqlite", "db", "sqlite3":
return FormatSQLite, nil
default:
return "", fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", s)
}
}
func OpenLogWriter(m Manifest, dir string) (LogWriter, error) {
if sm, ok := m.(*sqliteManifest); ok && sm.DB() != nil {
return NewSQLiteLogWriter(sm.DB())
}
return NewFileLogWriter(LogPath(dir))
}
+134
View File
@@ -0,0 +1,134 @@
package manifest
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
type sqliteManifest struct {
path string
db *sql.DB
open sqlOpenerFunc
execFunc func(query string, args ...any) (sql.Result, error)
}
type sqlOpenerFunc func(driverName, dataSourceName string) (*sql.DB, error)
var sqliteOpenFunc sqlOpenerFunc
func defaultSQLOpener() sqlOpenerFunc {
if sqliteOpenFunc != nil {
return sqliteOpenFunc
}
return sql.Open
}
func SQLitePath(dir string) string {
return filepath.Join(dir, "downloads.db")
}
func LoadSQLite(dir string) (*sqliteManifest, error) {
m := &sqliteManifest{
path: SQLitePath(dir),
open: defaultSQLOpener(),
}
return m, nil
}
func (m *sqliteManifest) OpenAppend() error {
if m.db != nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(m.path), 0755); err != nil {
return err
}
opener := m.open
if opener == nil {
opener = sql.Open
}
db, err := opener("sqlite", m.path)
if err != nil {
return fmt.Errorf("open sqlite: %w", err)
}
execFn := m.execFunc
if execFn == nil {
execFn = db.Exec
}
_, err = execFn(`CREATE TABLE IF NOT EXISTS downloads (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
cloud TEXT NOT NULL DEFAULT '',
exported INTEGER NOT NULL DEFAULT 0
)`)
if err != nil {
db.Close()
return fmt.Errorf("create table: %w", err)
}
_, err = execFn(`CREATE INDEX IF NOT EXISTS idx_downloads_id ON downloads(id)`)
if err != nil {
db.Close()
return fmt.Errorf("create index: %w", err)
}
m.db = db
return nil
}
func (m *sqliteManifest) Has(id string) bool {
if m.db == nil {
return false
}
var count int
err := m.db.QueryRow(`SELECT COUNT(*) FROM downloads WHERE id = ?`, id).Scan(&count)
if err != nil {
return false
}
return count > 0
}
func (m *sqliteManifest) Add(id string, filename string, size int64, cloud string) {
if m.db == nil {
return
}
entry := newEntry(id, filename, size, cloud)
m.db.Exec(`INSERT OR REPLACE INTO downloads (id, filename, size, cloud, exported) VALUES (?, ?, ?, ?, ?)`,
id, entry.Filename, entry.Size, entry.Cloud, entry.Exported)
}
func (m *sqliteManifest) Save() error {
return nil
}
func (m *sqliteManifest) Close() {
if m.db != nil {
m.db.Close()
m.db = nil
}
}
func (m *sqliteManifest) DB() *sql.DB {
return m.db
}
func (m *sqliteManifest) Entries() map[string]Entry {
if m.db == nil {
return nil
}
out := make(map[string]Entry)
rows, err := m.db.Query(`SELECT id, filename, size, cloud, exported FROM downloads`)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var e Entry
if err := rows.Scan(&e.ID, &e.Filename, &e.Size, &e.Cloud, &e.Exported); err == nil {
out[e.ID] = e
}
}
return out
}
+402
View File
@@ -0,0 +1,402 @@
package manifest
import (
"database/sql"
"fmt"
"os"
"strings"
"testing"
_ "modernc.org/sqlite"
)
func TestSetJSONLSaveHook(t *testing.T) {
old := SetJSONLSaveHook(func() error { return fmt.Errorf("hook error") })
if old != nil {
t.Error("expected nil old hook")
}
restore := SetJSONLSaveHook(old)
if restore == nil {
t.Error("expected non-nil restore function")
}
}
func TestJSONLSaveHookError(t *testing.T) {
dir := t.TempDir()
m := LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
old := SetJSONLSaveHook(func() error { return fmt.Errorf("hook error") })
defer SetJSONLSaveHook(old)
if err := m.Save(); err == nil {
t.Error("expected hook error from Save")
}
m.Close()
}
func TestJSONLSyncFuncError(t *testing.T) {
dir := t.TempDir()
m := LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.syncFunc = func() error { return fmt.Errorf("sync func error") }
if err := m.Save(); err == nil {
t.Error("expected syncFunc error from Save")
}
m.Close()
}
func TestSQLiteOpenAppendSQLError(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
return nil, fmt.Errorf("simulated open error")
}
if err := m.OpenAppend(); err == nil {
t.Error("expected error from sql.Open failure")
}
}
func TestSQLiteOpenAppendCreateTableError(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
realOpen := m.open
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
db, err := realOpen(driverName, dataSourceName)
if err != nil {
return nil, err
}
db.Close()
return db, nil
}
if err := m.OpenAppend(); err == nil {
t.Error("expected error from closed DB CREATE TABLE")
}
}
func TestSQLiteHasAfterClose(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.Close()
if m.Has("x1") {
t.Error("Has should return false after Close")
}
}
func TestSQLiteEntriesAfterClose(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.Close()
entries := m.Entries()
if entries != nil {
t.Errorf("Entries should return nil after Close, got %d entries", len(entries))
}
}
func TestSQLiteOpenAppendMkdirAllError(t *testing.T) {
m, err := LoadSQLite("/proc/cannot-write")
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err == nil {
t.Error("expected error creating dir under /proc")
m.Close()
}
}
func TestSQLiteOpenAppendNilOpener(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
m.open = nil
if err := m.OpenAppend(); err != nil {
t.Errorf("expected nil opener to use sql.Open fallback, got err: %v", err)
}
m.Close()
}
func TestSQLiteOpenAppendCreateIndexError(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
realOpen := m.open
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
db, err := realOpen(driverName, dataSourceName)
if err != nil {
return nil, err
}
db.Close()
return db, nil
}
if err := m.OpenAppend(); err == nil {
t.Error("expected error from closed DB")
}
}
func TestSQLiteHasQueryError(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
closedDB := m.db
closedDB.Close()
result := m.Has("x1")
if result {
t.Error("Has should return false with broken DB connection")
}
}
func TestSQLiteEntriesQueryError(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
closedDB := m.db
closedDB.Close()
entries := m.Entries()
if entries == nil {
t.Error("Entries should return non-nil map with broken DB connection")
}
if len(entries) != 0 {
t.Errorf("Entries should be empty with broken DB connection, got %d", len(entries))
}
}
func TestSQLiteHasQueryErrorWithOpenDB(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
closedDB := m.db
closedDB.Close()
if m.Has("x1") {
t.Error("Has should return false with closed DB")
}
}
func TestSQLiteEntriesQueryErrorWithOpenDB(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
closedDB := m.db
closedDB.Close()
entries := m.Entries()
if entries != nil && len(entries) != 0 {
t.Errorf("Entries should be empty with closed DB, got %d", len(entries))
}
}
func TestSQLiteCreateIndexError(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
realOpen := m.open
callCount := 0
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
db, err := realOpen(driverName, dataSourceName)
if err != nil {
return nil, err
}
m.execFunc = func(query string, args ...any) (sql.Result, error) {
callCount++
if callCount == 2 {
return nil, fmt.Errorf("injected CREATE INDEX error")
}
return db.Exec(query, args...)
}
return db, nil
}
if err := m.OpenAppend(); err == nil {
t.Error("expected error from CREATE INDEX")
m.Close()
}
}
func TestConvertFromJSONLOpenAppendSQLError(t *testing.T) {
dir := t.TempDir()
m := LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.Close()
oldOpen := sqliteOpenFunc
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
return nil, fmt.Errorf("simulated sqlite open error")
}
defer func() { sqliteOpenFunc = oldOpen }()
_, err := ConvertFromJSONL(dir)
if err == nil {
t.Error("expected error from dst.OpenAppend during ConvertFromJSONL")
}
}
func TestConvertFromJSONLDstOpenAppendError(t *testing.T) {
dir := t.TempDir()
m := LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.Close()
realOpen := defaultSQLOpener()
oldOpen := sqliteOpenFunc
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
db, err := realOpen(driverName, dataSourceName)
if err != nil {
return nil, err
}
db.Close()
return db, nil
}
defer func() { sqliteOpenFunc = oldOpen }()
_, err := ConvertFromJSONL(dir)
if err == nil {
t.Error("expected error from dst.OpenAppend during ConvertFromJSONL")
}
}
func TestConvertFromSQLiteSrcOpenAppendError(t *testing.T) {
dir := t.TempDir()
src, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := src.OpenAppend(); err != nil {
t.Fatal(err)
}
src.Add("x1", "photo.jpg", 1024, "local")
src.Close()
realOpen := defaultSQLOpener()
callCount := 0
oldOpen := sqliteOpenFunc
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
callCount++
db, err := realOpen(driverName, dataSourceName)
if err != nil {
return nil, err
}
if callCount > 0 {
db.Close()
}
return db, nil
}
defer func() { sqliteOpenFunc = oldOpen }()
_, err = ConvertFromSQLite(dir)
if err == nil {
t.Error("expected error from src.OpenAppend during ConvertFromSQLite")
}
}
func TestConvertFromSQLiteDstOpenAppendError(t *testing.T) {
dir := t.TempDir()
src, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := src.OpenAppend(); err != nil {
t.Fatal(err)
}
src.Add("x1", "photo.jpg", 1024, "local")
src.Close()
jsonlPath := JSONLPath(dir)
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatal(err)
}
f.Close()
os.Chmod(jsonlPath, 0444)
defer os.Chmod(jsonlPath, 0644)
_, err = ConvertFromSQLite(dir)
if err == nil {
t.Error("expected error from dst.OpenAppend during ConvertFromSQLite")
}
}
func TestJSONLSaveError(t *testing.T) {
dir := t.TempDir()
m := LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.file.Close()
if err := m.Save(); err == nil {
t.Error("expected Sync error on closed file")
}
m.Close()
}
func TestConvertFromSQLiteSaveError(t *testing.T) {
dir := t.TempDir()
src, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := src.OpenAppend(); err != nil {
t.Fatal(err)
}
src.Add("x1", "photo.jpg", 1024, "local")
src.Close()
oldHook := jsonlSaveHook
jsonlSaveHook = func() error { return fmt.Errorf("simulated sync error") }
defer func() { jsonlSaveHook = oldHook }()
_, err = ConvertFromSQLite(dir)
if err == nil {
t.Error("expected error from dst.Save during ConvertFromSQLite")
}
if err != nil && !strings.Contains(err.Error(), "save jsonl") {
t.Errorf("expected save jsonl error, got: %v", err)
}
}
+41
View File
@@ -0,0 +1,41 @@
package manifest
import (
"database/sql"
)
type sqliteLogWriter struct {
db *sql.DB
}
func NewSQLiteLogWriter(db *sql.DB) (LogWriter, error) {
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
level TEXT NOT NULL,
event TEXT NOT NULL,
asset_id TEXT,
album TEXT,
filename TEXT,
size INTEGER,
cloud TEXT,
duration_ms INTEGER,
message TEXT
)`)
if err != nil {
return nil, err
}
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts)`)
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_event ON logs(event)`)
return &sqliteLogWriter{db: db}, nil
}
func (w *sqliteLogWriter) Log(e LogEntry) {
if w.db == nil {
return
}
w.db.Exec(`INSERT INTO logs (ts, level, event, asset_id, album, filename, size, cloud, duration_ms, message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Timestamp, e.Level, e.Event, e.AssetID, e.Album, e.Filename, e.Size, e.Cloud, e.DurationMs, e.Message)
}
func (w *sqliteLogWriter) Close() { _ = w }
+2 -2
View File
@@ -10,9 +10,9 @@ type Bridge interface {
ListAlbums() ([]Album, error)
ListAssets(albumID string) ([]Asset, int, error)
ListTree() ([]CollectionNode, error)
ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error)
ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error)
ExportOriginal(assetID, outputDir string, index int) (ExportResult, error)
ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error)
ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error)
ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error)
Cancel()
IsCancelled() bool
+12 -8
View File
@@ -62,29 +62,29 @@ func (*CgoBridge) IsCancelled() bool {
return C.photos_request_is_cancelled() != 0
}
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
return exportPreviewWithSlot(assetID, outputDir, targetSize, index, -1)
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error) {
return exportPreviewWithSlot(assetID, outputDir, targetSize, quality, index, -1)
}
func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) {
return exportOriginalWithSlot(assetID, outputDir, index, -1)
}
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) {
return exportPreviewWithSlot(assetID, outputDir, targetSize, index, slotIndex)
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
return exportPreviewWithSlot(assetID, outputDir, targetSize, quality, index, slotIndex)
}
func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) {
return exportOriginalWithSlot(assetID, outputDir, index, slotIndex)
}
func exportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) {
func exportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index), C.int(slotIndex))
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(quality), C.int(index), C.int(slotIndex))
if cs == nil {
return ExportResult{}, errBridgeNil
}
@@ -117,8 +117,8 @@ func GetProgressSlots() []ExportProgressSlot {
ptr := (*C.export_progress_t)(unsafe.Pointer(uintptr(unsafe.Pointer(slots)) + uintptr(i)*unsafe.Sizeof(C.export_progress_t{})))
result[i] = ExportProgressSlot{
Active: ptr.active != 0,
Progress: float64(ptr.progress),
BytesDone: int64(ptr.bytes_done),
Progress: float64(ptr.progress),
BytesDone: int64(ptr.bytes_done),
BytesTotal: int64(ptr.bytes_total),
}
}
@@ -129,6 +129,10 @@ func ResetProgressSlots() {
C.photos_reset_progress_slots()
}
func GetProgressSlotCount() int {
return int(C.photos_get_progress_slot_count())
}
type ExportProgressSlot struct {
Active bool
Progress float64
+46 -15
View File
@@ -17,7 +17,11 @@ void photos_test_set_assets_null(void);
void photos_test_set_tree_null(void);
void photos_request_cancel(void);
void photos_test_set_export_preview_json(const char *json);
void photos_test_set_export_preview_json_null(void);
void photos_test_set_export_original_json(const char *json);
void photos_test_set_export_original_json_null(void);
void photos_test_set_progress_slot_count(int count);
void photos_test_set_progress_slots_null(int val);
*/
import "C"
@@ -27,15 +31,19 @@ type CgoBridge struct{}
var DefaultBridge Bridge = &CgoBridge{}
func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)) }
func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) }
func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) }
func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) }
func SetTestAlbumsNull() { C.photos_test_set_albums_null() }
func SetTestAssetsNull() { C.photos_test_set_assets_null() }
func SetTestTreeNull() { C.photos_test_set_tree_null() }
func SetTestExportPreviewJSON(json string) { C.photos_test_set_export_preview_json(C.CString(json)) }
func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)) }
func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) }
func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) }
func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) }
func SetTestAlbumsNull() { C.photos_test_set_albums_null() }
func SetTestAssetsNull() { C.photos_test_set_assets_null() }
func SetTestTreeNull() { C.photos_test_set_tree_null() }
func SetTestExportPreviewJSON(json string) { C.photos_test_set_export_preview_json(C.CString(json)) }
func SetTestExportPreviewJSONNull() { C.photos_test_set_export_preview_json_null() }
func SetTestExportOriginalJSON(json string) { C.photos_test_set_export_original_json(C.CString(json)) }
func SetTestExportOriginalJSONNull() { C.photos_test_set_export_original_json_null() }
func SetTestProgressSlotCount(count int) { C.photos_test_set_progress_slot_count(C.int(count)) }
func SetTestProgressSlotsNull(val int) { C.photos_test_set_progress_slots_null(C.int(val)) }
func (*CgoBridge) RequestAccess() error {
rc := C.photos_request_access()
@@ -82,28 +90,28 @@ func (*CgoBridge) IsCancelled() bool {
return C.photos_request_is_cancelled() != 0
}
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, index, -1)
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error) {
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, quality, index, -1)
}
func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) {
return exportOriginalWithSlotTest(assetID, outputDir, index, -1)
}
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) {
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, index, slotIndex)
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, quality, index, slotIndex)
}
func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) {
return exportOriginalWithSlotTest(assetID, outputDir, index, slotIndex)
}
func exportPreviewWithSlotTest(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) {
func exportPreviewWithSlotTest(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index), C.int(slotIndex))
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(quality), C.int(index), C.int(slotIndex))
if cs == nil {
return ExportResult{}, errBridgeNil
}
@@ -132,8 +140,31 @@ type ExportProgressSlot struct {
}
func GetProgressSlots() []ExportProgressSlot {
return nil
count := int(C.photos_get_progress_slot_count())
if count <= 0 {
return nil
}
cSlots := C.photos_get_progress_slots()
if cSlots == nil {
return nil
}
slots := make([]ExportProgressSlot, count)
for i := 0; i < count; i++ {
s := C.photos_get_progress_slot(cSlots, C.int(i))
slots[i] = ExportProgressSlot{
Active: s.active != 0,
Progress: float64(s.progress),
BytesDone: int64(s.bytes_done),
BytesTotal: int64(s.bytes_total),
}
}
return slots
}
func ResetProgressSlots() {
C.photos_reset_progress_slots()
}
func GetProgressSlotCount() int {
return int(C.photos_get_progress_slot_count())
}
+1 -1
View File
@@ -5,4 +5,4 @@ package photos
import "fmt"
var errAccessDenied = fmt.Errorf("photos access denied: grant Full Disk Access or Photos permission in System Settings > Privacy & Security")
var errBridgeNil = fmt.Errorf("bridge returned nil")
var errBridgeNil = fmt.Errorf("bridge returned nil")
+266 -25
View File
@@ -35,9 +35,9 @@ func TestParseAlbumsJSON(t *testing.T) {
wantErr: true,
},
{
name: "missing albums key",
json: `{}`,
want: []Album{},
name: "missing albums key",
json: `{}`,
want: []Album{},
},
}
@@ -66,23 +66,23 @@ func TestParseAlbumsJSON(t *testing.T) {
func TestParseAssetsJSON(t *testing.T) {
tests := []struct {
name string
json string
want []Asset
name string
json string
want []Asset
wantTotal int
wantErr bool
errMsg string
wantErr bool
errMsg string
}{
{
name: "empty assets",
json: `{"assets":[],"total":0}`,
want: []Asset{},
name: "empty assets",
json: `{"assets":[],"total":0}`,
want: []Asset{},
wantTotal: 0,
},
{
name: "single asset",
json: `{"assets":[{"id":"asset1","filename":"IMG_0001.JPG"}],"total":1}`,
want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}},
name: "single asset",
json: `{"assets":[{"id":"asset1","filename":"IMG_0001.JPG"}],"total":1}`,
want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}},
wantTotal: 1,
},
{
@@ -106,9 +106,9 @@ func TestParseAssetsJSON(t *testing.T) {
wantTotal: 1,
},
{
name: "multiple assets",
json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`,
want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}},
name: "multiple assets",
json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`,
want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}},
wantTotal: 3,
},
{
@@ -132,9 +132,9 @@ func TestParseAssetsJSON(t *testing.T) {
wantErr: true,
},
{
name: "empty error is not an error",
json: `{"error":"","assets":[{"id":"a1","filename":"IMG.JPG"}],"total":1}`,
want: []Asset{{ID: "a1", Filename: "IMG.JPG"}},
name: "empty error is not an error",
json: `{"error":"","assets":[{"id":"a1","filename":"IMG.JPG"}],"total":1}`,
want: []Asset{{ID: "a1", Filename: "IMG.JPG"}},
wantTotal: 1,
},
}
@@ -407,11 +407,11 @@ func TestErrBridgeNilMessage(t *testing.T) {
func TestParseExportResultJSON(t *testing.T) {
tests := []struct {
name string
json string
want ExportResult
wantErr bool
errMsg string
name string
json string
want ExportResult
wantErr bool
errMsg string
}{
{
name: "success",
@@ -488,3 +488,244 @@ func equalAsset(a, b Asset) bool {
}
return true
}
func TestCgoBridgeExportPreviewViaStub(t *testing.T) {
SetTestExportPreviewJSON(`{"filename":"0001_img.jpg","size":2048,"cloud":"local"}`)
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
bridge := &CgoBridge{}
result, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0)
if err != nil {
t.Fatal(err)
}
if result.Filename != "0001_img.jpg" {
t.Errorf("got filename %q, want %q", result.Filename, "0001_img.jpg")
}
if result.Size != 2048 {
t.Errorf("got size %d, want %d", result.Size, 2048)
}
if result.Cloud != "local" {
t.Errorf("got cloud %q, want %q", result.Cloud, "local")
}
}
func TestCgoBridgeExportPreviewWithSlotViaStub(t *testing.T) {
SetTestExportPreviewJSON(`{"filename":"slot_img.jpg","size":4096,"cloud":"cloud"}`)
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
bridge := &CgoBridge{}
result, err := bridge.ExportPreviewWithSlot("asset-1", "/tmp", 2048, 85, 0, 1)
if err != nil {
t.Fatal(err)
}
if result.Filename != "slot_img.jpg" {
t.Errorf("got filename %q", result.Filename)
}
if result.Cloud != "cloud" {
t.Errorf("got cloud %q, want %q", result.Cloud, "cloud")
}
}
func TestCgoBridgeExportOriginalViaStub(t *testing.T) {
SetTestExportOriginalJSON(`{"filename":"original.jpg","size":8192,"cloud":"local"}`)
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
bridge := &CgoBridge{}
result, err := bridge.ExportOriginal("asset-1", "/tmp", 0)
if err != nil {
t.Fatal(err)
}
if result.Filename != "original.jpg" {
t.Errorf("got filename %q, want %q", result.Filename, "original.jpg")
}
if result.Size != 8192 {
t.Errorf("got size %d, want %d", result.Size, 8192)
}
}
func TestCgoBridgeExportOriginalWithSlotViaStub(t *testing.T) {
SetTestExportOriginalJSON(`{"filename":"slot_orig.heic","size":16384,"cloud":"local"}`)
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
bridge := &CgoBridge{}
result, err := bridge.ExportOriginalWithSlot("asset-1", "/tmp", 0, 2)
if err != nil {
t.Fatal(err)
}
if result.Filename != "slot_orig.heic" {
t.Errorf("got filename %q", result.Filename)
}
}
func TestCgoBridgeExportPreviewErrorViaStub(t *testing.T) {
SetTestExportPreviewJSON(`{"error":"disk full","cloud":"local"}`)
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
bridge := &CgoBridge{}
_, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0)
if err == nil {
t.Fatal("expected error")
}
if err.Error() != "disk full" {
t.Errorf("got error %q, want %q", err.Error(), "disk full")
}
}
func TestCgoBridgeExportOriginalErrorViaStub(t *testing.T) {
SetTestExportOriginalJSON(`{"error":"write failed","cloud":"local"}`)
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
bridge := &CgoBridge{}
_, err := bridge.ExportOriginal("asset-1", "/tmp", 0)
if err == nil {
t.Fatal("expected error")
}
if err.Error() != "write failed" {
t.Errorf("got error %q, want %q", err.Error(), "write failed")
}
}
func TestCgoBridgeExportSkippedResult(t *testing.T) {
SetTestExportPreviewJSON(`{"filename":"skip.jpg","size":0,"cloud":"local","skipped":true}`)
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
bridge := &CgoBridge{}
result, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0)
if err != nil {
t.Fatal(err)
}
if !result.Skipped {
t.Error("expected Skipped to be true")
}
}
func TestCgoBridgeCancelAndIsCancelled(t *testing.T) {
bridge := &CgoBridge{}
if bridge.IsCancelled() {
t.Error("should not be cancelled initially")
}
bridge.Cancel()
if !bridge.IsCancelled() {
t.Error("should be cancelled after Cancel()")
}
}
func TestGetProgressSlotsReturnsSlots(t *testing.T) {
slots := GetProgressSlots()
if slots == nil {
t.Errorf("GetProgressSlots should return slots in test build")
}
if len(slots) != 16 {
t.Errorf("GetProgressSlots should return 16 slots, got %d", len(slots))
}
}
func TestResetProgressSlotsNoPanic(t *testing.T) {
ResetProgressSlots()
}
func TestGetProgressSlotCount(t *testing.T) {
count := GetProgressSlotCount()
if count != 16 {
t.Errorf("expected 16, got %d", count)
}
}
func TestCgoBridgeExportPreviewNilStub(t *testing.T) {
SetTestExportPreviewJSONNull()
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
bridge := &CgoBridge{}
_, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0)
if err == nil {
t.Fatal("expected errBridgeNil")
}
if err != errBridgeNil {
t.Errorf("got %v, want %v", err, errBridgeNil)
}
}
func TestCgoBridgeExportOriginalNilStub(t *testing.T) {
SetTestExportOriginalJSONNull()
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
bridge := &CgoBridge{}
_, err := bridge.ExportOriginal("asset-1", "/tmp", 0)
if err == nil {
t.Fatal("expected errBridgeNil")
}
if err != errBridgeNil {
t.Errorf("got %v, want %v", err, errBridgeNil)
}
}
func TestCgoBridgeExportPreviewWithSlotNilStub(t *testing.T) {
SetTestExportPreviewJSONNull()
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
bridge := &CgoBridge{}
_, err := bridge.ExportPreviewWithSlot("asset-1", "/tmp", 1024, 85, 0, 0)
if err == nil {
t.Fatal("expected errBridgeNil")
}
if err != errBridgeNil {
t.Errorf("got %v, want %v", err, errBridgeNil)
}
}
func TestCgoBridgeExportOriginalWithSlotNilStub(t *testing.T) {
SetTestExportOriginalJSONNull()
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
bridge := &CgoBridge{}
_, err := bridge.ExportOriginalWithSlot("asset-1", "/tmp", 0, 0)
if err == nil {
t.Fatal("expected errBridgeNil")
}
if err != errBridgeNil {
t.Errorf("got %v, want %v", err, errBridgeNil)
}
}
func TestGetProgressSlotsWithActiveSlot(t *testing.T) {
ResetProgressSlots()
slots := GetProgressSlots()
if len(slots) != 16 {
t.Errorf("expected 16 slots, got %d", len(slots))
}
for i, s := range slots {
if s.Active {
t.Errorf("slot %d should not be active after reset", i)
}
}
}
func TestResetProgressSlotsClearsState(t *testing.T) {
ResetProgressSlots()
slots := GetProgressSlots()
if len(slots) > 0 && slots[0].Active {
t.Errorf("slot should be inactive after reset")
}
}
func TestGetProgressSlotsZeroCount(t *testing.T) {
SetTestProgressSlotCount(0)
defer SetTestProgressSlotCount(3)
slots := GetProgressSlots()
if slots != nil {
t.Errorf("expected nil with zero count, got %v", slots)
}
}
func TestGetProgressSlotsNullPointer(t *testing.T) {
SetTestProgressSlotsNull(1)
defer SetTestProgressSlotsNull(0)
slots := GetProgressSlots()
if slots != nil {
t.Errorf("expected nil with null pointer, got %v", slots)
}
}
func TestExportProgressSlotType(t *testing.T) {
slot := ExportProgressSlot{
Active: true,
Progress: 0.75,
BytesDone: 512,
BytesTotal: 1024,
}
if !slot.Active {
t.Error("expected Active to be true")
}
if slot.Progress != 0.75 {
t.Errorf("expected Progress 0.75, got %f", slot.Progress)
}
}
+11 -11
View File
@@ -6,17 +6,17 @@ type Album struct {
}
type Asset struct {
ID string `json:"id"`
Filename string `json:"filename"`
Cloud string `json:"cloud"`
MediaType string `json:"mediaType"`
PixelWidth int `json:"pixelWidth"`
PixelHeight int `json:"pixelHeight"`
CreationDate *string `json:"creationDate,omitempty"`
Duration float64 `json:"duration,omitempty"`
IsFavorite bool `json:"isFavorite,omitempty"`
HasAdjustments bool `json:"hasAdjustments,omitempty"`
Resources []AssetResource `json:"resources,omitempty"`
ID string `json:"id"`
Filename string `json:"filename"`
Cloud string `json:"cloud"`
MediaType string `json:"mediaType"`
PixelWidth int `json:"pixelWidth"`
PixelHeight int `json:"pixelHeight"`
CreationDate *string `json:"creationDate,omitempty"`
Duration float64 `json:"duration,omitempty"`
IsFavorite bool `json:"isFavorite,omitempty"`
HasAdjustments bool `json:"hasAdjustments,omitempty"`
Resources []AssetResource `json:"resources,omitempty"`
}
type AssetResource struct {