diff --git a/.golangci.yml b/.golangci.yml index ca66d212..ecaa117c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,7 @@ version: "2" run: - go: "1.24" + go: "1.25" linters: exclusions: diff --git a/AGENTS.md b/AGENTS.md index c38abc07..1dfebba2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,102 +6,122 @@ A README for AI coding agents working on Zaparoo Core. Zaparoo Core is a hardware-agnostic game launcher that bridges physical tokens (NFC tags, barcodes, RFID) with digital media across 12 gaming platforms. Built in Go, it provides a unified API for launching games on MiSTer, Batocera, Bazzite, ChimeraOS, LibreELEC, Linux, macOS, RetroPie, Recalbox, SteamOS, Windows, and MiSTeX through token scanning. The system uses WebSocket/JSON-RPC for real-time communication, SQLite for dual-database storage, supports 11 reader types, includes cross-platform audio feedback, and features a custom ZapScript language for automation. -**Tech Stack**: Go 1.24.11+, SQLite (dual-DB: UserDB + MediaDB), WebSocket/HTTP with JSON-RPC 2.0, malgo+beep/v2 (audio), testify/mock, sqlmock, afero +**Tech Stack**: Go 1.25.7+, SQLite (dual-DB: UserDB + MediaDB), WebSocket/HTTP with JSON-RPC 2.0, malgo+beep/v2 (audio), testify/mock, sqlmock, afero -**Testing Standards**: Comprehensive test coverage required for all new code - we have extensive testing infrastructure with mocks, fixtures, and examples in `pkg/testing/` +### Zaparoo Ecosystem + +Zaparoo Core is the backend service in a larger ecosystem: + +- **Zaparoo App** ([github.com/ZaparooProject/zaparoo-app](https://github.com/ZaparooProject/zaparoo-app)) - The primary user interface (iOS, Android, Web). Its web build is embedded into the Core binary at `pkg/assets/_app/dist/` and served at `/app/`. The App uses Core's JSON-RPC API for all communication. CI automatically downloads the latest App build during Core builds. +- **go-pn532** - NFC reader driver library used by Core's PN532 reader implementations +- **go-zapscript** - ZapScript language parser library used by Core's token processing + +When working on the API, notifications, or media features, remember the App is the primary consumer of these interfaces. + +### Key Concepts + +- **Tokens**: Physical objects (NFC tags, barcodes, QR codes, optical discs) that carry or are mapped to ZapScript commands. Identified by UID, text content, or raw data. +- **ZapScript**: Command language stored on tokens. Format: `**command:arg1,arg2?key=value`, chained with `||`. Supports expressions (`[[variable]]`) and conditions (`?when=`). A bare path (no `**` prefix) auto-launches as media. See `pkg/zapscript/` and official docs. +- **Mappings**: Rules that override what a token does based on pattern matching (exact, partial/wildcard, regex) against UID, text, or data. Essential for read-only tokens like Amiibo. Stored in UserDB or as TOML files in `mappings/`. +- **Launchers**: Per-system programs that launch games/media. Each platform provides built-in launchers. Users can add custom launchers via TOML files in `launchers/`. See `pkg/platforms/`. +- **Systems**: 200+ supported game/computer/media systems (e.g., `SNES`, `Genesis`, `PSX`). IDs are case-insensitive with aliases and fallbacks. See official docs for the full list. +- **Readers**: Hardware or virtual devices that detect tokens. Support two scan modes: **tap** (default, token can be removed freely) and **hold** (token must stay on reader, removal stops media). + +## Safety & Permissions + +### Allowed without asking + +- Read any files in the repository +- Run file-scoped tests: `go test ./pkg/specific/` +- Run `task lint-fix` to fix linting and formatting issues +- Run package-level linting: `golangci-lint run pkg/specific/` +- Format files: `gofumpt -w file.go` +- View git history: `git log`, `git diff` + +### Ask before + +- Installing new Go dependencies +- Running `git push` or `git commit` +- Deleting files or directories +- Changing the database schema or migrations +- Modifying configuration schema (SchemaVersion) +- Adding new platform support +- Changing API contract (breaking changes) ## Development Guidelines ### Do -- **Write tests for all new code** - comprehensive coverage required -- **Use `task lint-fix`** to resolve all linting and formatting issues -- **Keep diffs small and focused** - one concern per change -- **Use file-scoped commands** (tests, formatting) for faster feedback -- **Reference existing patterns** before writing new code - consistency matters -- **Use zerolog for all logging** - standard `log` and `fmt.Println` are not allowed -- **Use filepath.Join** for all path construction - ensures cross-platform compatibility -- **Handle all errors explicitly** - use golangci-lint's error handling checks -- **Default to small components** - prefer focused modules over monolithic files +- Write tests for all new code - comprehensive coverage required +- Use `task lint-fix` to resolve all linting and formatting issues (enforced by depguard, goheader, etc.) +- Keep diffs small and focused - one concern per change +- Use file-scoped commands (tests, formatting) for faster feedback +- Reference existing patterns before writing new code - consistency matters +- Use zerolog for all logging - `log` and `fmt.Println` are forbidden (depguard) +- Use `filepath.Join` for all path construction - cross-platform compatibility +- Handle all errors explicitly - use golangci-lint's error handling checks +- Use afero for filesystem operations in testable code +- Use absolute imports with full module path `github.com/ZaparooProject/zaparoo-core/v2` +- Add GPL-3.0-or-later license headers on all new files (goheader linter) ### Don't -- ❌ Use standard `log` or `fmt.Println` (use zerolog instead) -- ❌ Run file-level golangci-lint (use `task lint-fix` or package-level commands) -- ❌ Add new dependencies without discussion -- ❌ Run full test suite unless needed (prefer file-scoped: `go test ./pkg/specific/`) -- ❌ Skip writing tests for new features or bug fixes -- ❌ Make large, unfocused diffs - keep changes small and targeted -- ❌ Write comments that restate what the code does - comments should explain *why*, not *what* -- ❌ Attempt to run builds, lints, or tests for another OS (e.g., `GOOS=windows`) - CGO dependencies mean these only work on the current OS. Files for other platforms will be silently skipped. **Rely on CI to report issues for other platforms.** +- Use standard `log` or `fmt.Println` (use zerolog instead) +- Run file-level golangci-lint (use `task lint-fix` or package-level commands) +- Add new dependencies without discussion +- Run full test suite unless needed (prefer file-scoped: `go test ./pkg/specific/`) +- Skip writing tests for new features or bug fixes +- Write comments that restate what the code does - comments should explain *why*, not *what* +- Amend commits - always prefer to create new commits +- **Attempt to run builds, lints, or tests for another OS** (e.g., `GOOS=windows`) - CGO dependencies mean these only work on the current OS. Rely on CI for other platforms. -### Code Quality +### Testing -- **Use Go 1.24.11+** with Go modules enabled -- **Handle all errors explicitly** - use golangci-lint's error handling checks -- **Use explicit returns** in functions longer than 5 lines (avoid naked returns) -- **Keep functions small** and focused on single responsibility -- **Keep diffs small and focused** - one concern per change -- **Reference existing code patterns** before writing new code - consistency matters +Full guide: [TESTING.md](TESTING.md) | Quick reference: `pkg/testing/README.md` -### Logging & Output +The goal is useful tests, not coverage metrics. High coverage means nothing if tests don't catch bugs. -- **Use zerolog for all logging** - standard `log` and `fmt.Println` are not allowed (enforced by depguard) -- **Log at appropriate levels** - debug, info, warn, error +**What to test**: Business logic and algorithms, edge cases and error paths, integration points (DB queries, API responses), state transitions, regression scenarios. -### Testing +**What NOT to test**: Library functions, simple getters/setters, third-party internals, implementation details, obvious code like `if err != nil { return err }`. -**The goal is useful tests, not coverage metrics.** High coverage means nothing if tests don't catch bugs. - -#### What to Test -- **Business logic and algorithms** - the core value of your code -- **Edge cases and error paths** - where bugs hide -- **Integration points** - database queries, API responses -- **State transitions** - multi-step workflows -- **Regression scenarios** - any bug that was fixed - -#### What NOT to Test -- ❌ **Library functions** - don't test that `strings.Split` works -- ❌ **Simple getters/setters** - trivial code with no logic -- ❌ **Third-party code** - don't verify its internals -- ❌ **Implementation details** - test behavior, not how it's done -- ❌ **Obvious code** - `if err != nil { return err }` doesn't need a test - -#### How to Test -- **Mock at interface boundaries** - all hardware interactions must be mocked -- **Use existing mocks/fixtures** from `pkg/testing/` instead of creating new ones -- **Write sqlmock tests** for all direct SQL operations -- **Use `t.Parallel()`** in tests when safe to run concurrently -- **Run file-scoped tests** for faster feedback (see Commands section below) -- **Commit regression files** - both rapid `.fail` files and fuzz corpus entries are valuable regression tests - -#### Test Quality Checklist -- Would this test catch a real bug? -- Does it test behavior the user/caller cares about? -- Is this testing MY code or a library's code? - -### File Paths & Filesystem - -- **Use filepath.Join** for all file path construction - ensures cross-platform compatibility -- **Use afero** for filesystem operations in testable code -- **Use absolute imports** with full module path `github.com/ZaparooProject/zaparoo-core/v2` +**How to test**: +- Mock at interface boundaries - all hardware interactions must be mocked +- Use existing mocks/fixtures from `pkg/testing/` instead of creating new ones +- Write sqlmock tests for all direct SQL operations +- Use `t.Parallel()` unless tests share state +- Use table-driven tests for multiple scenarios +- Verify mock expectations with `AssertExpectations(t)` +- Commit regression files - both rapid `.fail` files and fuzz corpus entries -### Dependencies & State +**Mock setup pattern**: + +```go +import ( + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/fixtures" +) + +mockPlatform := mocks.NewMockPlatform() +mockReader := mocks.NewMockReader() +mockUserDB := helpers.NewMockUserDBI() +mockMediaDB := helpers.NewMockMediaDBI() +``` -- **Discuss new dependencies** before adding them - keep the dependency tree lean -- **Protect global state** with sync.RWMutex or atomic operations -- **Use context** for cancellation and timeouts in long-running operations +See Commands section for test invocations. -### Compatibility & Migration +### Dependencies & State -- **Maintain backward compatibility** in config schema - use migrations for breaking changes -- **Plan migrations** before modifying database schemas (SchemaVersion) +- Discuss new dependencies before adding them - keep the dependency tree lean +- Protect global state with syncutil.RWMutex or atomic operations +- **Use `syncutil.Mutex`/`syncutil.RWMutex`** instead of `sync.Mutex`/`sync.RWMutex` (forbidigo linter, deadlock detection) +- Use context for cancellation and timeouts in long-running operations -### Code Hygiene +### Compatibility & Migration -- **Follow GPL-3.0-or-later license** header format on all new files -- **Run `task lint-fix`** before committing to auto-fix linting issues -- **Default to small components** - prefer focused modules over monolithic files +- Maintain backward compatibility in config schema - use migrations for breaking changes +- Plan migrations before modifying database schemas (SchemaVersion) ## Commands @@ -115,15 +135,15 @@ go test -run TestSpecificFunction ./pkg/api/ # Test with race detection go test -race ./pkg/service/tokens/ -# Lint and format - ALWAYS prefer task commands -task lint-fix # PREFERRED: Full project lint with auto-fixes +# Lint and format - prefer task commands +task lint-fix # Full project lint with auto-fixes # Package-level linting (only when file-scoped is needed) golangci-lint run --fix pkg/service/ # Package level OK golangci-lint run pkg/database/ # Package level OK -# ❌ NEVER use file-level golangci-lint - not well supported -# golangci-lint run pkg/config/config.go # DON'T DO THIS +# DON'T use file-level golangci-lint - not well supported +# golangci-lint run pkg/config/config.go # Run single test by name go test -run TestTokenProcessing ./pkg/service/ @@ -142,41 +162,12 @@ govulncheck ./pkg/api/... ### Project-wide commands ```bash -# Full test suite with race detection -task test - -# Full lint with auto-fixes -task lint-fix - -# Security vulnerability scan -task vulncheck - -# Nil-pointer analysis -task nilcheck - -# Platform-specific build examples -task linux:build-amd64 -task windows:build-amd64 -task mister:build-arm -task batocera:build-arm64 +task test # Full test suite with race detection +task lint-fix # Full lint with auto-fixes +task vulncheck # Security vulnerability scan +task nilcheck # Nil-pointer analysis ``` -## When Stuck - -**Don't guess - ask for help or gather more information first.** - -- **Ask clarifying questions** - Get requirements clear before coding -- **Propose a plan first** - Outline approach, then implement -- **Use extended thinking** - For complex problems, think through the solution systematically -- **Reference existing patterns** - Check similar code in the codebase for consistency -- **Consult TESTING.md** - For comprehensive testing guidance -- **Check pkg/testing/examples/** - For real-world test patterns -- **Look at git history** - `git log -p filename` shows how code evolved -- **Use subagents** - Delegate exploration and verification tasks when appropriate -- **Keep scope focused** - Small, well-defined changes are easier to review and debug - -**Remember**: It's better to ask than to make incorrect assumptions. The project values correctness over speed. - ## Project Structure Key entry points and frequently accessed directories: @@ -188,17 +179,19 @@ zaparoo-core/ │ ├── api/ # WebSocket/HTTP JSON-RPC server │ │ ├── methods/ # RPC method handlers │ │ └── models/ # API data models +│ ├── assets/ # Embedded static files (Zaparoo App web build) │ ├── audio/ # Cross-platform audio playback (beep-based) │ ├── config/ # Configuration management (TOML-based) │ ├── database/ # Dual database system │ │ ├── userdb/ # User mappings, history, playlists -│ │ └── mediadb/ # Indexed media content +│ │ ├── mediadb/ # Indexed media content +│ │ └── mediascanner/ # Media indexing engine │ ├── platforms/ # 12 platform implementations │ ├── readers/ # 11 reader type drivers │ ├── service/ # Core business logic │ │ ├── tokens/ # Token processing │ │ └── playlists/ # Playlist management -│ ├── testing/ # Testing infrastructure ⭐ +│ ├── testing/ # Testing infrastructure │ │ ├── README.md # Quick reference for all testing utilities │ │ ├── mocks/ # Pre-built mocks for all major interfaces │ │ ├── helpers/ # Testing utilities (DB, FS, API) @@ -208,22 +201,9 @@ zaparoo-core/ └── Taskfile.dist.yml # Build and development tasks ``` -## Audio System - -Zaparoo uses **malgo** (gen2brain/malgo) for cross-platform hardware audio output and **beep** (gopxl/beep/v2) for audio decoding. - -### Overview +### Reference Files -- **Location**: `pkg/audio/audio.go` -- **Supported platforms**: All 12 platforms (Linux, Windows, macOS, MiSTer, MiSTeX, Batocera, etc.) -- **Audio formats**: WAV, MP3, FLAC, Vorbis (beep handles decoding) -- **Sample rate**: Resampled to 48000 Hz for HDMI audio compatibility (MiSTer, etc.) -- **Playback**: Fire-and-forget asynchronous - audio devices are created and released per-playback -- **Cancellation**: New sounds automatically cancel any currently playing sound - -## Good Examples to Follow - -**Copy these patterns for new code:** +Copy these patterns for new code: - **Tests**: `pkg/testing/examples/service_token_processing_test.go` - Complete test pattern with mocks - **Tests**: `pkg/testing/examples/mock_usage_example_test.go` - How to use mocks and fixtures @@ -234,62 +214,11 @@ Zaparoo uses **malgo** (gen2brain/malgo) for cross-platform hardware audio outpu - **Platform**: `pkg/platforms/linux/platform.go` - Platform implementation pattern - **Service**: `pkg/service/tokens/tokens.go` - Service layer pattern -## Testing Quick Reference - -**Full guide**: [TESTING.md](TESTING.md) | **Quick reference**: `pkg/testing/README.md` - -### Mock Setup Pattern - -```go -import ( - "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/fixtures" -) - -mockPlatform := mocks.NewMockPlatform() -mockReader := mocks.NewMockReader() -mockUserDB := helpers.NewMockUserDBI() -mockMediaDB := helpers.NewMockMediaDBI() -``` - -### Running Tests - -```bash -task test # All tests with race detection -task test -- -run TestName # Specific test -task test -- ./pkg/service/... # Specific package -go test -race ./pkg/service/tokens/ # Package with race detection -``` - -### Test Organization - -- **Use table-driven tests** for multiple scenarios -- **Use `t.Parallel()`** unless tests share state -- **Verify mock expectations** with `AssertExpectations(t)` -- **Focus on cohesion over file size** - big test files are fine if cohesive - -## Code Style & Standards - -Following golangci-lint configuration in `.golangci.yml`: - -- **Line length**: 120 characters max (revive rule) -- **Function results**: Max 3 return values (revive rule) -- **Error handling**: All errors must be checked (errcheck, wrapcheck) -- **Imports**: Grouped and sorted with gci formatter -- **Formatting**: Use gofumpt (stricter than gofmt) -- **JSON tags**: camelCase (enforced by tagliatelle) -- **TOML tags**: snake_case (enforced by tagliatelle) -- **Nil checks**: Comprehensive (nilnil, nilerr rules) -- **SQL**: Close all rows/statements (sqlclosecheck, rowserrcheck) -- **Concurrency**: Proper context usage (noctx rule) -- **No naked returns** in long functions (nakedret rule) - ## Git & Commit Guidelines ### Commit message format -Zaparoo uses **Conventional Commits** format for automated semantic versioning: +Zaparoo uses **Conventional Commits** for automated semantic versioning: ``` [optional scope]: @@ -299,113 +228,48 @@ Zaparoo uses **Conventional Commits** format for automated semantic versioning: [optional footer(s)] ``` -**Types** (determines version bump): -- `feat:` - New feature (triggers **minor** version bump, e.g., 1.0.0 → 1.1.0) -- `fix:` - Bug fix (triggers **patch** version bump, e.g., 1.0.0 → 1.0.1) -- `docs:` - Documentation only changes (no version bump) -- `style:` - Code style/formatting changes (no version bump) -- `refactor:` - Code refactoring without behavior change (no version bump) -- `perf:` - Performance improvement (triggers **patch** bump) -- `test:` - Adding or updating tests (no version bump) -- `build:` - Build system or dependency changes (no version bump) -- `ci:` - CI/CD configuration changes (no version bump) -- `chore:` - Other changes that don't modify src or test files (no version bump) - -**Breaking changes** (triggers **major** version bump, e.g., 1.0.0 → 2.0.0): -- Add `!` after type/scope: `feat!:` or `fix(api)!:` -- Or add `BREAKING CHANGE:` in footer +**Primary types**: +- `feat:` - New feature (minor bump, e.g., 1.0.0 → 1.1.0) +- `fix:` - Bug fix (patch bump, e.g., 1.0.0 → 1.0.1) +- `docs:` - Documentation only (no bump) +- `refactor:` - Code change without behavior change (no bump) + +Also: `style`, `perf`, `test`, `build`, `ci`, `chore` (no bump except `perf` → patch) + +**Breaking changes** (major bump): Add `!` after type/scope (`feat!:`) or `BREAKING CHANGE:` in footer. **Examples**: ```bash -# Good examples: +# Good: git commit -m "feat: add support for new NFC reader type" git commit -m "fix: resolve token processing race condition" -git commit -m "docs: update API endpoint documentation" -git commit -m "refactor(database): improve batch inserter performance" -git commit -m "feat(api)!: change websocket message format" # Breaking change -git commit -m "fix: correct slug cache invalidation - -BREAKING CHANGE: slug cache now clears on all media updates" - -# Avoid: -git commit -m "Fixed bug" # Missing type, too vague -git commit -m "feat: Add feature" # Not descriptive enough -git commit -m "add reader support" # Missing type prefix -git commit -m "feat:add reader" # Missing space after colon -``` - -**Scopes** (optional but recommended): -- `api`, `database`, `config`, `reader`, `platform`, `zapscript`, etc. -- Use package name or feature area - -**Tips**: -- Use lowercase for description (after colon) -- Use imperative mood ("add" not "added", "fix" not "fixed") -- Keep description under 72 characters -- Reference issues in footer: `Fixes #123` or `Closes #456` - -Look at recent commits with `git log --oneline -20` to match the style. - -### Before committing - -**ALWAYS run these commands in order:** - -```bash -# 1. Fix all linting and formatting issues (REQUIRED) -task lint-fix - -# 2. Run all tests with race detection (REQUIRED) -task test +git commit -m "feat(api)!: change websocket message format" -# 3. Check for vulnerabilities (for security-sensitive changes) -task vulncheck +# Bad: +git commit -m "Fixed bug" # Missing type, too vague +git commit -m "add reader support" # Missing type prefix ``` -**Note**: `task lint-fix` handles all linting, formatting, and license header checks automatically. You should not need to run golangci-lint manually. +**Scopes** (optional): `api`, `database`, `config`, `reader`, `platform`, `zapscript`, etc. + +**Tips**: lowercase description, imperative mood ("add" not "added"), under 72 characters. Reference issues in footer: `Fixes #123`. Match style with `git log --oneline -20`. ### Commit checklist -- [ ] Tests pass (`task test`) -- [ ] Linting passes (`task lint-fix`) -- [ ] License headers on new files +Before committing: run **`task lint-fix`** then **`task test`** (required). + +- [ ] Tests pass and linting passes - [ ] Commit message follows Conventional Commits format -- [ ] Commit type correctly indicates change (feat/fix/docs/etc) - [ ] Breaking changes marked with `!` or `BREAKING CHANGE:` footer -- [ ] Diff is small and focused on one concern - [ ] No commented-out code or debug prints -- [ ] Documentation updated if needed ### Pull request descriptions -- **Do NOT include test plans** in PR descriptions - just summarize what the PR does +- Do NOT include test plans in PR descriptions - just summarize what the PR does - Keep descriptions concise with bullet points - Reference related issues if applicable -## Safety & Permissions - -### Allowed without asking: - -- Read any files in the repository -- Run file-scoped tests: `go test ./pkg/specific/` -- Run `task lint-fix` to fix linting and formatting issues -- Run package-level linting: `golangci-lint run pkg/specific/` -- Format files: `gofumpt -w file.go` -- View git history: `git log`, `git diff` -- Run vulnerability checks: `govulncheck ./...` - -### Ask before: - -- Installing new Go dependencies -- Running `git push` or `git commit` -- Deleting files or directories -- Running full `task test` (it's slow - prefer file-scoped) -- Running `task build` (slow - only when needed) -- Changing the database schema or migrations -- Modifying configuration schema (SchemaVersion) -- Adding new platform support -- Changing API contract (breaking changes) - ## API & Architecture Notes ### API Endpoints @@ -414,6 +278,12 @@ task vulncheck - HTTP: `http://localhost:7497/api/v0.1` - Default port: 7497 (configurable via config.toml) - Protocol: JSON-RPC 2.0 +- App UI: served at `/app/` (root `/` redirects here) +- Launch endpoint: `/l/{zapscript}` - simplified GET-based execution for QR codes +- Auth: API keys via `auth.toml`, anonymous access from localhost +- Discovery: mDNS (`_zaparoo._tcp`) for automatic network detection +- Notifications: Real-time events broadcast over WebSocket - readers connected/disconnected, tokens scanned/removed, media started/stopped, indexing progress, playtime warnings. See `docs/api/notifications.md`. +- API docs: See `docs/api/` ### Database Architecture @@ -426,8 +296,8 @@ task vulncheck - Location: `~/.config/zaparoo/config.toml` - Format: TOML with schema versioning -- Thread-safe: config.Instance uses sync.RWMutex -- **Plan migrations before schema changes** - maintain backward compatibility +- Thread-safe: config.Instance uses syncutil.RWMutex +- Plan migrations before schema changes - maintain backward compatibility ### Platform Detection @@ -439,33 +309,13 @@ Each platform has its own entry point in `cmd/{platform}/` with platform-specifi - acr122pcsc, externaldrive, file, libnfc, mqtt, opticaldrive, pn532, pn532uart, rs232barcode, simpleserial, tty2oled -## Additional Resources - -- **Testing**: See [TESTING.md](TESTING.md) and `pkg/testing/examples/` -- **Testing Quick Reference**: See `pkg/testing/README.md` -- **API Documentation**: See `docs/api/` -- **ZapScript**: See `pkg/zapscript/` -- **License**: GPL-3.0-or-later (see LICENSE file) - -## Platform-Specific Build Notes - -```bash -# Linux -task linux:build-amd64 - -# Windows -task windows:build-amd64 - -# MiSTer (ARM) -task mister:arm +## When Stuck -# See Taskfile.dist.yml for all platform builds -``` +Don't guess - ask for help or gather more information first. -## Remember +- **Ask clarifying questions** - get requirements clear before coding +- **Propose a plan first** - outline approach, then implement +- **Reference existing patterns** - check similar code in the codebase for consistency +- **Look at git history** - `git log -p filename` shows how code evolved -1. **Write tests** - comprehensive test coverage is required for all new code -2. **Small diffs** - focused changes are easier to review -3. **File-scoped commands** - faster feedback loop -4. **Use existing patterns** - consistency matters -5. **Ask when uncertain** - better than wrong assumptions +It's better to ask than to make incorrect assumptions. The project values correctness over speed. diff --git a/TESTING.md b/TESTING.md index 5eda71f9..628be728 100644 --- a/TESTING.md +++ b/TESTING.md @@ -42,30 +42,40 @@ The testing infrastructure is organized under `pkg/testing/`: ``` pkg/testing/ -├── README.md # Quick reference guide to all testing utilities ⭐ -├── mocks/ # Interface mocks -│ ├── reader.go # Reader interface mock -│ ├── platform.go # Platform interface mock -│ └── websocket.go # WebSocket mocks -├── helpers/ # Testing utilities -│ ├── db.go # Database testing helpers -│ ├── fs.go # Filesystem testing helpers -│ └── api.go # API testing helpers -├── fixtures/ # Test data -│ ├── tokens.go # Sample tokens: SampleTokens(), NewNFCToken(), NewTokenCollection() -│ ├── media.go # Sample media: SampleMedia(), NewRetroGame(), NewMediaCollection() -│ ├── playlists.go # Sample playlists: SamplePlaylists() -│ ├── kodi.go # Kodi test fixtures -│ └── database.go # Database fixtures and history entries -├── sqlmock/ # SQL mock utilities (testsqlmock.NewSQLMock()) -└── examples/ # Example tests and patterns - ├── mock_usage_example_test.go +├── README.md # Quick reference guide to all testing utilities ⭐ +├── mocks/ # Interface mocks +│ ├── api_client.go # API client mock +│ ├── audio.go # Audio interface mock +│ ├── command_executor.go # Command executor mock +│ ├── kodi_client.go # Kodi client mock +│ ├── platform.go # Platform interface mock +│ ├── reader.go # Reader interface mock +│ └── websocket.go # WebSocket mocks +├── helpers/ # Testing utilities +│ ├── api.go # API testing helpers +│ ├── command.go # Command testing helpers +│ ├── db_mocks.go # Database mock interfaces and matchers +│ ├── esapi_server.go # ES API test server +│ ├── fs.go # Filesystem testing helpers +│ ├── inmemory_db.go # In-memory database for testing +│ ├── kodi_server.go # Kodi test server +│ └── validation.go # Validation helpers +├── fixtures/ # Test data +│ ├── database.go # Database fixtures and history entries +│ ├── kodi.go # Kodi test fixtures +│ ├── media.go # Sample media: SampleMedia(), NewRetroGame(), NewMediaCollection() +│ ├── playlists.go # Sample playlists: SamplePlaylists() +│ └── tokens.go # Sample tokens: SampleTokens(), NewNFCToken(), NewTokenCollection() +├── sqlmock/ # SQL mock utilities (testsqlmock.NewSQLMock()) +│ └── sqlmock.go +└── examples/ # Example tests and patterns + ├── api_example_test.go ├── database_example_test.go ├── filesystem_example_test.go - ├── api_example_test.go + ├── mock_usage_example_test.go + ├── service_state_management_test.go ├── service_token_processing_test.go - ├── service_zapscript_test.go - └── service_state_management_test.go + └── service_zapscript_test.go ``` **New to testing in Zaparoo?** Start with `pkg/testing/README.md` for a quick reference guide to all available helpers and examples. @@ -80,9 +90,9 @@ package mypackage import ( "testing" - "github.com/ZaparooProject/zaparoo-core/pkg/testing/fixtures" - "github.com/ZaparooProject/zaparoo-core/pkg/testing/helpers" - "github.com/ZaparooProject/zaparoo-core/pkg/testing/mocks" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/fixtures" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -108,8 +118,7 @@ func TestWithFixtures(t *testing.T) { // Get sample data tokens := fixtures.SampleTokens() media := fixtures.SampleMedia() - systems := fixtures.SampleSystems() - + // Use in tests assert.Len(t, tokens, 3) assert.Equal(t, "Super Mario Bros", media[0].Name) @@ -122,7 +131,7 @@ func TestWithFixtures(t *testing.T) { func TestDatabaseOperations(t *testing.T) { // Setup mock database mockUserDB := helpers.NewMockUserDBI() - mockUserDB.On("AddHistory", fixtures.HistoryEntryMatcher()).Return(nil) + mockUserDB.On("AddHistory", helpers.HistoryEntryMatcher()).Return(nil) // Test your function err := MyDatabaseFunction(mockUserDB) @@ -228,7 +237,7 @@ func TestPlatformIntegration(t *testing.T) { platform.SetupBasicMock() // Set specific expectations - platform.On("LaunchMedia", fixtures.MediaMatcher(), fixtures.SystemMatcher()).Return(nil) + platform.On("LaunchMedia", helpers.MediaMatcher(), helpers.SystemMatcher()).Return(nil) platform.On("SendKeyboard", "RETURN").Return(nil) // Use in your code @@ -309,7 +318,7 @@ func TestUserOperations(t *testing.T) { } // Set expectations - userDB.On("AddHistory", fixtures.HistoryEntryMatcher()).Return(nil) + userDB.On("AddHistory", helpers.HistoryEntryMatcher()).Return(nil) mediaDB.On("GetMediaByText", "Game Name").Return(fixtures.SampleMedia()[0], nil) // Test your function @@ -464,13 +473,13 @@ func TestTokenProcessing(t *testing.T) { } // Set expectations for complete workflow - db.UserDB.(*helpers.MockUserDBI).On("AddHistory", fixtures.HistoryEntryMatcher()).Return(nil) + db.UserDB.(*helpers.MockUserDBI).On("AddHistory", helpers.HistoryEntryMatcher()).Return(nil) db.MediaDB.(*helpers.MockMediaDBI).On("GetMediaByText", "Game").Return(fixtures.SampleMedia()[0], nil) - platform.On("LaunchMedia", fixtures.MediaMatcher(), fixtures.SystemMatcher()).Return(nil) + platform.On("LaunchMedia", helpers.MediaMatcher(), helpers.SystemMatcher()).Return(nil) // Test complete workflow token := fixtures.SampleTokens()[0] - err := ProcessTokenWorkflow(token, platform, db) + err := MyTokenHandler(token, platform, db) // Verify require.NoError(t, err) @@ -791,7 +800,7 @@ func TestMediaHistoryTracker_UpdatePlayTime(t *testing.T) { ### Additional Resources - **Clockwork documentation**: https://github.com/jonboulle/clockwork -- **Example tests**: `pkg/database/mediadb/*_test.go` (extensive clockwork usage) +- **Example tests**: `pkg/database/mediadb/wal_checkpoint_test.go`, `concurrent_operations_test.go`, `optimization_test.go` - **Service layer example**: `pkg/service/media_history_tracker_test.go` ## Fuzz Testing @@ -954,19 +963,20 @@ When fuzzing finds a bug: ### Example Fuzz Tests -See `pkg/helpers/uris_fuzz_test.go` and `pkg/helpers/paths_fuzz_test.go` for complete examples: +Fuzz test files across the project: -- `FuzzParseVirtualPathStr` - Virtual path parsing -- `FuzzDecodeURIIfNeeded` - URI decoding -- `FuzzIsValidExtension` - Extension validation -- `FuzzFilenameFromPath` - Filename extraction -- `FuzzGetPathExt` - Path extension extraction +- `pkg/helpers/uris_fuzz_test.go` - URI parsing and decoding +- `pkg/helpers/paths_fuzz_test.go` - Path operations +- `pkg/helpers/virtualpath/virtualpath_fuzz_test.go` - Virtual path parsing +- `pkg/database/mediascanner/findpath_fuzz_test.go` - Media path matching +- `pkg/database/tags/filename_parser_fuzz_test.go` - Filename tag parsing +- `pkg/readers/shared/ndef/parser_fuzz_test.go` - NDEF record parsing +- `pkg/readers/rs232barcode/rs232barcode_fuzz_test.go` - Barcode input parsing ### Additional Resources - **Go fuzzing tutorial**: https://go.dev/doc/tutorial/fuzz - **Go fuzzing docs**: https://go.dev/doc/security/fuzz -- **Example fuzz tests**: `pkg/helpers/*_fuzz_test.go` ## Property-Based Testing with Rapid @@ -1119,13 +1129,19 @@ go test -run TestPropertyCacheKeyOrderIndependent -rapid.failfile=path/to/downlo ### Example Property Tests -See these files for complete examples: +Property test files across the project: -- `pkg/database/slugs/slugify_property_test.go` - Slug normalization properties -- `pkg/database/mediadb/slug_cache_property_test.go` - Cache key determinism -- `pkg/database/matcher/fuzzy_property_test.go` - Fuzzy matching properties +- `pkg/config/config_property_test.go` - Configuration properties - `pkg/database/filters/parser_property_test.go` - Tag filter parsing +- `pkg/database/matcher/fuzzy_property_test.go` - Fuzzy matching properties +- `pkg/database/mediadb/batch_inserter_property_test.go` - Batch insert properties +- `pkg/database/mediadb/slug_cache_property_test.go` - Cache key determinism +- `pkg/database/slugs/slugify_property_test.go` - Slug normalization properties +- `pkg/database/tags/tags_property_test.go` - Tag parsing properties +- `pkg/database/userdb/media_history_property_test.go` - Media history properties - `pkg/helpers/paths_property_test.go` - Path normalization and comparison +- `pkg/service/playlists/playlists_property_test.go` - Playlist properties +- `pkg/service/playtime/playtime_property_test.go` - Playtime tracking properties ## Best Practices diff --git a/docs/api/index.md b/docs/api/index.md index 2c8b6967..fb4ea4f0 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -184,11 +184,15 @@ If sent the bytes `ping`, the API will immediately respond with the bytes `pong` ## Launch Endpoint -The HTTP server has an additional endpoint which allows restricted access to launch ZapScript using a GET request. This endpoint is specifically meant to support uses such as QR codes scanned by a phone's camera app or simple launch testing. +The HTTP server has additional endpoints which allow restricted access to launch ZapScript using a GET request. These endpoints are specifically meant to support uses such as QR codes scanned by a phone's camera app or simple launch testing. -The endpoint is: `/l/` +The following endpoints are available: -An example request: `GET http://10.0.0.123:7497/l/**launch.system:snes` +- `/run/` - Preferred endpoint for launching ZapScript. +- `/r/` - Alias for `/run/`. +- `/l/` - **Deprecated.** Use `/run/` instead. + +An example request: `GET http://10.0.0.123:7497/run/**launch.system:snes` This would act as though a token with the text `**launch.system:snes` had been scanned. @@ -196,7 +200,7 @@ Requests from the local device are allowed without restriction. Remote requests ## Methods -Methods are used to execute actions and request data back from the API. The current API provides **26 methods** across core functionality areas. See the [API Methods](./methods) page for detailed definitions and examples of each method. +Methods are used to execute actions and request data back from the API. The current API provides **33 methods** across core functionality areas. See the [API Methods](./methods) page for detailed definitions and examples of each method. | ID | Description | | :------------------------------ | :------------------------------------------------------------------------------------ | @@ -229,6 +233,10 @@ Methods are used to execute actions and request data back from the API. The curr | readers.write.cancel | Cancel any active write operation. | | launchers.refresh | Refresh the internal launcher cache, forcing a reload of launcher configurations. | | version | Return server's current version and platform. | +| health | Simple health check to verify the server is running and responding. | +| inbox | List all inbox messages. | +| inbox.delete | Delete a specific inbox message by ID. | +| inbox.clear | Delete all inbox messages. | ## Notifications @@ -236,7 +244,6 @@ Notifications let a server or client know an event has occurred. See the [API No | ID | Description | | :--------------------- | :-------------------------------------------------------------------------------- | -| running | New ZapScript has been added to the launch queue. | | readers.added | A new reader was connected to the server. | | readers.removed | A connected reader was disconnected from the server. | | tokens.added | A new token detected by a reader. | @@ -246,3 +253,4 @@ Notifications let a server or client know an event has occurred. See the [API No | media.indexing | The state of the indexing or optimization process has changed. | | playtime.limit.reached | A playtime limit (session or daily) has been reached and enforced. | | playtime.limit.warning | A playtime warning notification sent at configured intervals before limit reached. | +| inbox.added | A new inbox message was added to the server. | diff --git a/docs/api/methods.md b/docs/api/methods.md index dd7c1849..8171b055 100644 --- a/docs/api/methods.md +++ b/docs/api/methods.md @@ -120,6 +120,7 @@ None. | text | string | Yes | Text content of the token. | | data | string | Yes | Raw data of the token as hexadecimal string. | | scanTime | string | Yes | Timestamp of when the token was scanned in RFC3339 format. | +| readerId | string | No | ID of the reader that scanned the token. | #### Example @@ -254,6 +255,7 @@ None. | mediaPath | string | Yes | Path to the media file. | | mediaName | string | Yes | Display name of the media. | | started | string | Yes | Timestamp when media started in RFC3339 format. | +| zapScript | string | Yes | ZapScript command to launch this media item. | #### Example @@ -333,12 +335,13 @@ An object: ##### Media object -| Key | Type | Required | Description | -| :----- | :----------------------- | :------- | :---------------------------------------------------------------------------------------------------------- | -| system | [System](#system-object) | Yes | System which the media has been indexed under. | -| name | string | Yes | A human-readable version of the result's filename without a file extension. | -| path | string | Yes | Path to the media file. If possible, this path will be compressed into the `/` launch format. | -| tags | [TagInfo](#taginfo-object)[] | Yes | Array of tags associated with this media item. | +| Key | Type | Required | Description | +| :-------- | :----------------------- | :------- | :---------------------------------------------------------------------------------------------------------- | +| system | [System](#system-object) | Yes | System which the media has been indexed under. | +| name | string | Yes | A human-readable version of the result's filename without a file extension. | +| path | string | Yes | Path to the media file. If possible, this path will be compressed into the `/` launch format. | +| zapScript | string | Yes | ZapScript command to launch this media item. | +| tags | [TagInfo](#taginfo-object)[] | Yes | Array of tags associated with this media item. | ##### System object @@ -391,6 +394,7 @@ An object: { "name": "240p Test Suite (PD) v0.03 tepples", "path": "Gameboy/240p Test Suite (PD) v0.03 tepples.gb", + "zapScript": "@Gameboy/240p Test Suite (PD) v0.03 tepples", "system": { "category": "Handheld", "id": "Gameboy", @@ -446,6 +450,7 @@ An object: { "name": "Super Mario Bros.", "path": "NES/Super Mario Bros.nes", + "zapScript": "@NES/Super Mario Bros. (year:1985)", "system": { "category": "Console", "id": "NES", @@ -571,7 +576,7 @@ An omitted or `null` value parameters key is also valid and will index every sys #### Result -Returns `null` on success once indexing is complete. +Returns `null` on success. Indexing runs in the background after the response is sent. Track progress using [media.indexing](./notifications.md) notifications. #### Examples @@ -678,7 +683,7 @@ None. #### Result -Returns a list of [ActiveMedia](#active-media-object) objects or an empty array if no media is active. +Returns an [ActiveMedia](#active-media-object) object if media is currently active, or `null` if no media is active. #### Example @@ -692,13 +697,31 @@ Returns a list of [ActiveMedia](#active-media-object) objects or an empty array } ``` -##### Response +##### Response (No Active Media) + +```json +{ + "jsonrpc": "2.0", + "id": "47f80537-7a5d-11ef-9c7b-020304050607", + "result": null +} +``` + +##### Response (Media Active) ```json { "jsonrpc": "2.0", "id": "47f80537-7a5d-11ef-9c7b-020304050607", - "result": [] + "result": { + "started": "2024-09-24T17:49:42.938167429+08:00", + "launcherId": "SNES", + "systemId": "SNES", + "systemName": "Super Nintendo Entertainment System", + "mediaPath": "/roms/snes/Super Mario World (USA).sfc", + "mediaName": "Super Mario World", + "zapScript": "@SNES/Super Mario World" + } } ``` @@ -859,7 +882,7 @@ None. "debugLogging": false, "audioScanFeedback": true, "readersAutoDetect": true, - "readersScanMode": "insert", + "readersScanMode": "tap", "readersScanExitDelay": 0.0, "readersScanIgnoreSystems": ["DOS"], "errorReporting": true, @@ -1286,9 +1309,7 @@ An object: #### Result -| Key | Type | Required | Description | -| :-- | :----- | :------- | :-------------------------------- | -| id | string | Yes | Database ID of new mapping entry. | +Returns an empty object `{}` on success. #### Example @@ -1316,9 +1337,7 @@ An object: { "jsonrpc": "2.0", "id": "562c0b60-7ae8-11ef-87d7-020304050607", - "result": { - "id": "2" - } + "result": {} } ``` @@ -1467,7 +1486,9 @@ None. | Key | Type | Required | Description | | :----------- | :------- | :------- | :-------------------------------------------- | -| id | string | Yes | Unique identifier for the reader. | +| id | string | Yes | Device path or system identifier of the reader. Legacy field, prefer `readerId` for stable identification. | +| readerId | string | Yes | Stable reader ID, deterministic across restarts. Format: `{driver}-{hash}`. | +| driver | string | Yes | Driver type for the reader (e.g., `"pn532"`, `"acr122pcsc"`, `"file"`). | | info | string | Yes | Human-readable information about the reader. | | connected | boolean | Yes | Whether the reader is currently connected. | | capabilities | string[] | Yes | List of capabilities supported by the reader. | @@ -1493,10 +1514,12 @@ None. "result": { "readers": [ { - "id": "pn532_1", - "info": "PN532 NFC Reader", - "connected": true, - "capabilities": ["read", "write"] + "id": "/dev/ttyUSB0", + "readerId": "pn532-ujqixjv6", + "driver": "pn532", + "info": "PN532 (1-2.3.1)", + "capabilities": ["read", "write"], + "connected": true } ] } @@ -1511,9 +1534,10 @@ Attempt to write given text to the first available write-capable reader, if poss An object: -| Key | Type | Required | Description | -| :--- | :----- | :------- | :------------------------------------ | -| text | string | Yes | ZapScript to be written to the token. | +| Key | Type | Required | Description | +| :------- | :----- | :------- | :--------------------------------------------------------------------------- | +| text | string | Yes | ZapScript to be written to the token. | +| readerId | string | No | ID of a specific reader to write to. If omitted, uses the first available write-capable reader. | #### Result @@ -1550,7 +1574,11 @@ Cancel any ongoing write operation. #### Parameters -None. +Optionally, an object: + +| Key | Type | Required | Description | +| :------- | :----- | :------- | :----------------------------------------------------------------------------- | +| readerId | string | No | ID of a specific reader to cancel write on. If omitted, cancels on all readers. | #### Result diff --git a/docs/api/notifications.md b/docs/api/notifications.md index d131f74b..9a131e49 100644 --- a/docs/api/notifications.md +++ b/docs/api/notifications.md @@ -43,6 +43,7 @@ A token was detected by a connected reader. | text | string | No | Text data associated with the token. | | data | string | No | Raw binary data of the token (base64 encoded). | | scanTime | string | Yes | ISO 8601 timestamp when token was scanned. | +| readerId | string | No | ID of the reader that scanned the token. | ### tokens.removed diff --git a/docs/index.md b/docs/index.md index ef5fb5da..0ad12496 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,7 @@ Build scripts work on Linux, Mac and Windows (natively or WSL). Just make sure a - [Go](https://go.dev/) - Version 1.23 or newer. The build script assumes your Go path is in the default location, for caching between Docker build environments: `$HOME/go` + Version 1.25 or newer. The build script assumes your Go path is in the default location, for caching between Docker build environments: `$HOME/go` - [Task](https://taskfile.dev/) - [Docker](https://www.docker.com/) @@ -35,7 +35,7 @@ These are the important commands: - `task :deploy-` - Some builds also have a helper command to automatically make a new build, transfer it to a remote device and remotely restart the service running. For example, to enable this for the MiSTer ARM build, add `MISTER_IP=1.2.3.4` to a `.env` file in the root of the project and then run `task mister:deploy-arm`. + Some platforms also have a helper command to automatically make a new build, transfer it to a remote device and remotely restart the service running. For example, to enable this for MiSTer, add `MISTER_IP=1.2.3.4` to a `.env` file in the root of the project and then run `task mister:deploy-arm`. ### Direct Builds @@ -43,7 +43,7 @@ Core can be built directly on the host using the `task build` command, but will #### Linux -Linux is the most complex because it uses a custom build of libnfc. Check a Dockerfile like `scripts/linux_amd64/Dockerfile` for full details of setting up the environment. +Linux is the most complex because it uses a custom build of libnfc. Check the Dockerfile at `scripts/zigcc/Dockerfile` for full details of setting up the environment. You will need: diff --git a/docs/media-titles.md b/docs/media-titles.md index 95c7252f..fec02401 100644 --- a/docs/media-titles.md +++ b/docs/media-titles.md @@ -4,7 +4,7 @@ Zaparoo Core's title normalization and matching system enables users to launch g ## Overview -The system enables game lookups using **natural language titles** rather than exact filenames or unique identifiers. Users can write titles in various forms (with or without articles, with Roman numerals or digits, with typos, etc.) and the system will find matching games through progressive normalization and intelligent fallback strategies. +The system lets users look up games by **natural language titles** rather than exact filenames or unique identifiers. Titles can be written in various forms (with or without articles, with Roman numerals or digits, with typos, etc.) and the system will find matching games through progressive normalization and fallback strategies. **Key Concept:** Slugs are **not IDs**. They are an intermediary normalization step that enables fuzzy matching between user queries and indexed filenames. The system normalizes both sides: @@ -41,9 +41,9 @@ The system works around several constraints: When scanning media, Zaparoo: 1. Cleans path and extracts filename (strips file extension and path) -2. Parses filename to extract clean display title (10-step pipeline - see Filename Parser section) +2. Parses filename to extract clean display title (8-step pipeline - see Filename Parser section) 3. Extracts tags from filename using bracket disambiguation (4-step pipeline - see Filename Parser section) -4. Determines media type from system (Game, TVShow, Movie, Music, etc.) +4. Determines media type from system (Game, TVShow, Movie, Music, Image, Audio, Video, Application) 5. Runs media-type-aware slugification on title (two-phase normalization - see Slug Normalization section) 6. Stores path, title, slug, tags, and metadata in database for fast searching @@ -78,23 +78,54 @@ Slug normalization uses a **two-phase architecture** that converts titles into a Applies format-specific normalization based on media type **before** universal normalization. **For Games** (`ParseGame`): -1. Width normalization (fullwidth separators → ASCII for detection) -2. Split titles and strip articles: "The Zelda: Link's Awakening" → "Zelda Link's Awakening" -3. Strip trailing articles: "Legend, The" → "Legend" -4. Strip metadata brackets: `(USA)`, `[!]`, `{Europe}` → removed -5. Strip edition/version suffixes: "Edition", "Version", "v1.0" → removed -6. Normalize symbols/separators (preserve commas for trailing articles) -7. Expand abbreviations: "Bros" → "brothers", "vs" → "versus", "Dr" → "doctor" -8. Expand number words: "one" → "1", "two" → "2" (1-20) -9. Normalize ordinals: "2nd" → "2", "3rd" → "3" -10. Convert roman numerals: "VII" → "7", "II" → "2" (preserves "X" in "Mega Man X") + +Width normalization is applied first (fullwidth separators → ASCII for detection), then 9 steps: + +1. Split titles and strip articles: "The Zelda: Link's Awakening" → "Zelda Link's Awakening" +2. Strip trailing articles: "Legend, The" → "Legend" +3. Strip metadata brackets: `(USA)`, `[!]`, `{Europe}` → removed +4. Strip edition/version suffixes: "Edition", "Version", "v1.0" → removed +5. Normalize symbols/separators (preserve commas for trailing articles) +6. Expand abbreviations: "Bros" → "brothers", "vs" → "versus", "Dr" → "doctor" +7. Expand number words: "one" → "1", "two" → "2" (1-20) +8. Normalize ordinals: "2nd" → "2", "3rd" → "3" +9. Convert roman numerals: "VII" → "7", "II" → "2" (preserves "X" in "Mega Man X") **For TV Shows** (`ParseTVShow`): -- Normalizes episode formats: `S01E02` / `1x02` / `1-02` → `s01e02` -- Preserves episode information for matching -**For Movies/Music** (`ParseMovie`, `ParseMusic`): -- Currently pass-through (future enhancement) +Width normalization is applied first, then 9 steps: + +1. Scene tag stripping: quality, codec, source tags (1080p, x264, BluRay, etc.) +2. Dot normalization: scene release dots → spaces +3. Strip metadata brackets: `[720p]`, `(extended)` → removed +4. Normalize date episodes: YYYY-MM-DD, DD-MM-YYYY (with `.`, `/`, `-` separators) → canonical `YYYY-MM-DD` +5. Normalize season-based formats: `S01E02`, `s01e02`, `1x02`, `S01.E02`, `S01_E02`, multi-episode (`S01E01-E02`) → `s01e02` +6. Normalize absolute numbering: `Episode 001`, `Ep 42`, `E001`, `#001` (anime), leading numbers → `e001` +7. Component reordering: episode marker placed after show name ("S01E02 - Show - Title" → "Show s01e02 Title") +8. Split titles and strip articles: "The Show: Episode Title" → "Show Episode Title" +9. Strip trailing articles: "Show, The" → "Show" + +**For Movies** (`ParseMovie`): +1. Width normalization (fullwidth separators → ASCII for detection) +2. Scene tag stripping: quality, codec, source, HDR, 3D tags (preserves edition qualifiers like "Extended", "Unrated", "Director's Cut") +3. Dot normalization: scene release dots → spaces +4. Edition suffix stripping: trailing "Edition", "Version", "Cut", "Release" removed (preserves qualifiers: "Director's Cut Edition" → "Director's") +5. Bracket stripping: `(2024)`, `{imdb-tt1234567}` → removed (years extracted as tags) +6. Split titles and strip articles: "The Movie: Subtitle" → "Movie Subtitle" +7. Strip trailing articles: "Movie, The" → "Movie" + +**For Music** (`ParseMusic`): +1. Width normalization (fullwidth separators → ASCII for detection) +2. Scene tag stripping: format (FLAC, MP3), quality (V0, 320, 24bit), source (CD, Vinyl, WEB), release group (preserves edition qualifiers like "Remastered", "Deluxe") +3. Separator normalization: dots, underscores, dashes → spaces +4. Bracket stripping: `(1979)`, `[FLAC]` → removed (years extracted as tags) +5. Disc number stripping: CD1, CD2, Disc 1 → removed +6. Strip leading article: "The Beatles Abbey Road" → "Beatles Abbey Road" +7. Strip trailing articles: "Album, The" → "Album" +8. Whitespace collapse: multiple spaces → single space + +**For Image/Audio/Video/Application**: +- Pass through to Phase 2 universal normalization only (no media-specific parsing yet) #### Phase 2: Universal Normalization (`normalizeInternal`) @@ -117,15 +148,15 @@ Applied after media-specific parsing: Input: "The Legend of Zelda: Ocarina of Time (USA) [!]" Phase 1 (ParseGame): - Step 1-2: Split & strip articles → "Zelda Ocarina of Time (USA) [!]" - Step 4: Strip brackets → "Zelda Ocarina of Time" - Step 10: Roman numerals (none) → "Zelda Ocarina of Time" + Step 1: Split & strip articles → "Legend of Zelda Ocarina of Time (USA) [!]" + Step 3: Strip brackets → "Legend of Zelda Ocarina of Time" + Step 9: Roman numerals (none) → "Legend of Zelda Ocarina of Time" Phase 2 (normalizeInternal): - Step 6: Lowercase → "zelda ocarina of time" + Step 6: Lowercase → "legend of zelda ocarina of time" Final: - Step 7: Filter → "legendofzeldaocarinaoftime" + Character filtering → "legendofzeldaocarinaoftime" Output: "legendofzeldaocarinaoftime" ``` @@ -170,9 +201,9 @@ When a file is indexed, the system: **Function:** `tags.ParseTitleFromFilename(filename, stripLeadingNumbers)` -Extracts a clean, human-readable display title from a filename by removing metadata and normalizing artifacts. +Extracts a clean display title from a filename by removing metadata and normalizing artifacts. -#### The 10-Step Pipeline +#### The 8-Step Pipeline 1. **Remove File Extension** - Strips `.zip`, `.nes`, `.mkv`, etc. @@ -217,26 +248,17 @@ Extracts a clean, human-readable display title from a filename by removing metad - Example: `"01 - Game Name"` → `"Game Name"` - **Contextual:** Only enabled when directory shows list-style numbering -7. **Extract Year from Brackets** - - Finds year in format: `(1997)`, `(2008)` - - Preserves for re-appending after bracket removal - - Range: 1970-2099 - -8. **Remove All Bracket Content** +7. **Remove All Bracket Content** - **Function:** `slugs.StripMetadataBrackets()` - Removes: `()`, `[]`, `{}`, `<>` - Handles nested brackets - Example: `"Game (USA) [!] {Europe}"` → `"Game"` + - Example: `"Movie (2008) (Blu-ray)"` → `"Movie"` -9. **Re-append Year** - - If year was extracted and not still present, append it - - Example: `"Movie (2008) (Blu-ray)"` → `"Movie 2008"` - - Preserves useful year information in display title - -10. **Normalize Whitespace** - - Collapses multiple spaces to single space - - Trims leading/trailing spaces - - Final cleanup after all transformations +8. **Normalize Whitespace** + - Collapses multiple spaces to single space + - Trims leading/trailing spaces + - Final cleanup after all transformations ### Title Extraction Examples @@ -244,9 +266,8 @@ Extracts a clean, human-readable display title from a filename by removing metad ``` Input: "Super Mario Bros. III (USA) (Rev A) [!].nes" Step 1: Remove extension → "Super Mario Bros. III (USA) (Rev A) [!]" -Step 7: Extract year → (none) -Step 8: Remove brackets → "Super Mario Bros. III" -Step 10: Normalize → "Super Mario Bros. III" +Step 7: Remove brackets → "Super Mario Bros. III" +Step 8: Normalize whitespace → "Super Mario Bros. III" Output: "Super Mario Bros. III" ``` @@ -280,15 +301,17 @@ Extracts metadata tags from filenames following No-Intro and TOSEC conventions. **Step 1: Extract Special Patterns** - **Function:** `extractSpecialPatterns()` - Finds patterns that appear outside brackets: - - **Translations**: `T+En`, `T-Fr v1.0` → `translation:en`, `translation-:fr` - **Disc numbers**: `(Disc 1 of 2)` → `disc:1`, `discof:2` - **Revisions**: `(Rev A)`, `(Rev 1)` → `rev:a`, `rev:1` - - **Versions**: `(v1.2)`, `v3.0` → `version:1.2`, `version:3.0` + - **Volumes**: `(Vol. 2)`, `(Volume 3)` → `volume:2` + - **Versions**: `(v1.2)`, `v3.0` → `rev:1-2`, `rev:3-0` - **Years**: `(1997)` → `year:1997` - - **Episodes**: `S01E02`, `1x05` → `season:01`, `episode:02` + - **Episodes**: `S01E02`, `1x05` → `season:1`, `episode:2` - **Issues**: `#12`, `Issue 5` → `issue:12` - - **Tracks**: `01 -`, `Track 03` → `track:01` - - **Volumes**: `(Vol. 2)` → `volume:2` + - **Tracks**: `01 -`, `Track 03` → `track:1` + - **Translations**: `T+En`, `T-Fr v1.0` → `translation:en`, `translation-:fr` + - **Bracketless versions**: `v1.0`, `v1.2.3` outside brackets → `rev:1-0` (only if no version already extracted) + - **Edition/version words**: "Edition", "Version" (+ multi-language equivalents) → `edition:edition` or `edition:version` (inferred, not removed from title) - Removes matched patterns from string for cleaner bracket extraction **Step 2: Extract Bracket Content** @@ -407,7 +430,7 @@ The system uses **positional** and **bracket-type** rules for disambiguation: ## Matching Strategies -Resolution tries strategies **in order** until finding results. Each strategy becomes progressively more lenient. +Resolution tries strategies **in order** until finding results. Each strategy is more lenient than the last. **Implementation:** `pkg/zapscript/titles.go` → `cmdTitle()` @@ -496,7 +519,7 @@ Base confidence from strategy (0.85-1.0) is adjusted by tag matching: ### Filtering Priority 1. **User-specified tags** - Filter to exact matches (if provided) -2. **Exclude variants** - Remove demos, betas, hacks, translations, bad dumps +2. **Exclude variants** - Remove unfinished (demo, beta, proto, alpha, sample, preview, prerelease), unlicensed (hack, translation, bootleg, clone), and bad dumps 3. **Exclude re-releases** - Remove reboxed editions, re-releases 4. **Preferred regions** - Match user's region config 5. **Preferred languages** - Match user's language config @@ -511,7 +534,7 @@ Base confidence from strategy (0.85-1.0) is adjusted by tag matching: ## Tag System -Tags provide additional filtering and disambiguation during both indexing and resolution. +Tags are used for filtering and disambiguation during indexing and resolution. ### Tag Extraction (During Indexing) diff --git a/pkg/service/reader_manager_test.go b/pkg/service/reader_manager_test.go index 23c7f272..88704414 100644 --- a/pkg/service/reader_manager_test.go +++ b/pkg/service/reader_manager_test.go @@ -71,7 +71,7 @@ func setupReaderManager(t *testing.T) *readerManagerEnv { lsq := make(chan *tokens.Token, 10) plq := make(chan *playlists.Playlist, 10) - go readerManager(mockPlatform, cfg, st, db, itq, lsq, plq, scanQueue, mockPlayer) + go readerManager(mockPlatform, cfg, st, db, itq, lsq, plq, scanQueue, mockPlayer, nil) t.Cleanup(func() { st.StopService() diff --git a/pkg/service/readers.go b/pkg/service/readers.go index 41c8125f..83103a5d 100644 --- a/pkg/service/readers.go +++ b/pkg/service/readers.go @@ -37,6 +37,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript" + "github.com/jonboulle/clockwork" "github.com/rs/zerolog/log" ) @@ -194,8 +195,9 @@ func timedExit( db *database.Database, lsq chan *tokens.Token, plq chan *playlists.Playlist, - exitTimer *time.Timer, -) *time.Timer { + clock clockwork.Clock, + exitTimer clockwork.Timer, +) clockwork.Timer { if exitTimer != nil { stopped := exitTimer.Stop() if stopped { @@ -226,12 +228,16 @@ func timedExit( return exitTimer } - timerLen := time.Second * time.Duration(cfg.ReadersScan().ExitDelay) + timerLen := time.Duration(float64(cfg.ReadersScan().ExitDelay) * float64(time.Second)) log.Debug().Msgf("exit timer set to: %s seconds", timerLen) - exitTimer = time.NewTimer(timerLen) + exitTimer = clock.NewTimer(timerLen) go func() { - <-exitTimer.C + select { + case <-exitTimer.Chan(): + case <-st.GetContext().Done(): + return + } if !cfg.HoldModeEnabled() { log.Debug().Msg("exit timer expired, but hold mode disabled") @@ -288,11 +294,16 @@ func readerManager( plq chan *playlists.Playlist, scanQueue chan readers.Scan, player audio.Player, + clock clockwork.Clock, ) { + if clock == nil { + clock = clockwork.NewRealClock() + } + var lastError time.Time proc := &scanPreprocessor{} - var exitTimer *time.Timer + var exitTimer clockwork.Timer var autoDetector *AutoDetector if cfg.AutoDetect() { @@ -400,7 +411,7 @@ preprocessing: scanSource = t.Source case stoken := <-lsq: // a token has been launched that starts software, used for managing exits - log.Debug().Msgf("new software token: %v", st) + log.Debug().Msgf("new software token: %v", stoken) if exitTimer != nil && !helpers.TokensEqual(stoken, st.GetSoftwareToken()) { if stopped := exitTimer.Stop(); stopped { log.Info().Msg("different software token inserted, cancelling exit") @@ -440,13 +451,13 @@ preprocessing: if exitTimer != nil { stopped := exitTimer.Stop() - activeToken := st.GetActiveCard() - if stopped && helpers.TokensEqual(scan, &activeToken) { + stoken := st.GetSoftwareToken() + if stopped && helpers.TokensEqual(scan, stoken) { log.Info().Msg("same token reinserted, cancelling exit") continue preprocessing } else if stopped { log.Info().Msg("new token inserted, restarting exit timer") - exitTimer = timedExit(pl, cfg, st, db, lsq, plq, exitTimer) + exitTimer = timedExit(pl, cfg, st, db, lsq, plq, clock, exitTimer) } } @@ -490,7 +501,7 @@ preprocessing: } } - exitTimer = timedExit(pl, cfg, st, db, lsq, plq, exitTimer) + exitTimer = timedExit(pl, cfg, st, db, lsq, plq, clock, exitTimer) } } diff --git a/pkg/service/scan_behavior_test.go b/pkg/service/scan_behavior_test.go new file mode 100644 index 00000000..903d74a9 --- /dev/null +++ b/pkg/service/scan_behavior_test.go @@ -0,0 +1,619 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package service + +import ( + "context" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playtime" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" + testhelpers "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + behaviorTimeout = 3 * time.Second + noEventWait = 200 * time.Millisecond + testReaderID = "test-reader-removable" + testReaderSrc = "test-reader-src" +) + +type scanBehaviorEnv struct { + st *state.State + cfg *config.Instance + scanQueue chan readers.Scan + clock *clockwork.FakeClock + launchCh chan string + stopCh chan struct{} + keyboardCh chan string + romsDir string +} + +func setupScanBehavior( + t *testing.T, + scanMode string, + exitDelay float32, +) *scanBehaviorEnv { + t.Helper() + + tmpDir := t.TempDir() + romsDir := filepath.Join(tmpDir, "roms") + + fs := testhelpers.NewMemoryFS() + cfg, err := testhelpers.NewTestConfig(fs, tmpDir) + require.NoError(t, err) + + cfg.SetScanMode(scanMode) + cfg.SetScanExitDelay(exitDelay) + + mockPlayer := mocks.NewMockPlayer() + mockPlayer.SetupNoOpMock() + + mockPlatform := mocks.NewMockPlatform() + mockPlatform.SetupBasicMock() + + st, notifCh := state.NewState(mockPlatform, "test-boot-uuid") + + // CapabilityRemovable required for timedExit to arm. + mockReader := mocks.NewMockReader() + mockReader.On("Metadata").Return(readers.DriverMetadata{ID: "mock-reader"}).Maybe() + mockReader.On("IDs").Return([]string{"mock:"}).Maybe() + mockReader.On("Connected").Return(true).Maybe() + mockReader.On("Path").Return("/dev/mock-device").Maybe() + mockReader.On("Info").Return("Mock Removable Reader").Maybe() + mockReader.On("Capabilities").Return([]readers.Capability{ + readers.CapabilityRemovable, + }).Maybe() + mockReader.On("ReaderID").Return(testReaderID).Maybe() + mockReader.On("OnMediaChange", mock.Anything).Return(nil).Maybe() + st.SetReader(mockReader) + + mockUserDB := testhelpers.NewMockUserDBI() + mockUserDB.On("GetEnabledMappings").Return([]database.Mapping{}, nil).Maybe() + mockUserDB.On("AddHistory", mock.Anything).Return(nil).Maybe() + mockUserDB.On("GetSupportedZapLinkHosts").Return([]string{}, nil).Maybe() + + mockMediaDB := testhelpers.NewMockMediaDBI() + + db := &database.Database{ + UserDB: mockUserDB, + MediaDB: mockMediaDB, + } + + launchCh := make(chan string, 10) + stopCh := make(chan struct{}, 10) + keyboardCh := make(chan string, 10) + + // LaunchMedia sets active media in state (simulating real platform behavior) + // and signals launchCh so tests can observe launches. + mockPlatform.On("LaunchMedia", + mock.AnythingOfType("*config.Instance"), + mock.AnythingOfType("string"), + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + path := args.String(1) + st.SetActiveMedia(&models.ActiveMedia{ + SystemID: "mock", + Path: path, + Name: path, + }) + launchCh <- path + }).Maybe() + + mockPlatform.On("StopActiveLauncher", + mock.AnythingOfType("platforms.StopIntent"), + ).Return(nil).Run(func(_ mock.Arguments) { + st.SetActiveMedia(nil) + stopCh <- struct{}{} + }).Maybe() + + mockPlatform.On("KeyboardPress", + mock.AnythingOfType("string"), + ).Return(nil).Run(func(args mock.Arguments) { + keyboardCh <- args.String(0) + }).Maybe() + + mockPlatform.On("ScanHook", mock.Anything).Return(nil).Maybe() + mockPlatform.On("LookupMapping", mock.Anything).Return("", false).Maybe() + mockPlatform.On("ConsoleManager").Return(platforms.NoOpConsoleManager{}).Maybe() + + fakeClock := clockwork.NewFakeClock() + + // lsq is buffered so goroutines spawned by processTokenQueue and timedExit + // can complete their sends after context cancellation. + scanQueue := make(chan readers.Scan) + itq := make(chan tokens.Token) + lsq := make(chan *tokens.Token, 10) + plq := make(chan *playlists.Playlist, 10) + + limitsManager := playtime.NewLimitsManager(db, mockPlatform, cfg, nil, mockPlayer) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + readerManager(mockPlatform, cfg, st, db, itq, lsq, plq, scanQueue, mockPlayer, fakeClock) + }() + go func() { + defer wg.Done() + processTokenQueue(mockPlatform, cfg, st, itq, db, lsq, plq, limitsManager, mockPlayer) + }() + + t.Cleanup(func() { + st.StopService() + wg.Wait() + for { + select { + case <-notifCh: + case <-lsq: + default: + return + } + } + }) + + return &scanBehaviorEnv{ + st: st, + cfg: cfg, + scanQueue: scanQueue, + clock: fakeClock, + romsDir: romsDir, + launchCh: launchCh, + stopCh: stopCh, + keyboardCh: keyboardCh, + } +} + +// --- Scan helpers --- + +// gamePath returns a platform-appropriate absolute path for a game file. +func (env *scanBehaviorEnv) gamePath(name string) string { + return filepath.Join(env.romsDir, name) +} + +func (env *scanBehaviorEnv) sendGameScan(uid, path string) { + env.scanQueue <- readers.Scan{ + Source: testReaderSrc, + Token: &tokens.Token{ + UID: uid, + Text: path, + ScanTime: time.Now(), + Source: tokens.SourceReader, + ReaderID: testReaderID, + }, + } +} + +func (env *scanBehaviorEnv) sendCommandScan(uid, cmd string) { + env.scanQueue <- readers.Scan{ + Source: testReaderSrc, + Token: &tokens.Token{ + UID: uid, + Text: cmd, + ScanTime: time.Now(), + Source: tokens.SourceReader, + ReaderID: testReaderID, + }, + } +} + +func (env *scanBehaviorEnv) sendRemoval() { + env.scanQueue <- readers.Scan{ + Source: testReaderSrc, + Token: nil, + } +} + +// --- Observation helpers --- + +func (env *scanBehaviorEnv) waitForLaunch(t *testing.T) string { + t.Helper() + select { + case path := <-env.launchCh: + return path + case <-time.After(behaviorTimeout): + t.Fatal("timed out waiting for LaunchMedia") + return "" + } +} + +func (env *scanBehaviorEnv) expectNoLaunch(t *testing.T) { + t.Helper() + select { + case path := <-env.launchCh: + t.Fatalf("unexpected LaunchMedia call with path: %s", path) + case <-time.After(noEventWait): + } +} + +func (env *scanBehaviorEnv) waitForStop(t *testing.T) { + t.Helper() + select { + case <-env.stopCh: + case <-time.After(behaviorTimeout): + t.Fatal("timed out waiting for StopActiveLauncher") + } +} + +func (env *scanBehaviorEnv) expectNoStop(t *testing.T) { + t.Helper() + select { + case <-env.stopCh: + t.Fatal("unexpected StopActiveLauncher call") + case <-time.After(noEventWait): + } +} + +func (env *scanBehaviorEnv) waitForKeyboard(t *testing.T) string { + t.Helper() + select { + case key := <-env.keyboardCh: + return key + case <-time.After(behaviorTimeout): + t.Fatal("timed out waiting for KeyboardPress") + return "" + } +} + +// waitForSoftwareToken polls until processTokenQueue has sent the software +// token back through lsq and readerManager has set it in state. +func (env *scanBehaviorEnv) waitForSoftwareToken(t *testing.T) { + t.Helper() + deadline := time.After(behaviorTimeout) + for { + if env.st.GetSoftwareToken() != nil { + return + } + select { + case <-deadline: + t.Fatal("timed out waiting for software token to be set") + case <-time.After(5 * time.Millisecond): + } + } +} + +// waitForActiveCard polls until readerManager has processed a scan and set +// the active card to the expected UID. Note: SetActiveCard executes before +// exitTimer.Stop() in the same goroutine, so use waitForTimerStopped after +// this if you need to guarantee the timer has been cancelled. +func (env *scanBehaviorEnv) waitForActiveCard(t *testing.T, uid string) { + t.Helper() + deadline := time.After(behaviorTimeout) + for { + if env.st.GetActiveCard().UID == uid { + return + } + select { + case <-deadline: + t.Fatalf("timed out waiting for active card UID=%q", uid) + case <-time.After(time.Millisecond): + } + } +} + +// waitForTimerStopped polls until the exit timer has been stopped, verified by +// the fake clock having no remaining waiters. +func (env *scanBehaviorEnv) waitForTimerStopped(t *testing.T) { + t.Helper() + deadline := time.After(behaviorTimeout) + for { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + err := env.clock.BlockUntilContext(ctx, 1) + cancel() + if err != nil { + return + } + select { + case <-deadline: + t.Fatal("timed out waiting for exit timer to be stopped") + case <-time.After(time.Millisecond): + } + } +} + +// simulateManualExit mimics a user quitting a game through the game's own UI. +// Platforms detect this and call setActiveMedia(nil). The software token is NOT +// cleared — only the service layer clears it via the lsq channel. +func (env *scanBehaviorEnv) simulateManualExit() { + env.st.SetActiveMedia(nil) +} + +// ============================================================================ +// Tap mode tests +// ============================================================================ + +func TestScanBehavior_Tap_RemovalDoesNotCloseGame(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeTap, 0) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + + env.sendRemoval() + env.expectNoStop(t) +} + +func TestScanBehavior_Tap_DuplicateSuppression(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeTap, 0) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + + // Same card again — should be suppressed. + env.sendGameScan("game1", env.gamePath("game.rom")) + env.expectNoLaunch(t) +} + +func TestScanBehavior_Tap_DifferentCardLaunchesDirectly(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeTap, 0) + + env.sendGameScan("gameA", env.gamePath("gameA.rom")) + require.Equal(t, env.gamePath("gameA.rom"), env.waitForLaunch(t)) + + env.sendGameScan("gameB", env.gamePath("gameB.rom")) + require.Equal(t, env.gamePath("gameB.rom"), env.waitForLaunch(t)) + + select { + case <-env.stopCh: + t.Fatal("StopActiveLauncher should not have been called between launches") + default: + } +} + +func TestScanBehavior_Tap_SameCardAfterRemoveReloads(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeTap, 0) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + + env.sendRemoval() + + // Re-tap same card — should launch again (prevToken cleared by removal). + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) +} + +func TestScanBehavior_Tap_CommandDoesNotInterruptGame(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeTap, 0) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + + env.sendCommandScan("cmd1", "**input.keyboard:coin") + env.waitForKeyboard(t) + + env.expectNoStop(t) +} + +func TestScanBehavior_Tap_ManualExitResetsState(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeTap, 0) + + env.sendGameScan("gameA", env.gamePath("gameA.rom")) + env.waitForLaunch(t) + + env.simulateManualExit() + + env.sendGameScan("gameB", env.gamePath("gameB.rom")) + require.Equal(t, env.gamePath("gameB.rom"), env.waitForLaunch(t)) +} + +func TestScanBehavior_Tap_ManualExitWithCardNoRelaunch(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeTap, 0) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + + // User manually exits — card still on reader (no removal sent). + env.simulateManualExit() + env.expectNoLaunch(t) +} + +// ============================================================================ +// Hold mode immediate (exit_delay=0) tests +// ============================================================================ + +func TestScanBehavior_HoldImmediate_RemovalClosesGame(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeHold, 0) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + // Wait for the software token roundtrip through lsq before removal, + // otherwise the 0s timer fires before software token is set. + env.waitForSoftwareToken(t) + + env.sendRemoval() + env.waitForStop(t) +} + +func TestScanBehavior_HoldImmediate_ManualExitNoRelaunch(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeHold, 0) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + + // User manually exits while card is still on reader. + env.simulateManualExit() + env.expectNoLaunch(t) +} + +func TestScanBehavior_HoldImmediate_ManualExitThenRemoveNoReload(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeHold, 0) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + + env.simulateManualExit() + + // Remove card after manual exit — should NOT trigger stop (already stopped). + env.sendRemoval() + env.expectNoStop(t) +} + +// ============================================================================ +// Hold mode delayed tests +// ============================================================================ + +func TestScanBehavior_HoldDelayed_RemovalClosesAfterDelay(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeHold, 5) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + env.waitForSoftwareToken(t) + + env.sendRemoval() + env.expectNoStop(t) + + env.clock.Advance(5 * time.Second) + env.waitForStop(t) +} + +func TestScanBehavior_HoldDelayed_ReinsertCancelsExit(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeHold, 5) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + env.waitForSoftwareToken(t) + + env.sendRemoval() + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForActiveCard(t, "game1") + env.waitForTimerStopped(t) + + env.clock.Advance(10 * time.Second) + env.expectNoStop(t) + env.expectNoLaunch(t) +} + +func TestScanBehavior_HoldDelayed_DifferentCardLaunchesImmediately(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeHold, 5) + + env.sendGameScan("gameA", env.gamePath("gameA.rom")) + require.Equal(t, env.gamePath("gameA.rom"), env.waitForLaunch(t)) + env.waitForSoftwareToken(t) + + env.sendRemoval() + env.sendGameScan("gameB", env.gamePath("gameB.rom")) + require.Equal(t, env.gamePath("gameB.rom"), env.waitForLaunch(t)) +} + +func TestScanBehavior_HoldDelayed_CommandResetsCountdown(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeHold, 5) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + env.waitForSoftwareToken(t) + + env.sendRemoval() + + // First command card resets the 5s countdown. + env.sendCommandScan("cmd1", "**input.keyboard:coin") + env.waitForKeyboard(t) + + // Advance 4s (< 5s exit_delay). If the timer was reset by the command, + // there's 1s remaining. If it wasn't, 4s > original timer and it would fire. + env.clock.Advance(4 * time.Second) + + // Second command card resets the countdown again. + env.sendCommandScan("cmd2", "**input.keyboard:start") + env.waitForKeyboard(t) + + // Advance another 4s (total 8s > 5s). Only passes if the second command + // truly reset the timer — otherwise the first command's timer (1s remaining) + // would have fired. + env.clock.Advance(4 * time.Second) + env.expectNoStop(t) + + // Reinsert original game card — cancels timer, session continues. + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForActiveCard(t, "game1") + env.waitForTimerStopped(t) + + env.clock.Advance(10 * time.Second) + env.expectNoStop(t) +} + +func TestScanBehavior_HoldDelayed_ManualExitNoRelaunch(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeHold, 5) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + + env.simulateManualExit() + env.expectNoLaunch(t) +} + +func TestScanBehavior_HoldDelayed_ManualExitThenRemoveNoReload(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeHold, 5) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + + env.simulateManualExit() + + env.sendRemoval() + env.expectNoStop(t) +} + +func TestScanBehavior_HoldDelayed_ManualExitDuringCountdownCancels(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, config.ScanModeHold, 5) + + env.sendGameScan("game1", env.gamePath("game.rom")) + env.waitForLaunch(t) + env.waitForSoftwareToken(t) + + env.sendRemoval() + + // Timer goroutine will see no active media and bail out. + env.simulateManualExit() + env.clock.Advance(10 * time.Second) + env.expectNoStop(t) +} diff --git a/pkg/service/service.go b/pkg/service/service.go index 6f096a13..607c44fc 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -316,7 +316,7 @@ func Start( } log.Info().Msg("starting reader manager") - go readerManager(pl, cfg, st, db, itq, lsq, plq, make(chan readers.Scan), player) + go readerManager(pl, cfg, st, db, itq, lsq, plq, make(chan readers.Scan), player, nil) log.Info().Msg("starting input token queue manager") go processTokenQueue(pl, cfg, st, itq, db, lsq, plq, limitsManager, player) diff --git a/pkg/testing/README.md b/pkg/testing/README.md index cbd14acf..e124c5eb 100644 --- a/pkg/testing/README.md +++ b/pkg/testing/README.md @@ -239,13 +239,6 @@ The `fixtures/` directory provides pre-built test data: - `fixtures.SamplePlaylists()` - Pre-defined playlist data - `fixtures.HistoryEntries` - Pre-populated history entries -## Integration with TDD Guard - -All tests are monitored by TDD Guard for strict test-driven development: -- Use `task test` instead of `go test` for TDD integration -- Write failing tests first, then implement features -- TDD Guard ensures code changes are driven by test failures - ## Best Practices 1. **Always use `t.Parallel()`** for independent tests diff --git a/pkg/testing/mocks/platform.go b/pkg/testing/mocks/platform.go index 524b6c70..15bfad43 100644 --- a/pkg/testing/mocks/platform.go +++ b/pkg/testing/mocks/platform.go @@ -27,6 +27,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" @@ -37,10 +38,11 @@ import ( // MockPlatform is a mock implementation of the Platform interface using testify/mock type MockPlatform struct { mock.Mock - launchedMedia []string // Track launched media for verification - launchedSystems []string // Track launched systems for verification - keyboardPresses []string // Track keyboard presses for verification - gamepadPresses []string // Track gamepad presses for verification + launchedMedia []string + launchedSystems []string + keyboardPresses []string + gamepadPresses []string + mu syncutil.Mutex } // ID returns the unique ID of this platform @@ -137,7 +139,9 @@ func (m *MockPlatform) ReturnToMenu() error { // LaunchSystem launches a system by ID func (m *MockPlatform) LaunchSystem(cfg *config.Instance, systemID string) error { args := m.Called(cfg, systemID) + m.mu.Lock() m.launchedSystems = append(m.launchedSystems, systemID) + m.mu.Unlock() if err := args.Error(0); err != nil { return fmt.Errorf("mock operation failed: %w", err) } @@ -150,7 +154,9 @@ func (m *MockPlatform) LaunchMedia( opts *platforms.LaunchOptions, ) error { args := m.Called(cfg, path, launcher, db, opts) + m.mu.Lock() m.launchedMedia = append(m.launchedMedia, path) + m.mu.Unlock() if err := args.Error(0); err != nil { return fmt.Errorf("mock operation failed: %w", err) } @@ -160,7 +166,9 @@ func (m *MockPlatform) LaunchMedia( // KeyboardPress presses and then releases a single keyboard button func (m *MockPlatform) KeyboardPress(key string) error { args := m.Called(key) + m.mu.Lock() m.keyboardPresses = append(m.keyboardPresses, key) + m.mu.Unlock() if err := args.Error(0); err != nil { return fmt.Errorf("mock operation failed: %w", err) } @@ -170,7 +178,9 @@ func (m *MockPlatform) KeyboardPress(key string) error { // GamepadPress presses and then releases a single gamepad button func (m *MockPlatform) GamepadPress(button string) error { args := m.Called(button) + m.mu.Lock() m.gamepadPresses = append(m.gamepadPresses, button) + m.mu.Unlock() if err := args.Error(0); err != nil { return fmt.Errorf("mock operation failed: %w", err) } @@ -260,26 +270,36 @@ func (m *MockPlatform) ConsoleManager() platforms.ConsoleManager { // GetLaunchedMedia returns a slice of all media paths that were launched func (m *MockPlatform) GetLaunchedMedia() []string { - return append([]string(nil), m.launchedMedia...) // Return a copy + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.launchedMedia...) } // GetLaunchedSystems returns a slice of all system IDs that were launched func (m *MockPlatform) GetLaunchedSystems() []string { - return append([]string(nil), m.launchedSystems...) // Return a copy + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.launchedSystems...) } // GetKeyboardPresses returns a slice of all keyboard keys that were pressed func (m *MockPlatform) GetKeyboardPresses() []string { - return append([]string(nil), m.keyboardPresses...) // Return a copy + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.keyboardPresses...) } // GetGamepadPresses returns a slice of all gamepad buttons that were pressed func (m *MockPlatform) GetGamepadPresses() []string { - return append([]string(nil), m.gamepadPresses...) // Return a copy + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.gamepadPresses...) } // ClearHistory clears all tracked interactions func (m *MockPlatform) ClearHistory() { + m.mu.Lock() + defer m.mu.Unlock() m.launchedMedia = m.launchedMedia[:0] m.launchedSystems = m.launchedSystems[:0] m.keyboardPresses = m.keyboardPresses[:0]