Compare commits
7 Commits
05c0dfb71a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8324ecd1e7 | |||
| 0567a1d29b | |||
| f2360cdae8 | |||
| 59245a469d | |||
| ab35e52728 | |||
| d2ce7f0fdc | |||
| a61f3a9c33 |
@@ -0,0 +1,10 @@
|
|||||||
|
# DOStalgia — Environment Configuration
|
||||||
|
# Copy this file to .env and fill in your values:
|
||||||
|
# cp .env.example .env
|
||||||
|
|
||||||
|
# Twitch Client ID for IGDB metadata auto-scrape.
|
||||||
|
# Get yours at https://dev.twitch.tv/console/apps → Create Application
|
||||||
|
# (Set OAuth Redirect URL to http://localhost, Category to "Other")
|
||||||
|
# Optional — IGDB features gracefully skip if not set.
|
||||||
|
# TWITCH_CLIENT_ID=your_client_id_here
|
||||||
|
# TWITCH_CLIENT_SECRET=your_client_secret_here
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
# This workflow builds and deploys Dostalgia whenever code is pushed to main.
|
# This workflow builds DOStalgia whenever code is pushed to main.
|
||||||
# The runner has Docker socket access so it can manage containers on the host.
|
# The runner has Docker socket access so it can manage containers on the host.
|
||||||
# Parameters match the docker-compose at:
|
|
||||||
# /appdata/dockhand/stacks/unraid/dostalgia/compose.yaml
|
|
||||||
# See: https://docs.gitea.com/usage/actions
|
# See: https://docs.gitea.com/usage/actions
|
||||||
|
|
||||||
name: Build & Deploy
|
name: Build & Deploy
|
||||||
@@ -13,8 +11,6 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE_NAME: dostalgia
|
IMAGE_NAME: dostalgia
|
||||||
CONTAINER_NAME: dostalgia
|
|
||||||
DATA_VOLUME: /mnt/cache/appdata/dostalgia
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
@@ -29,34 +25,5 @@ jobs:
|
|||||||
docker build -t ${{ env.IMAGE_NAME }}:${{ gitea.sha }} .
|
docker build -t ${{ env.IMAGE_NAME }}:${{ gitea.sha }} .
|
||||||
docker tag ${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ env.IMAGE_NAME }}:latest
|
docker tag ${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
- name: Deploy container
|
|
||||||
run: |
|
|
||||||
docker stop ${{ env.CONTAINER_NAME }} 2>/dev/null || true
|
|
||||||
docker rm ${{ env.CONTAINER_NAME }} 2>/dev/null || true
|
|
||||||
docker run -d \
|
|
||||||
--name ${{ env.CONTAINER_NAME }} \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--network dockernet \
|
|
||||||
-p 8765:8765/tcp \
|
|
||||||
-v ${{ env.DATA_VOLUME }}:/data \
|
|
||||||
-e TWITCH_CLIENT_ID='${{ secrets.TWITCH_CLIENT_ID }}' \
|
|
||||||
-e TWITCH_CLIENT_SECRET='${{ secrets.TWITCH_CLIENT_SECRET }}' \
|
|
||||||
${{ env.IMAGE_NAME }}:latest
|
|
||||||
|
|
||||||
- name: Verify container is running
|
|
||||||
run: |
|
|
||||||
sleep 5
|
|
||||||
STATUS=$(docker inspect -f '{{.State.Status}}' ${{ env.CONTAINER_NAME }})
|
|
||||||
if [ "$STATUS" = "running" ]; then
|
|
||||||
echo "✅ Dostalgia container is running"
|
|
||||||
echo "--- last logs ---"
|
|
||||||
docker logs ${{ env.CONTAINER_NAME }} --tail 5
|
|
||||||
echo "✅ Dostalgia deployed!"
|
|
||||||
else
|
|
||||||
echo "❌ Container status: $STATUS"
|
|
||||||
docker logs ${{ env.CONTAINER_NAME }} --tail 30
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Clean up old images
|
- name: Clean up old images
|
||||||
run: docker image prune -f --filter "until=24h"
|
run: docker image prune -f --filter "until=24h"
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Publish Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract tags
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: droideparanoico/dostalgia
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
|
|
||||||
|
# Environment — secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
# Built output
|
# Built output
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
dist/
|
dist/
|
||||||
@@ -36,3 +40,6 @@ data/saves/*
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Gitea
|
||||||
|
.gitea/
|
||||||
+3
-2
@@ -1,4 +1,4 @@
|
|||||||
# Dostalgia — Quarkus multi-stage build
|
# DOStalgia — Quarkus multi-stage build
|
||||||
|
|
||||||
# ─── 1. Build frontend ───────────────────────────
|
# ─── 1. Build frontend ───────────────────────────
|
||||||
FROM node:20-alpine AS frontend
|
FROM node:20-alpine AS frontend
|
||||||
@@ -7,6 +7,7 @@ COPY frontend/package.json frontend/package-lock.json ./
|
|||||||
RUN npm ci 2>/dev/null || npm install --legacy-peer-deps
|
RUN npm ci 2>/dev/null || npm install --legacy-peer-deps
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
RUN npm test
|
||||||
|
|
||||||
# ─── 2. Build Quarkus app ────────────────────────
|
# ─── 2. Build Quarkus app ────────────────────────
|
||||||
FROM maven:3-eclipse-temurin-21-alpine AS build
|
FROM maven:3-eclipse-temurin-21-alpine AS build
|
||||||
@@ -15,7 +16,7 @@ COPY pom.xml ./
|
|||||||
COPY src ./src
|
COPY src ./src
|
||||||
# Copy the built frontend into the source tree so maven-resources-plugin picks it up
|
# Copy the built frontend into the source tree so maven-resources-plugin picks it up
|
||||||
COPY --from=frontend /build/dist ./frontend/dist/
|
COPY --from=frontend /build/dist ./frontend/dist/
|
||||||
RUN mvn package -DskipTests -q
|
RUN mvn package -q -DskipFrontend=true -DskipFrontendTests=true
|
||||||
|
|
||||||
# ─── 3. Runtime ──────────────────────────────────
|
# ─── 3. Runtime ──────────────────────────────────
|
||||||
FROM eclipse-temurin:21-jre-alpine
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 droideparanoico
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,17 +1,109 @@
|
|||||||
# Dostalgia
|
# DOStalgia 🕹️
|
||||||
|
|
||||||
A nostalgic DOS game hub. Upload your old DOS games, scrape artwork, and play them directly in the browser via js-dos (WebAssembly DOSBox).
|
A nostalgic DOS game hub. Upload your old DOS games, auto-scrape artwork and metadata from IGDB, and play them directly in your browser via js-dos (DOSBox compiled to WebAssembly).
|
||||||
|
|
||||||
|
### Library view
|
||||||
|

|
||||||
|
|
||||||
|
### Game detail view
|
||||||
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🎮 Play in the browser
|
||||||
|
Every uploaded game is packaged into a `.jsdos` bundle — a standard ZIP with embedded DOSBox configuration. When you hit **Play**, js-dos v8 is loaded from CDN and starts the emulator instantly in your browser. No plugins, no native installs.
|
||||||
|
|
||||||
|
**Save states are automatic** — js-dos persists your game progress to the browser's storage. Come back anytime and pick up where you left off.
|
||||||
|
|
||||||
|
### 📦 Handles any file structure
|
||||||
|
DOS games come in all shapes. DOStalgia handles them transparently:
|
||||||
|
|
||||||
|
- **Flattening** — If your ZIP has a single root directory (e.g. `doom/` with all files inside), the extractor flattens it so the game files sit at the bundle root. No extra nesting.
|
||||||
|
- **Subdirectory games** — If files are deeper, the autoexec automatically `cd`s to the right directory before launching the executable.
|
||||||
|
- **CD images** — Games shipped on CD-ROM often need the disc mounted. DOStalgia detects `.iso`, `.cue`, `.img`, `.ccd`, and `.bin` files, fixes broken CloneCD `.cue` references (where the referenced `.bin` is actually `.img`), and mounts them in both `dosbox.conf` and `jsdos.json`.
|
||||||
|
- **CD-only games** — If the archive contains only CD images with no executable, a CD-only bundle is created that mounts the disc and drops you at the DOS prompt.
|
||||||
|
- **Hardcoded paths** — `ConfigPatcher` scans game config files for hardcoded absolute paths (e.g. `C:\FALLOUT1\MASTER.DAT`) that broke after flattening, and rewrites them to relative paths.
|
||||||
|
|
||||||
|
Other solutions such as [RomM](https://www.romm.app/) generate raw ZIP bundles and require you to write your own `dosbox.conf` with manual `mount` and `imgmount` commands. DOStalgia automates all of this for you.
|
||||||
|
|
||||||
|
### 🔍 Smart executable detection
|
||||||
|
On upload, DOStalgia scans every `.exe`, `.com`, and `.bat` file and picks the best candidate as the main executable using a scoring system:
|
||||||
|
|
||||||
|
1. **Not an installer** — INSTALL/SETUP/CONFIG executables are deprioritised
|
||||||
|
2. **DOS executables** — Pure DOS apps are preferred over Windows ones
|
||||||
|
3. **Larger files** — Bigger executables are more likely to be the game
|
||||||
|
4. **Shallow depth** — Files closer to the root are preferred
|
||||||
|
5. **Self-extractor filtering** — PKZIP/PKSFX stubs are filtered out
|
||||||
|
|
||||||
|
All discovered executables are stored and available in the **Edit** page, where you can pick a different one via radio buttons.
|
||||||
|
|
||||||
|
Again, RomM requires you to manually specify the executable in a custom `dosbox.conf`, while DOStalgia detects and configures it for you.
|
||||||
|
|
||||||
|
> **⚠️ Cache note:** If you change the executable, the `.jsdos` bundle is patched in-place. Your browser may serve a cached version of the old bundle — if the game doesn't launch with the new executable, **hard-refresh** the play page (Ctrl+Shift+R / Cmd+Shift+R).
|
||||||
|
|
||||||
|
### 🛠 One-click Setup launcher
|
||||||
|
Many DOS games include a `SETUP.EXE`, `INSTALL.EXE`, or `CONFIG.EXE` used to configure sound, controls, and graphics. When DOStalgia detects one:
|
||||||
|
|
||||||
|
- A **🛠 Setup** button appears on the game detail page
|
||||||
|
- Clicking it launches the setup executable *without modifying the main game bundle*
|
||||||
|
- The setup bundle is generated **on-the-fly** by the server — a modified `.jsdos` is streamed with the setup executable in the autoexec, then discarded. Nothing is written to disk.
|
||||||
|
|
||||||
|
This is not supported in RomM, where you would have to run the setup manually from the DOS prompt every time.
|
||||||
|
|
||||||
|
### 🪟 Windows game detection
|
||||||
|
DOSBox can't run Windows executables. DOStalgia's `PlatformDetector` reads MZ/PE/NE headers to detect Windows executables:
|
||||||
|
|
||||||
|
- The game detail page shows a **🪟 Requires Windows 3.1** badge
|
||||||
|
- The **Play** button is disabled with "Unplayable" text
|
||||||
|
- If you click Play anyway, a warning overlay explains the limitation with a **Try anyway** fallback
|
||||||
|
- Setup executables that are Windows-native are also filtered out from the Setup button
|
||||||
|
|
||||||
|
### 📡 IGDB metadata & media
|
||||||
|
DOStalgia integrates with [IGDB](https://www.igdb.com/) (via Twitch OAuth2) to auto-populate game info:
|
||||||
|
|
||||||
|
- **Auto Scrape** — On upload, DOStalgia searches IGDB by title and fills in year, genre, developer, publisher, description, and cover art. If IGDB finds a DOS result, it prioritises it.
|
||||||
|
- **Manual Search** — From the game detail page you can search IGDB by any query, browse results with cover thumbnails and DOS badges, and apply the one you want.
|
||||||
|
- **Media** — Videos (YouTube embeds) and screenshots (1080p) are fetched and displayed in a scrollable media gallery with a preview player.
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
|
||||||
|
1. Go to https://dev.twitch.tv/console/apps → **Register Your Application**
|
||||||
|
2. Name: `dostalgia` (or anything), OAuth Redirect URL: `http://localhost`, Category: **Other**
|
||||||
|
3. Copy the **Client ID** and generate a **Client Secret**
|
||||||
|
4. Provide them via environment variables:
|
||||||
|
|
||||||
|
Method | How
|
||||||
|
--- | ---
|
||||||
|
Docker run | `-e TWITCH_CLIENT_ID=xxx -e TWITCH_CLIENT_SECRET=yyy`
|
||||||
|
Docker Compose | Copy `.env.example` to `.env` and fill in the values
|
||||||
|
Local dev | `export TWITCH_CLIENT_ID=xxx TWITCH_CLIENT_SECRET=yyy`
|
||||||
|
|
||||||
|
IGDB features degrade gracefully — if credentials aren't set, uploads and metadata editing still work, just without auto-scrape.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### With Docker Compose (easiest)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build and run with Docker
|
# 1. (Optional) Enable IGDB metadata auto-scrape
|
||||||
docker build -t dostalgia .
|
cp .env.example .env
|
||||||
docker run -p 8765:8765 -v $(pwd)/data:/data dostalgia
|
# Edit .env with your Twitch credentials (see IGDB section below)
|
||||||
|
|
||||||
|
# 2. Pull & run
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:8765
|
Open http://localhost:8765
|
||||||
|
|
||||||
|
### With Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 8765:8765 -v $(pwd)/data:/data \
|
||||||
|
-e TWITCH_CLIENT_ID=your_id \
|
||||||
|
-e TWITCH_CLIENT_SECRET=your_secret \
|
||||||
|
droideparanoico/dostalgia
|
||||||
|
```
|
||||||
|
|
||||||
## Development (without Docker)
|
## Development (without Docker)
|
||||||
|
|
||||||
Terminal 1 — Frontend dev server:
|
Terminal 1 — Frontend dev server:
|
||||||
@@ -35,34 +127,8 @@ java -jar target/quarkus-app/quarkus-run.jar
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Backend**: Quarkus (Java 21, JAX-RS) — ~5 REST resource classes, zero database
|
- **Backend**: Quarkus (Java 21, JAX-RS) — REST endpoints, zero external database
|
||||||
- **Frontend**: Svelte 5 SPA with hash-based routing
|
- **Frontend**: Svelte 5 SPA with hash-based routing
|
||||||
- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly
|
- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly
|
||||||
- **Storage**: JSON-per-game under `/data/games/{id}/game.json`
|
- **Storage**: JSON-per-game under `/data/games/{id}/game.json`
|
||||||
|
- **Saves**: Browser localStorage / indexedDB (managed by js-dos)
|
||||||
## API
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | `/api/games` | List all games |
|
|
||||||
| GET | `/api/games/:id` | Get game details |
|
|
||||||
| PATCH | `/api/games/:id` | Update game metadata |
|
|
||||||
| DELETE | `/api/games/:id` | Delete a game |
|
|
||||||
| POST | `/api/upload` | Upload a game ZIP (multipart) |
|
|
||||||
| POST | `/api/games/:id/cover` | Upload cover art |
|
|
||||||
| GET | `/api/igdb/search?q=` | Search IGDB (placeholder) |
|
|
||||||
|
|
||||||
## Game JSON Schema
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "doom",
|
|
||||||
"title": "Doom",
|
|
||||||
"year": 1993,
|
|
||||||
"genre": "FPS",
|
|
||||||
"developer": "id Software",
|
|
||||||
"bundle_file": "doom.jsdos",
|
|
||||||
"has_cover": true,
|
|
||||||
"ready": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
+6
-6
@@ -1,14 +1,14 @@
|
|||||||
services:
|
services:
|
||||||
dostalgia:
|
dostalgia:
|
||||||
build: .
|
image: droideparanoico/dostalgia
|
||||||
container_name: dostalgia
|
container_name: dostalgia
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8765:8765"
|
- "8765:8765"
|
||||||
volumes:
|
volumes:
|
||||||
|
# Configure here your data directory if you want persistence.
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- DOSTALGIA_DATA_DIR=/data
|
- DOSTALGIA_DATA_DIR=/data
|
||||||
# IGDB credentials — set these for auto-scrape to work:
|
|
||||||
# - TWITCH_CLIENT_ID=your_client_id
|
|
||||||
# - TWITCH_CLIENT_SECRET=your_client_secret
|
|
||||||
restart: unless-stopped
|
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'><rect x='4' y='6' width='28' height='22' rx='2' fill='%2333FF33' opacity='.15'/><rect x='6' y='8' width='24' height='16' fill='%230A0A0A'/><rect x='8' y='10' width='20' height='10' fill='%2333FF33' opacity='.3'/><rect x='10' y='12' width='6' height='4' fill='%2333FF33' opacity='.6'/><rect x='18' y='12' width='6' height='4' fill='%2333FF33' opacity='.4'/></svg>" />
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'><rect x='4' y='6' width='28' height='22' rx='2' fill='%2333FF33' opacity='.15'/><rect x='6' y='8' width='24' height='16' fill='%230A0A0A'/><rect x='8' y='10' width='20' height='10' fill='%2333FF33' opacity='.3'/><rect x='10' y='12' width='6' height='4' fill='%2333FF33' opacity='.6'/><rect x='18' y='12' width='6' height='4' fill='%2333FF33' opacity='.4'/></svg>" />
|
||||||
<title>Dostalgia</title>
|
<title>DOStalgia</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
|
||||||
|
|||||||
Generated
+1874
-3
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "dostalgia-frontend",
|
"name": "dostalgia-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
|
"@testing-library/svelte": "^5.3.1",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Header onUploadClick={() => uploadTriggered++} />
|
<Header onUploadClick={() => uploadTriggered++} hideUpload={route !== "/"} />
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
{#if route === "/"}
|
{#if route === "/"}
|
||||||
|
|||||||
+13
-8
@@ -98,7 +98,8 @@ h3 { font-size: 1.2rem; }
|
|||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
BUTTONS
|
BUTTONS
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
.btn {
|
/* Only targets DOStalgia buttons, not js-dos emulator buttons inside .dos-container */
|
||||||
|
.btn.btn:not(.dos-container .btn) {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -111,32 +112,36 @@ h3 { font-size: 1.2rem; }
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-transform: none;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
line-height: normal;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
.btn:hover {
|
.btn.btn:not(.dos-container .btn):hover {
|
||||||
background: var(--phosphor-burn);
|
background: var(--phosphor-burn);
|
||||||
border-color: var(--phosphor);
|
border-color: var(--phosphor);
|
||||||
box-shadow: 0 0 12px var(--phosphor-dark);
|
box-shadow: 0 0 12px var(--phosphor-dark);
|
||||||
}
|
}
|
||||||
.btn:active {
|
.btn.btn:not(.dos-container .btn):active {
|
||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary.btn-primary:not(.dos-container .btn) {
|
||||||
background: var(--phosphor-dark);
|
background: var(--phosphor-dark);
|
||||||
border-color: var(--phosphor);
|
border-color: var(--phosphor);
|
||||||
color: var(--phosphor-glow);
|
color: var(--phosphor-glow);
|
||||||
}
|
}
|
||||||
.btn-primary:hover {
|
.btn-primary.btn-primary:not(.dos-container .btn):hover {
|
||||||
background: var(--phosphor-dim);
|
background: var(--phosphor-dim);
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger.btn-danger:not(.dos-container .btn) {
|
||||||
border-color: #cc3333;
|
border-color: #cc3333;
|
||||||
color: #cc3333;
|
color: #cc3333;
|
||||||
}
|
}
|
||||||
.btn-danger:hover {
|
.btn-danger.btn-danger:not(.dos-container .btn):hover {
|
||||||
background: #331111;
|
background: #331111;
|
||||||
border-color: #ff4444;
|
border-color: #ff4444;
|
||||||
color: #ff4444;
|
color: #ff4444;
|
||||||
@@ -187,7 +192,7 @@ input::placeholder {
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
h1 { font-size: 1.5rem; }
|
h1 { font-size: 1.5rem; }
|
||||||
h2 { font-size: 1.25rem; }
|
h2 { font-size: 1.25rem; }
|
||||||
.btn { padding: 8px 14px; font-size: 0.85rem; min-height: 40px; }
|
.btn.btn:not(.dos-container .btn) { padding: 8px 14px; font-size: 0.85rem; min-height: 40px; }
|
||||||
input, textarea, select { font-size: 16px; } /* prevents iOS zoom */
|
input, textarea, select { font-size: 16px; } /* prevents iOS zoom */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
let { onUploadClick = () => {} } = $props();
|
let { onUploadClick = () => {}, hideUpload = false } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="header">
|
<header class="header">
|
||||||
@@ -17,7 +17,9 @@
|
|||||||
<span class="logo-text">DOSTALGIA</span>
|
<span class="logo-text">DOSTALGIA</span>
|
||||||
</a>
|
</a>
|
||||||
<nav>
|
<nav>
|
||||||
<button class="btn" onclick={onUploadClick}>+ Upload</button>
|
{#if !hideUpload}
|
||||||
|
<button class="btn" onclick={onUploadClick}>+ Upload</button>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/svelte";
|
||||||
|
import GameCard from "../GameCard.svelte";
|
||||||
|
|
||||||
|
function makeGame(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: "test-1",
|
||||||
|
title: "Test Game",
|
||||||
|
year: 1995,
|
||||||
|
genre: "Adventure",
|
||||||
|
platform: "dos",
|
||||||
|
has_cover: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GameCard", () => {
|
||||||
|
it("renders the game title", () => {
|
||||||
|
render(GameCard, { props: { game: makeGame() } });
|
||||||
|
expect(screen.getByText("Test Game")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the year when provided", () => {
|
||||||
|
render(GameCard, { props: { game: makeGame({ year: 1997 }) } });
|
||||||
|
expect(screen.getByText("1997")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render year when absent", () => {
|
||||||
|
render(GameCard, { props: { game: makeGame({ year: null }) } });
|
||||||
|
expect(screen.queryByText("1995")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the genre when provided", () => {
|
||||||
|
render(GameCard, { props: { game: makeGame({ genre: "RPG" }) } });
|
||||||
|
expect(screen.getByText("RPG")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a cover image when has_cover is true", () => {
|
||||||
|
const game = makeGame({ has_cover: true });
|
||||||
|
render(GameCard, { props: { game } });
|
||||||
|
const img = screen.getByRole("img");
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.getAttribute("src")).toBe(`/games/${game.id}/cover.jpg`);
|
||||||
|
expect(img.getAttribute("alt")).toBe(game.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a placeholder when has_cover is false", () => {
|
||||||
|
render(GameCard, { props: { game: makeGame({ has_cover: false }) } });
|
||||||
|
expect(screen.getByText("💾")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Windows badge for windows platform", () => {
|
||||||
|
render(GameCard, {
|
||||||
|
props: { game: makeGame({ platform: "windows" }) },
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/🪟/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show Windows badge for DOS platform", () => {
|
||||||
|
render(GameCard, { props: { game: makeGame({ platform: "dos" }) } });
|
||||||
|
expect(screen.queryByText(/🪟/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates on click", async () => {
|
||||||
|
window.location.hash = "";
|
||||||
|
render(GameCard, { props: { game: makeGame() } });
|
||||||
|
const btn = screen.getByRole("button");
|
||||||
|
btn.click();
|
||||||
|
expect(window.location.hash).toBe("#/game/test-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { artworkUrl, bundleUrl, setupBundleUrl } from "../api.js";
|
||||||
|
|
||||||
|
describe("artworkUrl", () => {
|
||||||
|
it("returns null for null input", () => {
|
||||||
|
expect(artworkUrl(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for undefined input", () => {
|
||||||
|
expect(artworkUrl(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through absolute HTTP URLs", () => {
|
||||||
|
expect(artworkUrl("https://example.com/cover.jpg")).toBe(
|
||||||
|
"https://example.com/cover.jpg"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("proxies relative paths through /artwork/", () => {
|
||||||
|
expect(artworkUrl("images/game.png")).toBe("/artwork/images/game.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves nested relative paths", () => {
|
||||||
|
expect(artworkUrl("some/deep/path/file.webp")).toBe(
|
||||||
|
"/artwork/some/deep/path/file.webp"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bundleUrl", () => {
|
||||||
|
it("returns null for null game", () => {
|
||||||
|
expect(bundleUrl(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for undefined game", () => {
|
||||||
|
expect(bundleUrl(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds URL from bundle_file", () => {
|
||||||
|
expect(bundleUrl({ bundle_file: "mygame.zip" })).toBe("/games/mygame.zip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves subdirectory bundle paths", () => {
|
||||||
|
expect(bundleUrl({ bundle_file: "subdir/game.zip" })).toBe(
|
||||||
|
"/games/subdir/game.zip"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setupBundleUrl", () => {
|
||||||
|
it("returns null for null game", () => {
|
||||||
|
expect(setupBundleUrl(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for undefined game", () => {
|
||||||
|
expect(setupBundleUrl(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds API setup URL from game id", () => {
|
||||||
|
expect(setupBundleUrl({ id: "game-1" })).toBe(
|
||||||
|
"/api/games/game-1/setup-bundle"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles games with numeric id", () => {
|
||||||
|
expect(setupBundleUrl({ id: 42 })).toBe("/api/games/42/setup-bundle");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { push, replace } from "../router.js";
|
||||||
|
|
||||||
|
describe("router", () => {
|
||||||
|
describe("push", () => {
|
||||||
|
it("sets window.location.hash with # prefix", () => {
|
||||||
|
window.location.hash = "";
|
||||||
|
push("/game/123");
|
||||||
|
expect(window.location.hash).toBe("#/game/123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles root path", () => {
|
||||||
|
window.location.hash = "";
|
||||||
|
push("/");
|
||||||
|
expect(window.location.hash).toBe("#/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces hash on subsequent calls", () => {
|
||||||
|
window.location.hash = "";
|
||||||
|
push("/first");
|
||||||
|
push("/second");
|
||||||
|
expect(window.location.hash).toBe("#/second");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replace", () => {
|
||||||
|
it("sets hash via replace (no back-navigation allowed)", () => {
|
||||||
|
window.location.hash = "#/old";
|
||||||
|
replace("/new");
|
||||||
|
expect(window.location.hash).toBe("#/new");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/** API client for Dostalgia backend. */
|
/** API client for DOStalgia backend. */
|
||||||
|
|
||||||
const BASE = import.meta.env.PROD ? "" : ""; // Proxy handles it in dev
|
const BASE = import.meta.env.PROD ? "" : ""; // Proxy handles it in dev
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
← {isSetup ? "Back" : game.title}
|
← {isSetup ? "Back" : game.title}
|
||||||
</button>
|
</button>
|
||||||
{#if running}
|
{#if running}
|
||||||
<button class="btn stop-btn" onclick={stopEmulator}>⏹ Stop</button>
|
<button class="btn btn-danger" onclick={stopEmulator}>⏹ Stop</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,14 +211,6 @@
|
|||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
.link-button:hover { color: var(--phosphor); }
|
.link-button:hover { color: var(--phosphor); }
|
||||||
.stop-btn {
|
|
||||||
border-color: #cc3333;
|
|
||||||
color: #ff4444;
|
|
||||||
}
|
|
||||||
.stop-btn:hover {
|
|
||||||
background: #331111;
|
|
||||||
box-shadow: 0 0 12px #331111;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Emulator canvas — always mounted */
|
/* Emulator canvas — always mounted */
|
||||||
.dos-container {
|
.dos-container {
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/svelte";
|
||||||
|
import GameDetail from "../GameDetail.svelte";
|
||||||
|
|
||||||
|
// Mock api.js
|
||||||
|
vi.mock("../../lib/api.js", () => ({
|
||||||
|
getGame: vi.fn(),
|
||||||
|
deleteGame: vi.fn(),
|
||||||
|
updateGame: vi.fn(),
|
||||||
|
searchIGDB: vi.fn(),
|
||||||
|
scrapeIGDB: vi.fn(),
|
||||||
|
downloadGame: vi.fn(),
|
||||||
|
bundleUrl: vi.fn(),
|
||||||
|
setupBundleUrl: vi.fn(),
|
||||||
|
artworkUrl: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock router
|
||||||
|
vi.mock("../../lib/router.js", () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as api from "../../lib/api.js";
|
||||||
|
|
||||||
|
function makeGame(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: "game-1",
|
||||||
|
title: "Commander Keen",
|
||||||
|
year: 1991,
|
||||||
|
genre: "Platformer",
|
||||||
|
developer: "id Software",
|
||||||
|
publisher: "Apogee",
|
||||||
|
description: "A classic DOS platformer.",
|
||||||
|
platform: "dos",
|
||||||
|
ready: true,
|
||||||
|
has_cover: false,
|
||||||
|
has_setup: false,
|
||||||
|
bundle_file: "commander-keen.zip",
|
||||||
|
bundle_size: 512000,
|
||||||
|
executables: ["keen.exe"],
|
||||||
|
executable: null,
|
||||||
|
videos: [],
|
||||||
|
screenshots: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GameDetail", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading state initially", () => {
|
||||||
|
api.getGame.mockReturnValue(new Promise(() => {}));
|
||||||
|
render(GameDetail, { props: { id: "game-1" } });
|
||||||
|
expect(screen.getByText("Loading...")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when game is not found", async () => {
|
||||||
|
api.getGame.mockRejectedValue(new Error("Game not found"));
|
||||||
|
render(GameDetail, { props: { id: "missing" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("Game not found")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders game title", async () => {
|
||||||
|
api.getGame.mockResolvedValue(makeGame());
|
||||||
|
render(GameDetail, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("Commander Keen")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders developer and publisher", async () => {
|
||||||
|
api.getGame.mockResolvedValue(
|
||||||
|
makeGame({ developer: "id Software", publisher: "Apogee" })
|
||||||
|
);
|
||||||
|
render(GameDetail, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText(/id Software/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Apogee/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders year and genre as links", async () => {
|
||||||
|
api.getGame.mockResolvedValue(
|
||||||
|
makeGame({ year: 1991, genre: "Platformer" })
|
||||||
|
);
|
||||||
|
render(GameDetail, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("1991").closest("a")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Platformer").closest("a")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows DOS badge for dos platform", async () => {
|
||||||
|
api.getGame.mockResolvedValue(makeGame({ platform: "dos" }));
|
||||||
|
render(GameDetail, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("💾 DOS")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Windows badge for windows platform", async () => {
|
||||||
|
api.getGame.mockResolvedValue(makeGame({ platform: "windows" }));
|
||||||
|
render(GameDetail, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText(/Windows 3\.1/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Play button for ready DOS games", async () => {
|
||||||
|
api.getGame.mockResolvedValue(
|
||||||
|
makeGame({ ready: true, platform: "dos" })
|
||||||
|
);
|
||||||
|
render(GameDetail, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("▶ Play")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows disabled Play button for Windows games", async () => {
|
||||||
|
api.getGame.mockResolvedValue(
|
||||||
|
makeGame({ ready: true, platform: "windows" })
|
||||||
|
);
|
||||||
|
render(GameDetail, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("▶ Unplayable")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Setup button when has_setup is true", async () => {
|
||||||
|
api.getGame.mockResolvedValue(
|
||||||
|
makeGame({ has_setup: true })
|
||||||
|
);
|
||||||
|
render(GameDetail, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("🛠 Setup")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Edit, Download, Delete buttons", async () => {
|
||||||
|
api.getGame.mockResolvedValue(makeGame());
|
||||||
|
render(GameDetail, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("✏ Edit")).toBeTruthy();
|
||||||
|
expect(screen.getByText("⬇ Download")).toBeTruthy();
|
||||||
|
expect(screen.getByText("✕ Delete")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters edit mode when Edit button is clicked", async () => {
|
||||||
|
api.getGame.mockResolvedValue(makeGame());
|
||||||
|
render(GameDetail, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("✏ Edit")).toBeTruthy();
|
||||||
|
});
|
||||||
|
screen.getByText("✏ Edit").click();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("Save")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Cancel")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows cover placeholder when no cover", async () => {
|
||||||
|
api.getGame.mockResolvedValue(makeGame({ has_cover: false }));
|
||||||
|
render(GameDetail, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("💾")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows description when provided", async () => {
|
||||||
|
api.getGame.mockResolvedValue(
|
||||||
|
makeGame({ description: "A classic DOS platformer." })
|
||||||
|
);
|
||||||
|
render(GameDetail, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("A classic DOS platformer.")
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/svelte";
|
||||||
|
import Library from "../Library.svelte";
|
||||||
|
|
||||||
|
// Mock the API module that Library imports
|
||||||
|
vi.mock("../../lib/api.js", () => ({
|
||||||
|
listGames: vi.fn(),
|
||||||
|
uploadGame: vi.fn(),
|
||||||
|
searchIGDB: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as api from "../../lib/api.js";
|
||||||
|
|
||||||
|
function makeGame(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: "g1",
|
||||||
|
title: "Test Game",
|
||||||
|
year: 1995,
|
||||||
|
genre: "Adventure",
|
||||||
|
platform: "dos",
|
||||||
|
has_cover: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Library", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
window.location.hash = "#/";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading state initially", () => {
|
||||||
|
api.listGames.mockReturnValue(new Promise(() => {})); // never resolves
|
||||||
|
render(Library);
|
||||||
|
expect(screen.getByText("Scanning library...")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows empty state when no games", async () => {
|
||||||
|
api.listGames.mockResolvedValue({ games: [] });
|
||||||
|
render(Library);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("No games yet")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders game cards when games load", async () => {
|
||||||
|
api.listGames.mockResolvedValue({
|
||||||
|
games: [
|
||||||
|
makeGame({ id: "g1", title: "Game One" }),
|
||||||
|
makeGame({ id: "g2", title: "Game Two" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(Library);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("Game One")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Game Two")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows game count", async () => {
|
||||||
|
api.listGames.mockResolvedValue({
|
||||||
|
games: [
|
||||||
|
makeGame({ id: "g1", title: "One" }),
|
||||||
|
makeGame({ id: "g2", title: "Two" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(Library);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("2 games")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows singular game count for one game", async () => {
|
||||||
|
api.listGames.mockResolvedValue({
|
||||||
|
games: [makeGame({ id: "g1", title: "Only" })],
|
||||||
|
});
|
||||||
|
render(Library);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("1 game")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filters", () => {
|
||||||
|
it("shows filter options derived from loaded games", async () => {
|
||||||
|
api.listGames.mockResolvedValue({
|
||||||
|
games: [
|
||||||
|
makeGame({ id: "g1", genre: "RPG", year: 1997 }),
|
||||||
|
makeGame({ id: "g2", genre: "RPG", year: 1998 }),
|
||||||
|
makeGame({ id: "g3", genre: "Adventure", year: 1995 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(Library);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
// Year labels appear both as filter options and in game cards
|
||||||
|
expect(screen.getAllByText("1995").length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText("1997").length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText("1998").length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies genre filter from props", async () => {
|
||||||
|
api.listGames.mockResolvedValue({
|
||||||
|
games: [
|
||||||
|
makeGame({ id: "g1", title: "RPG Game", genre: "RPG" }),
|
||||||
|
makeGame({ id: "g2", title: "Adventure Game", genre: "Adventure" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(Library, { props: { genreFilter: "RPG" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("RPG Game")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("Adventure Game")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies year filter from props", async () => {
|
||||||
|
api.listGames.mockResolvedValue({
|
||||||
|
games: [
|
||||||
|
makeGame({ id: "g1", title: "Old Game", year: 1993 }),
|
||||||
|
makeGame({ id: "g2", title: "New Game", year: 1997 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(Library, { props: { yearFilter: "1997" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("New Game")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("Old Game")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows filter-empty message when no filter options exist", async () => {
|
||||||
|
api.listGames.mockResolvedValue({
|
||||||
|
games: [
|
||||||
|
makeGame({ id: "g1", genre: null, year: null }),
|
||||||
|
makeGame({ id: "g2", genre: null, year: null }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(Library);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const dashes = screen.getAllByText("—");
|
||||||
|
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/svelte";
|
||||||
|
import Play from "../Play.svelte";
|
||||||
|
|
||||||
|
// Mock the API module that Play imports
|
||||||
|
vi.mock("../../lib/api.js", () => ({
|
||||||
|
getGame: vi.fn(),
|
||||||
|
bundleUrl: vi.fn(),
|
||||||
|
setupBundleUrl: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock router
|
||||||
|
vi.mock("../../lib/router.js", () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as api from "../../lib/api.js";
|
||||||
|
|
||||||
|
function makeGame(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: "game-1",
|
||||||
|
title: "Commander Keen",
|
||||||
|
platform: "dos",
|
||||||
|
ready: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Play", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading state initially", () => {
|
||||||
|
api.getGame.mockReturnValue(new Promise(() => {}));
|
||||||
|
render(Play, { props: { id: "game-1" } });
|
||||||
|
expect(screen.getByText("Loading game data...")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows not-ready state when game is not processed", async () => {
|
||||||
|
api.getGame.mockResolvedValue(makeGame({ ready: false }));
|
||||||
|
render(Play, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("Not ready")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows unsupported warning for Windows games", async () => {
|
||||||
|
api.getGame.mockResolvedValue(
|
||||||
|
makeGame({ platform: "windows", ready: true })
|
||||||
|
);
|
||||||
|
render(Play, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("Windows game")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error box when game fetch fails", async () => {
|
||||||
|
api.getGame.mockRejectedValue(new Error("Game not found"));
|
||||||
|
render(Play, { props: { id: "missing" } });
|
||||||
|
// The error should appear in the overlay as well
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("⚠️")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows emulator booting state for ready DOS games", async () => {
|
||||||
|
api.getGame.mockResolvedValue(
|
||||||
|
makeGame({ platform: "dos", ready: true })
|
||||||
|
);
|
||||||
|
api.bundleUrl.mockReturnValue("/games/game-1.jsdos");
|
||||||
|
render(Play, { props: { id: "game-1" } });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("Starting emulator...")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
@@ -15,4 +16,12 @@ export default defineConfig({
|
|||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
conditions: process.env.VITEST ? ["browser", "module", "import"] : [],
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
include: ["src/**/*.test.js"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 261 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 488 B |
Binary file not shown.
|
After Width: | Height: | Size: 869 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 314 KiB |
@@ -4,9 +4,9 @@
|
|||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<groupId>com.dostalgia</groupId>
|
<groupId>org.dostalgia</groupId>
|
||||||
<artifactId>dostalgia</artifactId>
|
<artifactId>dostalgia</artifactId>
|
||||||
<version>0.1.0</version>
|
<version>0.1.1</version>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.release>21</maven.compiler.release>
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
@@ -15,6 +15,10 @@
|
|||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<quarkus.platform.version>3.19.2</quarkus.platform.version>
|
<quarkus.platform.version>3.19.2</quarkus.platform.version>
|
||||||
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||||
|
<!-- Set to true to skip all frontend npm tasks (install/build/test) -->
|
||||||
|
<skipFrontend>false</skipFrontend>
|
||||||
|
<!-- Set to true to skip only frontend tests (backend tests still run) -->
|
||||||
|
<skipFrontendTests>false</skipFrontendTests>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
@@ -97,6 +101,55 @@
|
|||||||
</execution>
|
</execution>
|
||||||
</executions>
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<!-- Run frontend npm tasks during the Maven lifecycle -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
|
<version>3.5.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>npm-install</id>
|
||||||
|
<phase>initialize</phase>
|
||||||
|
<goals><goal>exec</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<workingDirectory>frontend</workingDirectory>
|
||||||
|
<executable>npm</executable>
|
||||||
|
<arguments>
|
||||||
|
<argument>ci</argument>
|
||||||
|
<argument>--prefer-offline</argument>
|
||||||
|
</arguments>
|
||||||
|
<skip>${skipFrontend}</skip>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>frontend-build</id>
|
||||||
|
<phase>generate-resources</phase>
|
||||||
|
<goals><goal>exec</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<workingDirectory>frontend</workingDirectory>
|
||||||
|
<executable>npm</executable>
|
||||||
|
<arguments>
|
||||||
|
<argument>run</argument>
|
||||||
|
<argument>build</argument>
|
||||||
|
</arguments>
|
||||||
|
<skip>${skipFrontend}</skip>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>frontend-test</id>
|
||||||
|
<phase>test</phase>
|
||||||
|
<goals><goal>exec</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<workingDirectory>frontend</workingDirectory>
|
||||||
|
<executable>npm</executable>
|
||||||
|
<arguments>
|
||||||
|
<argument>test</argument>
|
||||||
|
</arguments>
|
||||||
|
<skip>${skipFrontendTests}</skip>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
+10
-18
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import jakarta.annotation.Nonnull;
|
import jakarta.annotation.Nonnull;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
@@ -36,19 +36,7 @@ public class ExecutableDetector {
|
|||||||
);
|
);
|
||||||
|
|
||||||
public List<String> findAll(Path dir) throws IOException {
|
public List<String> findAll(Path dir) throws IOException {
|
||||||
List<String> result = new ArrayList<>();
|
return FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
|
||||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
|
||||||
@Override
|
|
||||||
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
|
|
||||||
String name = f.getFileName().toString().toLowerCase();
|
|
||||||
if (name.endsWith(".exe") || name.endsWith(".com") || name.endsWith(".bat")) {
|
|
||||||
result.add(dir.relativize(f).toString().replace('\\', '/'));
|
|
||||||
}
|
|
||||||
return FileVisitResult.CONTINUE;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
result.sort(String::compareTo);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String findMain(Path dir) throws IOException {
|
public String findMain(Path dir) throws IOException {
|
||||||
@@ -94,9 +82,9 @@ public class ExecutableDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String findSetup(Path dir) throws IOException {
|
public String findSetup(Path dir) throws IOException {
|
||||||
record Candidate(int depth, long size, String path) {}
|
record Candidate(int depth, boolean isWindows, long size, String path) {}
|
||||||
List<Candidate> candidates = new ArrayList<>();
|
List<Candidate> candidates = new ArrayList<>();
|
||||||
List<String> patterns = List.of("setup", "install", "config", "custom");
|
List<String> patterns = List.of("setup", "setmain", "install", "config", "custom");
|
||||||
|
|
||||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||||
@Override
|
@Override
|
||||||
@@ -107,8 +95,9 @@ public class ExecutableDetector {
|
|||||||
return FileVisitResult.CONTINUE;
|
return FileVisitResult.CONTINUE;
|
||||||
for (String pat : patterns) {
|
for (String pat : patterns) {
|
||||||
if (stem.equals(pat) || stem.startsWith(pat + ".") || stem.startsWith(pat + "-")) {
|
if (stem.equals(pat) || stem.startsWith(pat + ".") || stem.startsWith(pat + "-")) {
|
||||||
|
boolean isWin = "windows".equals(platform.detect(f));
|
||||||
candidates.add(new Candidate(
|
candidates.add(new Candidate(
|
||||||
f.getNameCount() - dir.getNameCount(), a.size(), f.toString()));
|
f.getNameCount() - dir.getNameCount(), isWin, a.size(), f.toString()));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +106,10 @@ public class ExecutableDetector {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (candidates.isEmpty()) return null;
|
if (candidates.isEmpty()) return null;
|
||||||
candidates.sort(Comparator.comparingInt(Candidate::depth).thenComparingLong(Candidate::size));
|
candidates.sort(Comparator
|
||||||
|
.comparingInt((Candidate c) -> c.isWindows() ? 1 : 0) // prefer DOS over Windows
|
||||||
|
.thenComparingInt(Candidate::depth)
|
||||||
|
.thenComparingLong(Candidate::size));
|
||||||
return candidates.getFirst().path();
|
return candidates.getFirst().path();
|
||||||
}
|
}
|
||||||
|
|
||||||
+28
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import jakarta.annotation.Nonnull;
|
import jakarta.annotation.Nonnull;
|
||||||
|
|
||||||
@@ -8,6 +8,9 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.SimpleFileVisitor;
|
import java.nio.file.SimpleFileVisitor;
|
||||||
import java.nio.file.attribute.BasicFileAttributes;
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/** Shared file-system utilities. */
|
/** Shared file-system utilities. */
|
||||||
public final class FileUtils {
|
public final class FileUtils {
|
||||||
@@ -30,4 +33,28 @@ public final class FileUtils {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a directory tree and collect relative paths of files whose extension matches
|
||||||
|
* one of the given extensions. Extensions should include the dot, e.g. {@code ".exe"}.
|
||||||
|
* Results are sorted alphabetically.
|
||||||
|
*/
|
||||||
|
public static List<String> findFilesByExtensions(Path dir, Set<String> extensions) throws IOException {
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||||
|
@Override
|
||||||
|
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
|
||||||
|
String name = f.getFileName().toString().toLowerCase();
|
||||||
|
for (String ext : extensions) {
|
||||||
|
if (name.endsWith(ext)) {
|
||||||
|
result.add(dir.relativize(f).toString().replace('\\', '/'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
result.sort(String::compareTo);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
+7
-4
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -77,9 +77,12 @@ public class GameService implements GameStore {
|
|||||||
);
|
);
|
||||||
// Detect bundle size
|
// Detect bundle size
|
||||||
try {
|
try {
|
||||||
Path bundlePath = gamesDir.resolve(game.getBundleFile());
|
String bf = game.getBundleFile();
|
||||||
if (Files.exists(bundlePath)) {
|
if (bf != null) {
|
||||||
game.setBundleSize(Files.size(bundlePath));
|
Path bundlePath = gamesDir.resolve(bf);
|
||||||
|
if (Files.exists(bundlePath)) {
|
||||||
|
game.setBundleSize(Files.size(bundlePath));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException ignored) {}
|
} catch (IOException ignored) {}
|
||||||
return game;
|
return game;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ public class PlatformDetector {
|
|||||||
|
|
||||||
/** Read enough PE header bytes from any InputStream (shared by Path and ZIP callers). */
|
/** Read enough PE header bytes from any InputStream (shared by Path and ZIP callers). */
|
||||||
static byte[] readHeaderBytes(InputStream is) throws IOException {
|
static byte[] readHeaderBytes(InputStream is) throws IOException {
|
||||||
byte[] buf = new byte[0x200];
|
byte[] buf = new byte[0x1000];
|
||||||
int read = is.readNBytes(buf, 0, buf.length);
|
int read = is.readNBytes(buf, 0, buf.length);
|
||||||
return read < buf.length ? java.util.Arrays.copyOf(buf, read) : buf;
|
return read < buf.length ? java.util.Arrays.copyOf(buf, read) : buf;
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.PathParam;
|
import jakarta.ws.rs.PathParam;
|
||||||
@@ -53,7 +53,7 @@ public class StaticResource {
|
|||||||
@GET
|
@GET
|
||||||
@jakarta.ws.rs.Path("/api/health")
|
@jakarta.ws.rs.Path("/api/health")
|
||||||
public Response health() {
|
public Response health() {
|
||||||
return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build();
|
return Response.ok(Map.of("status", "ok", "version", "0.1.1")).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String guessContentType(String name) {
|
private String guessContentType(String name) {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
@@ -5,7 +5,6 @@ quarkus.http.cors=true
|
|||||||
|
|
||||||
# Allow large game uploads (DOS games can be 500MB+)
|
# Allow large game uploads (DOS games can be 500MB+)
|
||||||
quarkus.http.limits.max-body-size=2048M
|
quarkus.http.limits.max-body-size=2048M
|
||||||
quarkus.resteasy-reactive.multipart.input-part.max-size=2048M
|
|
||||||
|
|
||||||
# ─── Jackson: snake_case to match frontend expectations ─
|
# ─── Jackson: snake_case to match frontend expectations ─
|
||||||
quarkus.jackson.property-naming-strategy=SNAKE_CASE
|
quarkus.jackson.property-naming-strategy=SNAKE_CASE
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
+65
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
@@ -9,6 +9,7 @@ import java.util.List;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
@@ -136,6 +137,69 @@ class ExecutableDetectorTest {
|
|||||||
assertFalse(ExecutableDetector.isLikelySelfExtractor(f));
|
assertFalse(ExecutableDetector.isLikelySelfExtractor(f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findMain_prefersDosOverWindows(@TempDir Path dir) throws Exception {
|
||||||
|
// Windows PE executable: MZ header + PE signature at offset 0x80
|
||||||
|
byte[] windowsExe = new byte[0x100];
|
||||||
|
windowsExe[0] = 0x4D; windowsExe[1] = 0x5A; // MZ
|
||||||
|
windowsExe[0x3C] = (byte) 0x80; // PE offset at 0x80
|
||||||
|
windowsExe[0x80] = 0x50; windowsExe[0x81] = 0x45; // "PE" signature
|
||||||
|
Files.write(dir.resolve("WIN.EXE"), windowsExe);
|
||||||
|
|
||||||
|
// DOS executable: just MZ header, no PE
|
||||||
|
byte[] dosExe = new byte[0x40];
|
||||||
|
dosExe[0] = 0x4D; dosExe[1] = 0x5A; // MZ
|
||||||
|
Files.write(dir.resolve("DOS.EXE"), dosExe);
|
||||||
|
|
||||||
|
String main = detector.findMain(dir);
|
||||||
|
assertNotNull(main);
|
||||||
|
assertTrue(main.endsWith("DOS.EXE"),
|
||||||
|
"DOS executable should be preferred over Windows, but got: " + main);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findMain_returnsNullForNoExecutables(@TempDir Path dir) throws Exception {
|
||||||
|
Files.writeString(dir.resolve("readme.txt"), "text");
|
||||||
|
Files.writeString(dir.resolve("notes.md"), "markdown");
|
||||||
|
assertNull(detector.findMain(dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findMain_skipsSelfExtractorsWithPkSignature(@TempDir Path dir) throws Exception {
|
||||||
|
// Self-extractor with PK\x03\x04 signature bytes (ZIP local header)
|
||||||
|
byte[] sfx = new byte[0x80];
|
||||||
|
sfx[0] = 0x4D; sfx[1] = 0x5A; // MZ header
|
||||||
|
sfx[0x60] = 0x50; sfx[0x61] = 0x4B; sfx[0x62] = 0x03; sfx[0x63] = 0x04; // PK\x03\x04
|
||||||
|
Files.write(dir.resolve("SFX.EXE"), sfx);
|
||||||
|
|
||||||
|
// Real game executable
|
||||||
|
Files.write(dir.resolve("GAME.EXE"), createMZ(2000));
|
||||||
|
|
||||||
|
String main = detector.findMain(dir);
|
||||||
|
assertNotNull(main);
|
||||||
|
assertTrue(main.endsWith("GAME.EXE"),
|
||||||
|
"Self-extractor with PK signature bytes should be skipped, but got: " + main);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findMain_handlesMixedContent(@TempDir Path dir) throws Exception {
|
||||||
|
Files.writeString(dir.resolve("readme.txt"), "text");
|
||||||
|
Files.write(dir.resolve("DOS4GW.EXE"), createMZ(200)); // extender, skipped
|
||||||
|
Files.write(dir.resolve("INSTALL.EXE"), createMZ(150)); // installer, deprioritized
|
||||||
|
Files.write(dir.resolve("GAME.EXE"), createMZ(5000)); // real game, should win
|
||||||
|
Files.write(dir.resolve("helper.com"), createMZ(100)); // small .com
|
||||||
|
|
||||||
|
String main = detector.findMain(dir);
|
||||||
|
assertNotNull(main);
|
||||||
|
assertTrue(main.endsWith("GAME.EXE"),
|
||||||
|
"Main game executable should be selected, but got: " + main);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_returnsEmptyForEmptyDir(@TempDir Path dir) throws Exception {
|
||||||
|
assertTrue(detector.findAll(dir).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
private static byte[] createMZ(int size) {
|
private static byte[] createMZ(int size) {
|
||||||
byte[] buf = new byte[size];
|
byte[] buf = new byte[size];
|
||||||
buf[0] = 0x4D; // M
|
buf[0] = 0x4D; // M
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package org.dostalgia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class FileUtilsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteDirectory_nonExistentDir_doesNotThrow(@TempDir Path dir) {
|
||||||
|
Path nonExistent = dir.resolve("doesnotexist");
|
||||||
|
assertDoesNotThrow(() -> FileUtils.deleteDirectory(nonExistent));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteDirectory_deletesFilesRecursively(@TempDir Path dir) throws Exception {
|
||||||
|
Files.writeString(dir.resolve("file1.txt"), "hello");
|
||||||
|
Files.writeString(dir.resolve("file2.txt"), "world");
|
||||||
|
Path sub = Files.createDirectories(dir.resolve("sub"));
|
||||||
|
Files.writeString(sub.resolve("nested.txt"), "deep");
|
||||||
|
|
||||||
|
assertTrue(Files.exists(dir.resolve("file1.txt")));
|
||||||
|
FileUtils.deleteDirectory(dir);
|
||||||
|
assertFalse(Files.exists(dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteDirectory_deletesSubdirectories(@TempDir Path dir) throws Exception {
|
||||||
|
Path a = Files.createDirectories(dir.resolve("a/b/c"));
|
||||||
|
Files.writeString(a.resolve("deep.txt"), "deep");
|
||||||
|
Path sub2 = Files.createDirectories(dir.resolve("sub2"));
|
||||||
|
Files.writeString(sub2.resolve("file.txt"), "data");
|
||||||
|
|
||||||
|
FileUtils.deleteDirectory(dir);
|
||||||
|
assertFalse(Files.exists(dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFilesByExtensions_findsExeComBat(@TempDir Path dir) throws Exception {
|
||||||
|
Files.writeString(dir.resolve("game.exe"), "data");
|
||||||
|
Files.writeString(dir.resolve("helper.com"), "data");
|
||||||
|
Files.writeString(dir.resolve("run.bat"), "data");
|
||||||
|
Files.writeString(dir.resolve("readme.txt"), "data");
|
||||||
|
|
||||||
|
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
|
||||||
|
assertEquals(3, result.size());
|
||||||
|
assertTrue(result.contains("game.exe"));
|
||||||
|
assertTrue(result.contains("helper.com"));
|
||||||
|
assertTrue(result.contains("run.bat"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFilesByExtensions_caseInsensitive(@TempDir Path dir) throws Exception {
|
||||||
|
Files.writeString(dir.resolve("GAME.EXE"), "data");
|
||||||
|
Files.writeString(dir.resolve("Helper.Com"), "data");
|
||||||
|
Files.writeString(dir.resolve("RUN.BAT"), "data");
|
||||||
|
|
||||||
|
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
|
||||||
|
assertEquals(3, result.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFilesByExtensions_emptyDir_returnsEmpty(@TempDir Path dir) throws Exception {
|
||||||
|
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe"));
|
||||||
|
assertTrue(result.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFilesByExtensions_noMatches_returnsEmpty(@TempDir Path dir) throws Exception {
|
||||||
|
Files.writeString(dir.resolve("readme.txt"), "data");
|
||||||
|
Files.writeString(dir.resolve("notes.md"), "data");
|
||||||
|
|
||||||
|
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
|
||||||
|
assertTrue(result.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFilesByExtensions_includesSubdirectories(@TempDir Path dir) throws Exception {
|
||||||
|
Path sub = Files.createDirectories(dir.resolve("subdir"));
|
||||||
|
Files.writeString(sub.resolve("game.exe"), "data");
|
||||||
|
Files.writeString(dir.resolve("root.exe"), "data");
|
||||||
|
|
||||||
|
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe"));
|
||||||
|
assertEquals(2, result.size());
|
||||||
|
assertTrue(result.contains("root.exe"));
|
||||||
|
assertTrue(result.contains("subdir/game.exe"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package org.dostalgia;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.NoSuchFileException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class GameServiceTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||||
|
.registerModule(new JavaTimeModule())
|
||||||
|
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||||
|
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||||
|
.enable(SerializationFeature.INDENT_OUTPUT);
|
||||||
|
|
||||||
|
// -- sanitizeId tests --
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizeId_normalString_kept() {
|
||||||
|
assertEquals("hello", GameService.sanitizeId("hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizeId_uppercaseToLowercase() {
|
||||||
|
assertEquals("hello", GameService.sanitizeId("HELLO"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizeId_spacesToHyphens() {
|
||||||
|
assertEquals("hello-world", GameService.sanitizeId("hello world"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizeId_specialCharsStripped() {
|
||||||
|
assertEquals("helloworld", GameService.sanitizeId("hello!@#$world"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizeId_tripleHyphenStripped() {
|
||||||
|
assertEquals("test", GameService.sanitizeId("---test---"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizeId_emptyReturnsUnknown() {
|
||||||
|
assertEquals("unknown", GameService.sanitizeId(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizeId_onlySpecialCharsReturnsUnknown() {
|
||||||
|
assertEquals("unknown", GameService.sanitizeId("!@#$%^&*()"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizeId_mixedCaseAndSpaces() {
|
||||||
|
assertEquals("doom-ii-hell-on-earth", GameService.sanitizeId("DOOM II: Hell on Earth"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizeId_underscoresToHyphens() {
|
||||||
|
assertEquals("my-game", GameService.sanitizeId("my_game"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizeId_numbersPreserved() {
|
||||||
|
assertEquals("game2", GameService.sanitizeId("Game2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- gameDir / gameJsonPath tests --
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gameDir_returnsCorrectPath(@TempDir Path tempDir) throws Exception {
|
||||||
|
GameService svc = new GameService();
|
||||||
|
svc.dataDir = tempDir.toString();
|
||||||
|
svc.init();
|
||||||
|
|
||||||
|
Path dir = svc.gameDir("test-game");
|
||||||
|
assertEquals(tempDir.resolve("games/test-game"), dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gameJsonPath_returnsCorrectPath(@TempDir Path tempDir) throws Exception {
|
||||||
|
GameService svc = new GameService();
|
||||||
|
svc.dataDir = tempDir.toString();
|
||||||
|
svc.init();
|
||||||
|
|
||||||
|
Path path = svc.gameJsonPath("test-game");
|
||||||
|
assertEquals(tempDir.resolve("games/test-game/game.json"), path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- init tests --
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void init_createsDirectories(@TempDir Path tempDir) throws Exception {
|
||||||
|
GameService svc = new GameService();
|
||||||
|
svc.dataDir = tempDir.toString();
|
||||||
|
svc.init();
|
||||||
|
|
||||||
|
assertTrue(Files.isDirectory(tempDir.resolve("games")));
|
||||||
|
assertTrue(Files.isDirectory(tempDir.resolve("artwork")));
|
||||||
|
assertTrue(Files.isDirectory(tempDir.resolve("saves")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- save / load tests --
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveAndLoad_roundTrip(@TempDir Path tempDir) throws Exception {
|
||||||
|
GameService svc = new GameService();
|
||||||
|
svc.dataDir = tempDir.toString();
|
||||||
|
svc.init();
|
||||||
|
|
||||||
|
Game game = new Game("test-game", "Test Game");
|
||||||
|
game.setYear(1995);
|
||||||
|
game.setGenre("FPS");
|
||||||
|
svc.save(game);
|
||||||
|
|
||||||
|
Game loaded = svc.load("test-game");
|
||||||
|
assertEquals("test-game", loaded.getId());
|
||||||
|
assertEquals("Test Game", loaded.getTitle());
|
||||||
|
assertEquals(1995, loaded.getYear());
|
||||||
|
assertEquals("FPS", loaded.getGenre());
|
||||||
|
assertNotNull(loaded.getCreatedAt());
|
||||||
|
assertNotNull(loaded.getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void save_updatesUpdatedAt(@TempDir Path tempDir) throws Exception {
|
||||||
|
GameService svc = new GameService();
|
||||||
|
svc.dataDir = tempDir.toString();
|
||||||
|
svc.init();
|
||||||
|
|
||||||
|
Game game = new Game("game1", "Game One");
|
||||||
|
svc.save(game);
|
||||||
|
Thread.sleep(10); // ensure timestamp advances
|
||||||
|
|
||||||
|
game.setGenre("Adventure");
|
||||||
|
svc.save(game);
|
||||||
|
|
||||||
|
Game loaded = svc.load("game1");
|
||||||
|
assertNotNull(loaded.getUpdatedAt());
|
||||||
|
assertTrue(loaded.getUpdatedAt().isAfter(loaded.getCreatedAt())
|
||||||
|
|| loaded.getUpdatedAt().equals(loaded.getCreatedAt()),
|
||||||
|
"updatedAt should be >= createdAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_nonExistent_throwsNoSuchFileException(@TempDir Path tempDir) throws Exception {
|
||||||
|
GameService svc = new GameService();
|
||||||
|
svc.dataDir = tempDir.toString();
|
||||||
|
svc.init();
|
||||||
|
|
||||||
|
assertThrows(NoSuchFileException.class, () -> svc.load("nonexistent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- list tests --
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_emptyDir_returnsEmpty(@TempDir Path tempDir) throws Exception {
|
||||||
|
GameService svc = new GameService();
|
||||||
|
svc.dataDir = tempDir.toString();
|
||||||
|
svc.init();
|
||||||
|
|
||||||
|
List<Game> games = svc.list();
|
||||||
|
assertTrue(games.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveAndList_returnsGame(@TempDir Path tempDir) throws Exception {
|
||||||
|
GameService svc = new GameService();
|
||||||
|
svc.dataDir = tempDir.toString();
|
||||||
|
svc.init();
|
||||||
|
|
||||||
|
svc.save(new Game("game1", "Game One"));
|
||||||
|
svc.save(new Game("game2", "Game Two"));
|
||||||
|
|
||||||
|
List<Game> games = svc.list();
|
||||||
|
assertEquals(2, games.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_sortsByTitle(@TempDir Path tempDir) throws Exception {
|
||||||
|
GameService svc = new GameService();
|
||||||
|
svc.dataDir = tempDir.toString();
|
||||||
|
svc.init();
|
||||||
|
|
||||||
|
svc.save(new Game("zzz", "Z Game"));
|
||||||
|
svc.save(new Game("aaa", "A Game"));
|
||||||
|
|
||||||
|
List<Game> games = svc.list();
|
||||||
|
assertEquals(2, games.size());
|
||||||
|
assertEquals("A Game", games.get(0).getTitle());
|
||||||
|
assertEquals("Z Game", games.get(1).getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- delete tests --
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void delete_removesGameDir(@TempDir Path tempDir) throws Exception {
|
||||||
|
GameService svc = new GameService();
|
||||||
|
svc.dataDir = tempDir.toString();
|
||||||
|
svc.init();
|
||||||
|
|
||||||
|
svc.save(new Game("game1", "Game One"));
|
||||||
|
assertTrue(Files.exists(svc.gameDir("game1")));
|
||||||
|
|
||||||
|
svc.delete("game1");
|
||||||
|
assertFalse(Files.exists(svc.gameDir("game1")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void delete_nonExistent_doesNotThrow(@TempDir Path tempDir) throws Exception {
|
||||||
|
GameService svc = new GameService();
|
||||||
|
svc.dataDir = tempDir.toString();
|
||||||
|
svc.init();
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> svc.delete("nonexistent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void delete_removesOldJsdosFiles(@TempDir Path tempDir) throws Exception {
|
||||||
|
GameService svc = new GameService();
|
||||||
|
svc.dataDir = tempDir.toString();
|
||||||
|
svc.init();
|
||||||
|
|
||||||
|
// Create backward-compat files that delete should clean up
|
||||||
|
Files.writeString(tempDir.resolve("games/game1.jsdos"), "old");
|
||||||
|
Files.writeString(tempDir.resolve("games/game1.setup.jsdos"), "old-setup");
|
||||||
|
|
||||||
|
svc.delete("game1");
|
||||||
|
|
||||||
|
assertFalse(Files.exists(tempDir.resolve("games/game1.jsdos")));
|
||||||
|
assertFalse(Files.exists(tempDir.resolve("games/game1.setup.jsdos")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- JSON serialization tests --
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gameSerialization_snakeCase(@TempDir Path tempDir) throws Exception {
|
||||||
|
Game g = new Game("my-game", "My Game");
|
||||||
|
g.setIgdbCoverId("abc123");
|
||||||
|
g.setHasCover(true);
|
||||||
|
g.setHasScreenshots(false);
|
||||||
|
g.setIgdbId(42);
|
||||||
|
g.setBundleSize(1024L);
|
||||||
|
g.setSetupExe("setup.exe");
|
||||||
|
|
||||||
|
String json = MAPPER.writeValueAsString(g);
|
||||||
|
assertTrue(json.contains("\"igdb_cover_id\""));
|
||||||
|
assertTrue(json.contains("\"has_cover\""));
|
||||||
|
assertTrue(json.contains("\"has_screenshots\""));
|
||||||
|
assertTrue(json.contains("\"bundle_size\""));
|
||||||
|
assertTrue(json.contains("\"setup_exe\""));
|
||||||
|
assertFalse(json.contains("igdbCoverId"));
|
||||||
|
assertFalse(json.contains("hasCover"));
|
||||||
|
assertFalse(json.contains("hasScreenshots"));
|
||||||
|
assertFalse(json.contains("bundleSize"));
|
||||||
|
assertFalse(json.contains("setupExe"));
|
||||||
|
|
||||||
|
Game deserialized = MAPPER.readValue(json, Game.class);
|
||||||
|
assertEquals("my-game", deserialized.getId());
|
||||||
|
assertEquals("My Game", deserialized.getTitle());
|
||||||
|
assertEquals("abc123", deserialized.getIgdbCoverId());
|
||||||
|
assertTrue(deserialized.isHasCover());
|
||||||
|
assertFalse(deserialized.isHasScreenshots());
|
||||||
|
assertEquals(Integer.valueOf(42), deserialized.getIgdbId());
|
||||||
|
assertEquals(Long.valueOf(1024L), deserialized.getBundleSize());
|
||||||
|
assertEquals("setup.exe", deserialized.getSetupExe());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gameSerialization_timestamps(@TempDir Path tempDir) throws Exception {
|
||||||
|
Game g = new Game("ts-game", "Timestamp Test");
|
||||||
|
String json = MAPPER.writeValueAsString(g);
|
||||||
|
|
||||||
|
assertTrue(json.contains("\"created_at\""));
|
||||||
|
assertTrue(json.contains("\"updated_at\""));
|
||||||
|
|
||||||
|
Game deserialized = MAPPER.readValue(json, Game.class);
|
||||||
|
assertNotNull(deserialized.getCreatedAt());
|
||||||
|
assertNotNull(deserialized.getUpdatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package org.dostalgia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class GameTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noArgConstructor_setsNothing() {
|
||||||
|
Game g = new Game();
|
||||||
|
assertNull(g.getId());
|
||||||
|
assertNull(g.getTitle());
|
||||||
|
assertNull(g.getYear());
|
||||||
|
assertNull(g.getGenre());
|
||||||
|
assertNull(g.getDeveloper());
|
||||||
|
assertNull(g.getPublisher());
|
||||||
|
assertNull(g.getDescription());
|
||||||
|
assertNull(g.getRating());
|
||||||
|
assertNull(g.getBundleFile());
|
||||||
|
assertNull(g.getSetupExe());
|
||||||
|
assertNull(g.getBundleSize());
|
||||||
|
assertNull(g.getExecutable());
|
||||||
|
assertNull(g.getExecutables());
|
||||||
|
assertNull(g.getPlatform());
|
||||||
|
assertNull(g.getIgdbId());
|
||||||
|
assertNull(g.getIgdbCoverId());
|
||||||
|
assertFalse(g.isHasCover());
|
||||||
|
assertFalse(g.isHasScreenshots());
|
||||||
|
assertNull(g.getScreenshots());
|
||||||
|
assertNull(g.getVideos());
|
||||||
|
assertFalse(g.isReady());
|
||||||
|
assertNull(g.getCreatedAt());
|
||||||
|
assertNull(g.getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twoArgConstructor_setsIdTitleAndTimestamps() {
|
||||||
|
Game g = new Game("test-id", "Test Game");
|
||||||
|
assertEquals("test-id", g.getId());
|
||||||
|
assertEquals("Test Game", g.getTitle());
|
||||||
|
assertFalse(g.isReady());
|
||||||
|
assertNotNull(g.getCreatedAt());
|
||||||
|
assertNotNull(g.getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void settersAndGetters_roundTrip() {
|
||||||
|
Game g = new Game();
|
||||||
|
g.setId("id1");
|
||||||
|
g.setTitle("Title");
|
||||||
|
g.setYear(1995);
|
||||||
|
g.setGenre("Action");
|
||||||
|
g.setDeveloper("DevCo");
|
||||||
|
g.setPublisher("PubCo");
|
||||||
|
g.setDescription("A great game");
|
||||||
|
g.setRating(4.5);
|
||||||
|
g.setBundleFile("game.zip");
|
||||||
|
g.setSetupExe("setup.exe");
|
||||||
|
g.setBundleSize(12345L);
|
||||||
|
g.setExecutable("game.exe");
|
||||||
|
g.setExecutables(List.of("game.exe", "setup.exe"));
|
||||||
|
g.setPlatform("dos");
|
||||||
|
g.setIgdbId(42);
|
||||||
|
g.setIgdbCoverId("abc123");
|
||||||
|
g.setHasCover(true);
|
||||||
|
g.setHasScreenshots(true);
|
||||||
|
g.setScreenshots(List.of("shot1.png"));
|
||||||
|
g.setVideos(List.of("vid1"));
|
||||||
|
g.setReady(true);
|
||||||
|
Instant now = Instant.now();
|
||||||
|
g.setCreatedAt(now);
|
||||||
|
g.setUpdatedAt(now);
|
||||||
|
|
||||||
|
assertEquals("id1", g.getId());
|
||||||
|
assertEquals("Title", g.getTitle());
|
||||||
|
assertEquals(1995, g.getYear());
|
||||||
|
assertEquals("Action", g.getGenre());
|
||||||
|
assertEquals("DevCo", g.getDeveloper());
|
||||||
|
assertEquals("PubCo", g.getPublisher());
|
||||||
|
assertEquals("A great game", g.getDescription());
|
||||||
|
assertEquals(4.5, g.getRating());
|
||||||
|
assertEquals("game.zip", g.getBundleFile());
|
||||||
|
assertEquals("setup.exe", g.getSetupExe());
|
||||||
|
assertEquals(12345L, g.getBundleSize());
|
||||||
|
assertEquals("game.exe", g.getExecutable());
|
||||||
|
assertEquals(List.of("game.exe", "setup.exe"), g.getExecutables());
|
||||||
|
assertEquals("dos", g.getPlatform());
|
||||||
|
assertEquals(42, g.getIgdbId());
|
||||||
|
assertEquals("abc123", g.getIgdbCoverId());
|
||||||
|
assertTrue(g.isHasCover());
|
||||||
|
assertTrue(g.isHasScreenshots());
|
||||||
|
assertEquals(List.of("shot1.png"), g.getScreenshots());
|
||||||
|
assertEquals(List.of("vid1"), g.getVideos());
|
||||||
|
assertTrue(g.isReady());
|
||||||
|
assertEquals(now, g.getCreatedAt());
|
||||||
|
assertEquals(now, g.getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isHasSetup_nullSetupExe_returnsFalse() {
|
||||||
|
Game g = new Game();
|
||||||
|
g.setSetupExe(null);
|
||||||
|
assertFalse(g.isHasSetup());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isHasSetup_blankSetupExe_returnsFalse() {
|
||||||
|
Game g = new Game();
|
||||||
|
g.setSetupExe(" ");
|
||||||
|
assertFalse(g.isHasSetup());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isHasSetup_nonBlankSetupExe_returnsTrue() {
|
||||||
|
Game g = new Game();
|
||||||
|
g.setSetupExe("setup.exe");
|
||||||
|
assertTrue(g.isHasSetup());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isPlayable_notReady_returnsFalse() {
|
||||||
|
Game g = new Game();
|
||||||
|
g.setReady(false);
|
||||||
|
assertFalse(g.isPlayable());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isPlayable_readyNullPlatform_returnsTrue() {
|
||||||
|
Game g = new Game();
|
||||||
|
g.setReady(true);
|
||||||
|
g.setPlatform(null);
|
||||||
|
assertTrue(g.isPlayable());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isPlayable_readyDosPlatform_returnsTrue() {
|
||||||
|
Game g = new Game();
|
||||||
|
g.setReady(true);
|
||||||
|
g.setPlatform("dos");
|
||||||
|
assertTrue(g.isPlayable());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isPlayable_readyWindowsPlatform_returnsFalse() {
|
||||||
|
Game g = new Game();
|
||||||
|
g.setReady(true);
|
||||||
|
g.setPlatform("windows");
|
||||||
|
assertFalse(g.isPlayable());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package org.dostalgia;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class IgdbServiceTest {
|
||||||
|
|
||||||
|
private IgdbService service;
|
||||||
|
private ObjectMapper mapper;
|
||||||
|
|
||||||
|
private Method epochToYearMethod;
|
||||||
|
private Method extractGenresMethod;
|
||||||
|
private Method sanitizeMethod;
|
||||||
|
private Method jsonNodeToMapMethod;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void setUp() throws Exception {
|
||||||
|
service = new IgdbService();
|
||||||
|
service.clientId = Optional.of("test-client-id");
|
||||||
|
service.clientSecret = Optional.of("test-client-secret");
|
||||||
|
mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
epochToYearMethod = IgdbService.class.getDeclaredMethod("epochToYear", JsonNode.class);
|
||||||
|
epochToYearMethod.setAccessible(true);
|
||||||
|
|
||||||
|
extractGenresMethod = IgdbService.class.getDeclaredMethod("extractGenres", JsonNode.class);
|
||||||
|
extractGenresMethod.setAccessible(true);
|
||||||
|
|
||||||
|
sanitizeMethod = IgdbService.class.getDeclaredMethod("sanitize", String.class);
|
||||||
|
sanitizeMethod.setAccessible(true);
|
||||||
|
|
||||||
|
jsonNodeToMapMethod = IgdbService.class.getDeclaredMethod("jsonNodeToMap", JsonNode.class);
|
||||||
|
jsonNodeToMapMethod.setAccessible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- isConfigured tests --
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isConfigured_returnsTrueWhenBothPresentAndNonBlank() {
|
||||||
|
assertTrue(service.isConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isConfigured_returnsFalseWhenClientIdEmpty() {
|
||||||
|
service.clientId = Optional.empty();
|
||||||
|
assertFalse(service.isConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isConfigured_returnsFalseWhenClientSecretEmpty() {
|
||||||
|
service.clientSecret = Optional.empty();
|
||||||
|
assertFalse(service.isConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isConfigured_returnsFalseWhenClientIdBlank() {
|
||||||
|
service.clientId = Optional.of("");
|
||||||
|
assertFalse(service.isConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isConfigured_returnsFalseWhenClientSecretBlank() {
|
||||||
|
service.clientSecret = Optional.of("");
|
||||||
|
assertFalse(service.isConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- epochToYear tests --
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void epochToYear_withReleaseDate_returnsYear() throws Exception {
|
||||||
|
// 946684800 = 2000-01-01T00:00:00Z
|
||||||
|
JsonNode node = mapper.readTree("{\"first_release_date\": 946684800}");
|
||||||
|
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
|
||||||
|
assertTrue(year.isPresent());
|
||||||
|
int y = year.get();
|
||||||
|
assertTrue(y >= 1999 && y <= 2001, "Year should be around 2000 but was " + y);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void epochToYear_withoutReleaseDate_returnsEmpty() throws Exception {
|
||||||
|
JsonNode node = mapper.readTree("{\"name\": \"Test Game\"}");
|
||||||
|
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
|
||||||
|
assertFalse(year.isPresent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void epochToYear_withEpochZero_returnsYear() throws Exception {
|
||||||
|
// epoch 0 = 1970-01-01T00:00:00Z
|
||||||
|
JsonNode node = mapper.readTree("{\"first_release_date\": 0}");
|
||||||
|
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
|
||||||
|
assertTrue(year.isPresent());
|
||||||
|
assertEquals(1970, year.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void epochToYear_negativeEpoch_returnsYear() throws Exception {
|
||||||
|
// negative epoch is before 1970
|
||||||
|
JsonNode node = mapper.readTree("{\"first_release_date\": -315619200}");
|
||||||
|
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
|
||||||
|
assertTrue(year.isPresent());
|
||||||
|
// Calendar may handle negative values differently; just verify it returns something
|
||||||
|
assertNotNull(year.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- extractGenres tests --
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractGenres_withGenresArray_returnsList() throws Exception {
|
||||||
|
JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"name\": \"Adventure\"}]}");
|
||||||
|
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
|
||||||
|
assertEquals(2, genres.size());
|
||||||
|
assertTrue(genres.contains("Action"));
|
||||||
|
assertTrue(genres.contains("Adventure"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractGenres_emptyArray_returnsEmptyList() throws Exception {
|
||||||
|
JsonNode node = mapper.readTree("{\"genres\": []}");
|
||||||
|
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
|
||||||
|
assertTrue(genres.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractGenres_missingField_returnsEmptyList() throws Exception {
|
||||||
|
JsonNode node = mapper.readTree("{\"name\": \"Test\"}");
|
||||||
|
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
|
||||||
|
assertTrue(genres.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractGenres_genreWithoutName_skipsEntry() throws Exception {
|
||||||
|
JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"id\": 5}]}");
|
||||||
|
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
|
||||||
|
assertEquals(1, genres.size());
|
||||||
|
assertEquals("Action", genres.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- sanitize tests --
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitize_escapesBackslashes() throws Exception {
|
||||||
|
String result = (String) sanitizeMethod.invoke(service, "path\\to\\file");
|
||||||
|
assertEquals("path\\\\to\\\\file", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitize_escapesQuotes() throws Exception {
|
||||||
|
String result = (String) sanitizeMethod.invoke(service, "say \"hello\"");
|
||||||
|
assertEquals("say \\\"hello\\\"", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitize_escapesBothBackslashesAndQuotes() throws Exception {
|
||||||
|
String result = (String) sanitizeMethod.invoke(service, "\\\"mixed\\\"");
|
||||||
|
assertEquals("\\\\\\\"mixed\\\\\\\"", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitize_normalString_unchanged() throws Exception {
|
||||||
|
String result = (String) sanitizeMethod.invoke(service, "hello world");
|
||||||
|
assertEquals("hello world", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitize_emptyString_unchanged() throws Exception {
|
||||||
|
String result = (String) sanitizeMethod.invoke(service, "");
|
||||||
|
assertEquals("", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- jsonNodeToMap tests --
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonNodeToMap_basicFields() throws Exception {
|
||||||
|
JsonNode node = mapper.readTree("{\"id\": 123, \"name\": \"Test Game\"}");
|
||||||
|
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||||
|
assertEquals(123, map.get("igdb_id"));
|
||||||
|
assertEquals("Test Game", map.get("name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonNodeToMap_withGenres() throws Exception {
|
||||||
|
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"genres\": [{\"name\": \"RPG\"}]}");
|
||||||
|
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||||
|
assertTrue(map.containsKey("genres"));
|
||||||
|
assertEquals(List.of("RPG"), map.get("genres"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonNodeToMap_withInvolvedCompanies() throws Exception {
|
||||||
|
JsonNode node = mapper.readTree("""
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "G",
|
||||||
|
"involved_companies": [
|
||||||
|
{ "company": { "name": "DevStudio" }, "developer": true, "publisher": false },
|
||||||
|
{ "company": { "name": "PubStudio" }, "developer": false, "publisher": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||||
|
assertEquals("DevStudio", map.get("developer"));
|
||||||
|
assertEquals("PubStudio", map.get("publisher"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonNodeToMap_withSummary() throws Exception {
|
||||||
|
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"summary\": \"A great game\"}");
|
||||||
|
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||||
|
assertEquals("A great game", map.get("summary"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonNodeToMap_withCoverUrl() throws Exception {
|
||||||
|
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"cover\": { \"url\": \"//images.igdb.com/igdb/image/upload/t_thumb/abc.jpg\" }}");
|
||||||
|
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||||
|
String coverUrl = (String) map.get("cover_url");
|
||||||
|
assertNotNull(coverUrl);
|
||||||
|
assertTrue(coverUrl.startsWith("https:"));
|
||||||
|
assertTrue(coverUrl.contains("t_cover_big"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonNodeToMap_emptyObject_returnsDefaults() throws Exception {
|
||||||
|
JsonNode node = mapper.readTree("{}");
|
||||||
|
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||||
|
assertEquals(0, map.get("igdb_id"));
|
||||||
|
assertEquals("", map.get("name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonNodeToMap_noInvolvedCompanies_skipsDevPub() throws Exception {
|
||||||
|
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\"}");
|
||||||
|
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||||
|
assertFalse(map.containsKey("developer"));
|
||||||
|
assertFalse(map.containsKey("publisher"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonNodeToMap_firstDevAndPubOnly() throws Exception {
|
||||||
|
// Multiple developers/publishers — only first of each should be recorded
|
||||||
|
JsonNode node = mapper.readTree("""
|
||||||
|
{
|
||||||
|
"id": 1, "name": "G",
|
||||||
|
"involved_companies": [
|
||||||
|
{ "company": { "name": "FirstDev" }, "developer": true, "publisher": false },
|
||||||
|
{ "company": { "name": "SecondDev" }, "developer": true, "publisher": false },
|
||||||
|
{ "company": { "name": "FirstPub" }, "developer": false, "publisher": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||||
|
assertEquals("FirstDev", map.get("developer"));
|
||||||
|
assertEquals("FirstPub", map.get("publisher"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.dostalgia;
|
package org.dostalgia;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package org.dostalgia;
|
||||||
|
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class StaticResourceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void health_returnsOkStatusAndVersion(@TempDir Path tempDir) {
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.health();
|
||||||
|
assertEquals(200, resp.getStatus());
|
||||||
|
Object entity = resp.getEntity();
|
||||||
|
assertInstanceOf(Map.class, entity);
|
||||||
|
Map<?, ?> map = (Map<?, ?>) entity;
|
||||||
|
assertEquals("ok", map.get("status"));
|
||||||
|
assertEquals("0.1.1", map.get("version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameFile_jsdosContentType(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||||
|
Files.writeString(gamesDir.resolve("test.jsdos"), "zip content");
|
||||||
|
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.getGameFile("test.jsdos");
|
||||||
|
assertEquals(200, resp.getStatus());
|
||||||
|
assertNotNull(resp.getMediaType());
|
||||||
|
assertEquals("application/zip", resp.getMediaType().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameFile_zipContentType(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||||
|
Files.writeString(gamesDir.resolve("archive.zip"), "zip content");
|
||||||
|
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.getGameFile("archive.zip");
|
||||||
|
assertEquals(200, resp.getStatus());
|
||||||
|
assertNotNull(resp.getMediaType());
|
||||||
|
assertEquals("application/zip", resp.getMediaType().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameFile_jpgContentType(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||||
|
Files.writeString(gamesDir.resolve("cover.jpg"), "image data");
|
||||||
|
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.getGameFile("cover.jpg");
|
||||||
|
assertEquals(200, resp.getStatus());
|
||||||
|
assertNotNull(resp.getMediaType());
|
||||||
|
assertEquals("image/jpeg", resp.getMediaType().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameFile_jpegContentType(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||||
|
Files.writeString(gamesDir.resolve("photo.jpeg"), "image data");
|
||||||
|
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.getGameFile("photo.jpeg");
|
||||||
|
assertEquals(200, resp.getStatus());
|
||||||
|
assertNotNull(resp.getMediaType());
|
||||||
|
assertEquals("image/jpeg", resp.getMediaType().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameFile_pngContentType(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||||
|
Files.writeString(gamesDir.resolve("screenshot.png"), "png data");
|
||||||
|
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.getGameFile("screenshot.png");
|
||||||
|
assertEquals(200, resp.getStatus());
|
||||||
|
assertNotNull(resp.getMediaType());
|
||||||
|
assertEquals("image/png", resp.getMediaType().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameFile_webpContentType(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||||
|
Files.writeString(gamesDir.resolve("image.webp"), "webp data");
|
||||||
|
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.getGameFile("image.webp");
|
||||||
|
assertEquals(200, resp.getStatus());
|
||||||
|
assertNotNull(resp.getMediaType());
|
||||||
|
assertEquals("image/webp", resp.getMediaType().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameFile_gifContentType(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||||
|
Files.writeString(gamesDir.resolve("anim.gif"), "gif data");
|
||||||
|
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.getGameFile("anim.gif");
|
||||||
|
assertEquals(200, resp.getStatus());
|
||||||
|
assertNotNull(resp.getMediaType());
|
||||||
|
assertEquals("image/gif", resp.getMediaType().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameFile_jsonContentType(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||||
|
Files.writeString(gamesDir.resolve("data.json"), "{}");
|
||||||
|
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.getGameFile("data.json");
|
||||||
|
assertEquals(200, resp.getStatus());
|
||||||
|
assertNotNull(resp.getMediaType());
|
||||||
|
assertEquals("application/json", resp.getMediaType().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameFile_exeContentType(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||||
|
Files.writeString(gamesDir.resolve("game.exe"), "MZ");
|
||||||
|
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.getGameFile("game.exe");
|
||||||
|
assertEquals(200, resp.getStatus());
|
||||||
|
assertNotNull(resp.getMediaType());
|
||||||
|
assertEquals("application/octet-stream", resp.getMediaType().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameFile_pathTraversal_returns403(@TempDir Path tempDir) {
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.getGameFile("../etc/passwd");
|
||||||
|
assertEquals(403, resp.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameFile_nonExistentFile_returns404(@TempDir Path tempDir) throws Exception {
|
||||||
|
Files.createDirectories(tempDir.resolve("games"));
|
||||||
|
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.getGameFile("nonexistent.exe");
|
||||||
|
assertEquals(404, resp.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getArtwork_contentTypeAndCacheHeader(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path artworkDir = Files.createDirectories(tempDir.resolve("artwork"));
|
||||||
|
Files.writeString(artworkDir.resolve("cover.jpg"), "image data");
|
||||||
|
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.getArtwork("cover.jpg");
|
||||||
|
assertEquals(200, resp.getStatus());
|
||||||
|
assertNotNull(resp.getMediaType());
|
||||||
|
assertEquals("image/jpeg", resp.getMediaType().toString());
|
||||||
|
assertTrue(resp.getHeaders().containsKey("Cache-Control"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getArtwork_pathTraversal_returns403(@TempDir Path tempDir) {
|
||||||
|
StaticResource sr = new StaticResource();
|
||||||
|
sr.dataDir = tempDir.toString();
|
||||||
|
|
||||||
|
Response resp = sr.getArtwork("../../etc/passwd");
|
||||||
|
assertEquals(403, resp.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package org.dostalgia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipInputStream;
|
||||||
|
import java.util.zip.ZipOutputStream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class ZipServiceTest {
|
||||||
|
|
||||||
|
private final ZipService zip = new ZipService();
|
||||||
|
|
||||||
|
// Wire the config dependency manually (no CDI)
|
||||||
|
{
|
||||||
|
zip.config = new ConfigBuilder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unzip_extractsFiles(@TempDir Path dir) throws Exception {
|
||||||
|
Path zipFile = dir.resolve("test.zip");
|
||||||
|
Path dest = dir.resolve("output");
|
||||||
|
|
||||||
|
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
|
||||||
|
zos.putNextEntry(new ZipEntry("game.exe"));
|
||||||
|
zos.write("MZ".getBytes());
|
||||||
|
zos.closeEntry();
|
||||||
|
zos.putNextEntry(new ZipEntry("readme.txt"));
|
||||||
|
zos.write("Hello".getBytes());
|
||||||
|
zos.closeEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
zip.unzip(zipFile, dest);
|
||||||
|
assertTrue(Files.exists(dest.resolve("game.exe")));
|
||||||
|
assertTrue(Files.exists(dest.resolve("readme.txt")));
|
||||||
|
assertEquals("MZ", Files.readString(dest.resolve("game.exe")));
|
||||||
|
assertEquals("Hello", Files.readString(dest.resolve("readme.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unzip_handlesSubdirectories(@TempDir Path dir) throws Exception {
|
||||||
|
Path zipFile = dir.resolve("test.zip");
|
||||||
|
Path dest = dir.resolve("output");
|
||||||
|
|
||||||
|
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
|
||||||
|
zos.putNextEntry(new ZipEntry("subdir/game.exe"));
|
||||||
|
zos.write("MZ".getBytes());
|
||||||
|
zos.closeEntry();
|
||||||
|
zos.putNextEntry(new ZipEntry("subdir/nested/deep.txt"));
|
||||||
|
zos.write("deep".getBytes());
|
||||||
|
zos.closeEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
zip.unzip(zipFile, dest);
|
||||||
|
assertTrue(Files.exists(dest.resolve("subdir/game.exe")));
|
||||||
|
assertTrue(Files.exists(dest.resolve("subdir/nested/deep.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unzip_pathTraversal_skipsMaliciousEntries(@TempDir Path dir) throws Exception {
|
||||||
|
Path zipFile = dir.resolve("test.zip");
|
||||||
|
Path dest = dir.resolve("output");
|
||||||
|
Files.createDirectories(dest);
|
||||||
|
|
||||||
|
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
|
||||||
|
zos.putNextEntry(new ZipEntry("../evil.txt"));
|
||||||
|
zos.write("malicious".getBytes());
|
||||||
|
zos.closeEntry();
|
||||||
|
zos.putNextEntry(new ZipEntry("good.txt"));
|
||||||
|
zos.write("good".getBytes());
|
||||||
|
zos.closeEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
zip.unzip(zipFile, dest);
|
||||||
|
// Malicious entry should be skipped (path traversal)
|
||||||
|
assertFalse(Files.exists(dir.resolve("evil.txt")));
|
||||||
|
// Good entry should be extracted
|
||||||
|
assertTrue(Files.exists(dest.resolve("good.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unzip_directoryEntries_createDirs(@TempDir Path dir) throws Exception {
|
||||||
|
Path zipFile = dir.resolve("test.zip");
|
||||||
|
Path dest = dir.resolve("output");
|
||||||
|
|
||||||
|
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
|
||||||
|
zos.putNextEntry(new ZipEntry("adir/"));
|
||||||
|
zos.closeEntry();
|
||||||
|
zos.putNextEntry(new ZipEntry("adir/file.txt"));
|
||||||
|
zos.write("data".getBytes());
|
||||||
|
zos.closeEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
zip.unzip(zipFile, dest);
|
||||||
|
assertTrue(Files.isDirectory(dest.resolve("adir")));
|
||||||
|
assertTrue(Files.exists(dest.resolve("adir/file.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flattenSingleDir_noOpWhenMultipleDirs(@TempDir Path dir) throws Exception {
|
||||||
|
Files.createDirectories(dir.resolve("sub1"));
|
||||||
|
Files.createDirectories(dir.resolve("sub2"));
|
||||||
|
Files.writeString(dir.resolve("sub1/file.txt"), "data");
|
||||||
|
|
||||||
|
zip.flattenSingleDir(dir);
|
||||||
|
assertTrue(Files.exists(dir.resolve("sub1/file.txt")));
|
||||||
|
assertTrue(Files.isDirectory(dir.resolve("sub1")));
|
||||||
|
assertTrue(Files.isDirectory(dir.resolve("sub2")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flattenSingleDir_noOpWhenNoSingleDir(@TempDir Path dir) throws Exception {
|
||||||
|
Files.writeString(dir.resolve("file.txt"), "data");
|
||||||
|
Files.writeString(dir.resolve("other.txt"), "data");
|
||||||
|
|
||||||
|
zip.flattenSingleDir(dir);
|
||||||
|
assertTrue(Files.exists(dir.resolve("file.txt")));
|
||||||
|
assertTrue(Files.exists(dir.resolve("other.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flattenSingleDir_flattensOneDir(@TempDir Path dir) throws Exception {
|
||||||
|
Path single = Files.createDirectories(dir.resolve("single"));
|
||||||
|
Files.createDirectories(single.resolve("nested"));
|
||||||
|
Files.writeString(single.resolve("file.txt"), "data");
|
||||||
|
Files.writeString(single.resolve("nested/deep.txt"), "deep");
|
||||||
|
|
||||||
|
zip.flattenSingleDir(dir);
|
||||||
|
// Files should now be directly under dir
|
||||||
|
assertTrue(Files.exists(dir.resolve("file.txt")));
|
||||||
|
assertTrue(Files.exists(dir.resolve("nested/deep.txt")));
|
||||||
|
// The intermediate single dir should be gone
|
||||||
|
assertFalse(Files.exists(single));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flattenSingleDir_emptyDir_noOp(@TempDir Path dir) throws Exception {
|
||||||
|
zip.flattenSingleDir(dir);
|
||||||
|
assertTrue(Files.exists(dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createBundle_createsValidJsdosZip(@TempDir Path dir) throws Exception {
|
||||||
|
Path extractDir = dir.resolve("extract");
|
||||||
|
Files.createDirectories(extractDir.resolve("sub"));
|
||||||
|
Files.writeString(extractDir.resolve("game.exe"), "MZ");
|
||||||
|
Files.writeString(extractDir.resolve("sub/data.bin"), "binary");
|
||||||
|
Files.writeString(extractDir.resolve("readme.txt"), "info");
|
||||||
|
|
||||||
|
Path bundlePath = dir.resolve("output.jsdos");
|
||||||
|
|
||||||
|
zip.createBundle(extractDir, extractDir.resolve("game.exe").toString(), List.of(), bundlePath);
|
||||||
|
|
||||||
|
assertTrue(Files.exists(bundlePath));
|
||||||
|
assertTrue(Files.size(bundlePath) > 0);
|
||||||
|
|
||||||
|
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||||
|
boolean hasGameExe = false;
|
||||||
|
boolean hasJsdosDir = false;
|
||||||
|
boolean hasJsdosConf = false;
|
||||||
|
boolean hasJsdosJson = false;
|
||||||
|
boolean hasDataBin = false;
|
||||||
|
ZipEntry entry;
|
||||||
|
while ((entry = zis.getNextEntry()) != null) {
|
||||||
|
String name = entry.getName();
|
||||||
|
if (name.equals("game.exe")) hasGameExe = true;
|
||||||
|
if (name.startsWith(".jsdos/")) hasJsdosDir = true;
|
||||||
|
if (name.equals(".jsdos/dosbox.conf")) hasJsdosConf = true;
|
||||||
|
if (name.equals(".jsdos/jsdos.json")) hasJsdosJson = true;
|
||||||
|
if (name.equals("sub/data.bin")) hasDataBin = true;
|
||||||
|
zis.closeEntry();
|
||||||
|
}
|
||||||
|
assertTrue(hasGameExe, "Should contain game.exe");
|
||||||
|
assertTrue(hasJsdosDir, "Should contain .jsdos/ entries");
|
||||||
|
assertTrue(hasJsdosConf, "Should contain .jsdos/dosbox.conf");
|
||||||
|
assertTrue(hasJsdosJson, "Should contain .jsdos/jsdos.json");
|
||||||
|
assertTrue(hasDataBin, "Should contain sub/data.bin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createBundle_respectsSkipExt(@TempDir Path dir) throws Exception {
|
||||||
|
Path extractDir = dir.resolve("extract");
|
||||||
|
Files.createDirectories(extractDir);
|
||||||
|
Files.writeString(extractDir.resolve("game.exe"), "MZ");
|
||||||
|
Files.writeString(extractDir.resolve("cdimage.nrg"), "image");
|
||||||
|
Files.writeString(extractDir.resolve("cdimage.mdf"), "image");
|
||||||
|
|
||||||
|
Path bundlePath = dir.resolve("output.jsdos");
|
||||||
|
|
||||||
|
zip.createBundle(extractDir, extractDir.resolve("game.exe").toString(), List.of(), bundlePath);
|
||||||
|
|
||||||
|
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||||
|
ZipEntry entry;
|
||||||
|
while ((entry = zis.getNextEntry()) != null) {
|
||||||
|
String name = entry.getName();
|
||||||
|
assertNotEquals("cdimage.nrg", name, "Should not contain .nrg");
|
||||||
|
assertNotEquals("cdimage.mdf", name, "Should not contain .mdf");
|
||||||
|
zis.closeEntry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createBundle_noExe_createsCdOnlyConfig(@TempDir Path dir) throws Exception {
|
||||||
|
Path extractDir = dir.resolve("extract");
|
||||||
|
Files.createDirectories(extractDir);
|
||||||
|
Files.writeString(extractDir.resolve("somefile.bin"), "data");
|
||||||
|
|
||||||
|
Path bundlePath = dir.resolve("output.jsdos");
|
||||||
|
|
||||||
|
// null exePath triggers CD-only config generation
|
||||||
|
zip.createBundle(extractDir, null, List.of("cd.iso"), bundlePath);
|
||||||
|
|
||||||
|
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||||
|
boolean hasJsdosConf = false;
|
||||||
|
ZipEntry entry;
|
||||||
|
while ((entry = zis.getNextEntry()) != null) {
|
||||||
|
if (entry.getName().equals(".jsdos/dosbox.conf")) hasJsdosConf = true;
|
||||||
|
zis.closeEntry();
|
||||||
|
}
|
||||||
|
assertTrue(hasJsdosConf, "CD-only bundle should still have dosbox.conf");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user