Compare commits
143 Commits
main
..
05c0dfb71a
| Author | SHA1 | Date | |
|---|---|---|---|
| 05c0dfb71a | |||
| 1f0ea8de47 | |||
| df5ac3d87b | |||
| e40e84c879 | |||
| 19a43a55d5 | |||
| 5758ea2ea5 | |||
| f5815a0ba9 | |||
| 40ca1187db | |||
| a6103902cc | |||
| dfb46ef032 | |||
| be15783034 | |||
| e452df00e4 | |||
| 7bd25136ab | |||
| 3a4e4f7e95 | |||
| 30927c4159 | |||
| 9afbac32f1 | |||
| f69f3fa30a | |||
| c3a332d600 | |||
| 397c99bd0c | |||
| a109dee5ba | |||
| f8adddec52 | |||
| bb38066069 | |||
| e5d2fc2096 | |||
| c3b6ea8567 | |||
| ab7a5ed8de | |||
| 554347ac0e | |||
| 9a5ed5bc03 | |||
| 149d6d6c1a | |||
| efe0017756 | |||
| 82b379d211 | |||
| 9801e37bec | |||
| f2fb1c035d | |||
| fc6f44b2ce | |||
| ee05a7f031 | |||
| 106cb5bf83 | |||
| 145f11eaa0 | |||
| 22a9a55af8 | |||
| 9fceeb4b78 | |||
| e9c4d963ad | |||
| d119b3ccb9 | |||
| d652f3f94b | |||
| 8108a661fa | |||
| 57842abe0d | |||
| 8a45ceccdc | |||
| 3162b9ff38 | |||
| 6f68e6c062 | |||
| 1fc0a11512 | |||
| 632fd7f554 | |||
| 85b0aa8c0c | |||
| 4cdd72b3e2 | |||
| 6f59028cb0 | |||
| 383c66b166 | |||
| 7c4d090de0 | |||
| 3b7c9b587f | |||
| de9e978e55 | |||
| 4a3ba3b986 | |||
| 7b7cc56bc1 | |||
| 1362e6034a | |||
| 5abc066178 | |||
| f297d30248 | |||
| 4044ef3786 | |||
| 677740aece | |||
| 9bb341eab9 | |||
| e2dca1db39 | |||
| ab1ba503ff | |||
| 4c3ded802b | |||
| 9b95e5b994 | |||
| 5f69ea5ead | |||
| e39ee05a16 | |||
| 2ab6b19598 | |||
| 02d0c4f377 | |||
| e262a46487 | |||
| 815247f760 | |||
| 54542612a5 | |||
| 473fb7a216 | |||
| 45d145a950 | |||
| dee37b3f01 | |||
| fa1804dc65 | |||
| 12db0e9bb9 | |||
| 27a9c180d7 | |||
| 3f69820911 | |||
| 13293b325e | |||
| b8ccba4d20 | |||
| 676e101ffc | |||
| e15ce10f5a | |||
| a9e52ade68 | |||
| ff7b7b446f | |||
| a4b633ae86 | |||
| 41d1e22522 | |||
| 95c7ba7192 | |||
| 5b55a53b73 | |||
| 733bdaa44f | |||
| f9967eacf8 | |||
| eb37cd43bb | |||
| 8f14ecb5a1 | |||
| 5a3500c586 | |||
| 65e098244a | |||
| efa0aa5f81 | |||
| ba34065278 | |||
| e11f19c769 | |||
| 6840b3eee1 | |||
| 5f5cb8411f | |||
| 00267da3dd | |||
| 61a83cff65 | |||
| 72391fb577 | |||
| 3c49d72f56 | |||
| e7b845e8a1 | |||
| 596b897df6 | |||
| 542db6a916 | |||
| 6c56cbc992 | |||
| 4bd771f97f | |||
| 9a4e87d044 | |||
| 655d3e83cd | |||
| feccf4178f | |||
| d751779984 | |||
| 3ae6f43ccb | |||
| 404e8b6719 | |||
| 446a9d61b8 | |||
| 0912e43d99 | |||
| 1f55f63b99 | |||
| b8cc11caf4 | |||
| 8c1be9db03 | |||
| b2eb99ec82 | |||
| 672be01e8c | |||
| 466299b8f0 | |||
| f2bd6ca3fb | |||
| 5579afb5e7 | |||
| 28ea711b24 | |||
| 7f17f4b31e | |||
| f6a1dccdf2 | |||
| a3ba5f8098 | |||
| 40aede0dfc | |||
| 3f41f6f678 | |||
| 5aedf20b7e | |||
| 353f1d8af3 | |||
| 338ec870f5 | |||
| 2b82404cf8 | |||
| 545e2d2bb5 | |||
| df5ce4f127 | |||
| aad4ec21a6 | |||
| 70fba6d14a | |||
| 1efd10f81f | |||
| c3c154ec65 |
@@ -1,10 +0,0 @@
|
|||||||
# 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,5 +1,7 @@
|
|||||||
# This workflow builds DOStalgia whenever code is pushed to main.
|
# This workflow builds and deploys 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
|
||||||
@@ -11,6 +13,8 @@ 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:
|
||||||
@@ -25,5 +29,34 @@ 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"
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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,10 +2,6 @@
|
|||||||
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/
|
||||||
@@ -40,6 +36,3 @@ data/saves/*
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Gitea
|
|
||||||
.gitea/
|
|
||||||
+2
-3
@@ -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,7 +7,6 @@ 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
|
||||||
@@ -16,7 +15,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 -q -DskipFrontend=true -DskipFrontendTests=true
|
RUN mvn package -DskipTests -q
|
||||||
|
|
||||||
# ─── 3. Runtime ──────────────────────────────────
|
# ─── 3. Runtime ──────────────────────────────────
|
||||||
FROM eclipse-temurin:21-jre-alpine
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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,109 +1,17 @@
|
|||||||
# DOStalgia 🕹️
|
# Dostalgia
|
||||||
|
|
||||||
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).
|
A nostalgic DOS game hub. Upload your old DOS games, scrape artwork, and play them directly in the browser via js-dos (WebAssembly DOSBox).
|
||||||
|
|
||||||
### 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
|
||||||
# 1. (Optional) Enable IGDB metadata auto-scrape
|
# Build and run with Docker
|
||||||
cp .env.example .env
|
docker build -t dostalgia .
|
||||||
# Edit .env with your Twitch credentials (see IGDB section below)
|
docker run -p 8765:8765 -v $(pwd)/data:/data dostalgia
|
||||||
|
|
||||||
# 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:
|
||||||
@@ -127,8 +35,34 @@ java -jar target/quarkus-app/quarkus-run.jar
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Backend**: Quarkus (Java 21, JAX-RS) — REST endpoints, zero external database
|
- **Backend**: Quarkus (Java 21, JAX-RS) — ~5 REST resource classes, zero 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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
+5
-5
@@ -1,14 +1,14 @@
|
|||||||
services:
|
services:
|
||||||
dostalgia:
|
dostalgia:
|
||||||
image: droideparanoico/dostalgia
|
build: .
|
||||||
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
+3
-1874
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "dostalgia-frontend",
|
"name": "dostalgia-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.1.0",
|
||||||
"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++} hideUpload={route !== "/"} />
|
<Header onUploadClick={() => uploadTriggered++} />
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
{#if route === "/"}
|
{#if route === "/"}
|
||||||
|
|||||||
+8
-13
@@ -98,8 +98,7 @@ h3 { font-size: 1.2rem; }
|
|||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
BUTTONS
|
BUTTONS
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
/* Only targets DOStalgia buttons, not js-dos emulator buttons inside .dos-container */
|
.btn {
|
||||||
.btn.btn:not(.dos-container .btn) {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -112,36 +111,32 @@ 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.btn:not(.dos-container .btn):hover {
|
.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.btn:not(.dos-container .btn):active {
|
.btn:active {
|
||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary.btn-primary:not(.dos-container .btn) {
|
.btn-primary {
|
||||||
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.btn-primary:not(.dos-container .btn):hover {
|
.btn-primary:hover {
|
||||||
background: var(--phosphor-dim);
|
background: var(--phosphor-dim);
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger.btn-danger:not(.dos-container .btn) {
|
.btn-danger {
|
||||||
border-color: #cc3333;
|
border-color: #cc3333;
|
||||||
color: #cc3333;
|
color: #cc3333;
|
||||||
}
|
}
|
||||||
.btn-danger.btn-danger:not(.dos-container .btn):hover {
|
.btn-danger:hover {
|
||||||
background: #331111;
|
background: #331111;
|
||||||
border-color: #ff4444;
|
border-color: #ff4444;
|
||||||
color: #ff4444;
|
color: #ff4444;
|
||||||
@@ -192,7 +187,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.btn:not(.dos-container .btn) { padding: 8px 14px; font-size: 0.85rem; min-height: 40px; }
|
.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 = () => {}, hideUpload = false } = $props();
|
let { onUploadClick = () => {} } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="header">
|
<header class="header">
|
||||||
@@ -17,9 +17,7 @@
|
|||||||
<span class="logo-text">DOSTALGIA</span>
|
<span class="logo-text">DOSTALGIA</span>
|
||||||
</a>
|
</a>
|
||||||
<nav>
|
<nav>
|
||||||
{#if !hideUpload}
|
|
||||||
<button class="btn" onclick={onUploadClick}>+ Upload</button>
|
<button class="btn" onclick={onUploadClick}>+ Upload</button>
|
||||||
{/if}
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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 btn-danger" onclick={stopEmulator}>⏹ Stop</button>
|
<button class="btn stop-btn" onclick={stopEmulator}>⏹ Stop</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,6 +211,14 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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,4 +1,3 @@
|
|||||||
/// <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";
|
||||||
|
|
||||||
@@ -16,12 +15,4 @@ 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.
|
Before Width: | Height: | Size: 261 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 488 B |
Binary file not shown.
|
Before Width: | Height: | Size: 869 B |
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
Before 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>org.dostalgia</groupId>
|
<groupId>com.dostalgia</groupId>
|
||||||
<artifactId>dostalgia</artifactId>
|
<artifactId>dostalgia</artifactId>
|
||||||
<version>0.1.1</version>
|
<version>0.1.0</version>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.release>21</maven.compiler.release>
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
@@ -15,10 +15,6 @@
|
|||||||
<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>
|
||||||
@@ -101,55 +97,6 @@
|
|||||||
</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 org.dostalgia;
|
package com.dostalgia;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package org.dostalgia;
|
package com.dostalgia;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package org.dostalgia;
|
package com.dostalgia;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
+18
-10
@@ -1,4 +1,4 @@
|
|||||||
package org.dostalgia;
|
package com.dostalgia;
|
||||||
|
|
||||||
import jakarta.annotation.Nonnull;
|
import jakarta.annotation.Nonnull;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
@@ -36,7 +36,19 @@ public class ExecutableDetector {
|
|||||||
);
|
);
|
||||||
|
|
||||||
public List<String> findAll(Path dir) throws IOException {
|
public List<String> findAll(Path dir) throws IOException {
|
||||||
return FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
|
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();
|
||||||
|
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 {
|
||||||
@@ -82,9 +94,9 @@ public class ExecutableDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String findSetup(Path dir) throws IOException {
|
public String findSetup(Path dir) throws IOException {
|
||||||
record Candidate(int depth, boolean isWindows, long size, String path) {}
|
record Candidate(int depth, long size, String path) {}
|
||||||
List<Candidate> candidates = new ArrayList<>();
|
List<Candidate> candidates = new ArrayList<>();
|
||||||
List<String> patterns = List.of("setup", "setmain", "install", "config", "custom");
|
List<String> patterns = List.of("setup", "install", "config", "custom");
|
||||||
|
|
||||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||||
@Override
|
@Override
|
||||||
@@ -95,9 +107,8 @@ 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(), isWin, a.size(), f.toString()));
|
f.getNameCount() - dir.getNameCount(), a.size(), f.toString()));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,10 +117,7 @@ public class ExecutableDetector {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (candidates.isEmpty()) return null;
|
if (candidates.isEmpty()) return null;
|
||||||
candidates.sort(Comparator
|
candidates.sort(Comparator.comparingInt(Candidate::depth).thenComparingLong(Candidate::size));
|
||||||
.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();
|
||||||
}
|
}
|
||||||
|
|
||||||
+1
-28
@@ -1,4 +1,4 @@
|
|||||||
package org.dostalgia;
|
package com.dostalgia;
|
||||||
|
|
||||||
import jakarta.annotation.Nonnull;
|
import jakarta.annotation.Nonnull;
|
||||||
|
|
||||||
@@ -8,9 +8,6 @@ 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 {
|
||||||
@@ -33,28 +30,4 @@ 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 org.dostalgia;
|
package com.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 org.dostalgia;
|
package com.dostalgia;
|
||||||
|
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
+2
-5
@@ -1,4 +1,4 @@
|
|||||||
package org.dostalgia;
|
package com.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,13 +77,10 @@ public class GameService implements GameStore {
|
|||||||
);
|
);
|
||||||
// Detect bundle size
|
// Detect bundle size
|
||||||
try {
|
try {
|
||||||
String bf = game.getBundleFile();
|
Path bundlePath = gamesDir.resolve(game.getBundleFile());
|
||||||
if (bf != null) {
|
|
||||||
Path bundlePath = gamesDir.resolve(bf);
|
|
||||||
if (Files.exists(bundlePath)) {
|
if (Files.exists(bundlePath)) {
|
||||||
game.setBundleSize(Files.size(bundlePath));
|
game.setBundleSize(Files.size(bundlePath));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (IOException ignored) {}
|
} catch (IOException ignored) {}
|
||||||
return game;
|
return game;
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package org.dostalgia;
|
package com.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 org.dostalgia;
|
package com.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 org.dostalgia;
|
package com.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 org.dostalgia;
|
package com.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[0x1000];
|
byte[] buf = new byte[0x200];
|
||||||
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 org.dostalgia;
|
package com.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.1")).build();
|
return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String guessContentType(String name) {
|
private String guessContentType(String name) {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package org.dostalgia;
|
package com.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 org.dostalgia;
|
package com.dostalgia;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
@@ -5,6 +5,7 @@ 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 org.dostalgia;
|
package com.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 org.dostalgia;
|
package com.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 org.dostalgia;
|
package com.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
-65
@@ -1,4 +1,4 @@
|
|||||||
package org.dostalgia;
|
package com.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,7 +9,6 @@ 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;
|
||||||
|
|
||||||
@@ -137,69 +136,6 @@ 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
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package org.dostalgia;
|
package com.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,94 +0,0 @@
|
|||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
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,198 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
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