Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0aedf5eecf | |||
| cd5a5bff79 | |||
| e6036fd831 | |||
| b42210cb0c | |||
| 2cb57aed41 | |||
| 1e7c5855e5 | |||
| 7ea9f93dc8 | |||
| 4428cd4cb8 | |||
| 90eb88d26e | |||
| fb2ffeb4fe | |||
| 01d1b369b7 |
@@ -0,0 +1,43 @@
|
||||
name: PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://git.keligrubb.com/actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: https://git.keligrubb.com/actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
test-coverage:
|
||||
name: test-coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://git.keligrubb.com/actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: https://git.keligrubb.com/actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Test with coverage
|
||||
run: npm run test:coverage
|
||||
@@ -0,0 +1,87 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
jobs:
|
||||
generate-dungeon:
|
||||
name: generate-dungeon
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/puppeteer/puppeteer:latest
|
||||
env:
|
||||
OLLAMA_API_URL: https://ai.keligrubb.com/api/chat/completions
|
||||
OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
|
||||
OLLAMA_MODEL: qwen3.5-122b-a10b
|
||||
COMFYUI_URL: http://192.168.1.124:8188
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://git.keligrubb.com/actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: https://git.keligrubb.com/actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate dungeon PDF
|
||||
run: npm start
|
||||
|
||||
- name: Upload PDF artifact
|
||||
uses: https://git.keligrubb.com/actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-pdf
|
||||
path: "*.pdf"
|
||||
|
||||
upload-to-gitea-release:
|
||||
name: upload-to-gitea-release
|
||||
runs-on: ubuntu-latest
|
||||
needs: generate-dungeon
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SCROLLSMITH_GITEA_TOKEN }}
|
||||
steps:
|
||||
- name: Download PDF artifact
|
||||
uses: https://git.keligrubb.com/actions/download-artifact@v8
|
||||
with:
|
||||
name: release-pdf
|
||||
path: .
|
||||
|
||||
- name: Create release and upload PDF
|
||||
run: |
|
||||
api_base="https://git.keligrubb.com/api/v1/repos/${{ gitea.repository }}"
|
||||
pdf=$(ls *.pdf | head -n1)
|
||||
tag=$(date +%F)
|
||||
echo "Creating release for tag $tag..."
|
||||
create_resp=$(curl -s -w "%{http_code}" -o /tmp/create.json -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
|
||||
"$api_base/releases")
|
||||
echo "Create release HTTP status: $create_resp"
|
||||
echo "Fetching release ID..."
|
||||
release_id=$(curl -s \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"$api_base/releases/tags/$tag" |
|
||||
awk -F: '/"id"[ ]*:/ {gsub(/[^0-9]/,"",$2); print $2; exit}')
|
||||
echo "Release ID = $release_id"
|
||||
echo "Checking if asset $pdf already exists..."
|
||||
assets=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"$api_base/releases/$release_id/assets")
|
||||
echo "Assets response: $assets"
|
||||
if echo "$assets" | grep -q "\"name\":\"$pdf\""; then
|
||||
echo "Asset $pdf already uploaded, skipping."
|
||||
exit 0
|
||||
fi
|
||||
echo "Uploading $pdf to release $release_id..."
|
||||
upload_resp=$(curl -s -w "%{http_code}" -o /tmp/upload.json -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-F "attachment=@$pdf" \
|
||||
"$api_base/releases/$release_id/assets")
|
||||
echo "Upload HTTP status: $upload_resp"
|
||||
echo "Upload response: $(cat /tmp/upload.json)"
|
||||
@@ -1,19 +0,0 @@
|
||||
workspace:
|
||||
base: /woodpecker
|
||||
path: pr
|
||||
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: lint
|
||||
image: node:24-slim
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run lint
|
||||
|
||||
- name: test-coverage
|
||||
image: node:24-slim
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run test:coverage
|
||||
@@ -1,61 +0,0 @@
|
||||
workspace:
|
||||
base: /woodpecker
|
||||
path: release
|
||||
|
||||
when:
|
||||
- event: cron
|
||||
branch: main
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
steps:
|
||||
- name: generate-dungeon
|
||||
image: ghcr.io/puppeteer/puppeteer:latest
|
||||
environment:
|
||||
OLLAMA_API_URL:
|
||||
from_secret: OLLAMA_API_URL
|
||||
OLLAMA_API_KEY:
|
||||
from_secret: OLLAMA_API_KEY
|
||||
COMFYUI_URL:
|
||||
from_secret: COMFYUI_URL
|
||||
commands:
|
||||
- npm ci
|
||||
- npm start
|
||||
|
||||
- name: upload-to-gitea-release
|
||||
image: curlimages/curl:latest
|
||||
environment:
|
||||
GITEA_TOKEN:
|
||||
from_secret: GITEA_TOKEN
|
||||
commands:
|
||||
- pdf=$(ls *.pdf | head -n1)
|
||||
- tag=$(date +%F)
|
||||
- |
|
||||
echo "Creating release for tag $tag..."
|
||||
create_resp=$(curl -s -w "%{http_code}" -o /tmp/create.json -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
|
||||
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases)
|
||||
echo "Create release HTTP status: $create_resp"
|
||||
echo "Fetching release ID..."
|
||||
release_id=$(curl -s \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/tags/$tag |
|
||||
awk -F: '/"id"[ ]*:/ {gsub(/[^0-9]/,"",$2); print $2; exit}')
|
||||
echo "Release ID = $release_id"
|
||||
echo "Checking if asset $pdf already exists..."
|
||||
assets=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/$release_id/assets)
|
||||
echo "Assets response: $assets"
|
||||
if echo "$assets" | grep -q "\"name\":\"$pdf\""; then
|
||||
echo "Asset $pdf already uploaded, skipping."
|
||||
exit 0
|
||||
fi
|
||||
echo "Uploading $pdf to release $release_id..."
|
||||
upload_resp=$(curl -s -w "%{http_code}" -o /tmp/upload.json -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-F "attachment=@$pdf" \
|
||||
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/$release_id/assets)
|
||||
echo "Upload HTTP status: $upload_resp"
|
||||
echo "Upload response: $(cat /tmp/upload.json)"
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[](https://ci.keligrubb.com/repos/2)
|
||||
|
||||
Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon PDFs automatically. It uses an Ollama LLM server to create dungeon content, proofreads and refines it, then formats it into a structured PDF with maps, rooms, encounters, treasure, and NPCs.
|
||||
Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon PDFs automatically. It calls an LLM (Open WebUI `/api/chat/completions` or Ollama `/api/generate`, inferred from `OLLAMA_API_URL`), proofreads and refines the result, then builds a structured PDF with maps, rooms, encounters, treasure, and NPCs.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,20 +14,21 @@ Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon
|
||||
3. JSON conversion: output strictly valid JSON for PDF generation
|
||||
- Automatically generates a PDF named after the dungeon title
|
||||
- PDF layout includes three columns: map & hooks, rooms, encounters & treasure & NPCs
|
||||
- Easy to integrate with a local Ollama server
|
||||
- Open WebUI or Ollama
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 22+
|
||||
- Ollama server running and accessible
|
||||
- LLM endpoint (see below)
|
||||
- Gitea Releases (optional) for PDF uploads
|
||||
- `.env` file with:
|
||||
|
||||
```env
|
||||
OLLAMA_API_URL=http://localhost:3000/api/chat/completions
|
||||
OLLAMA_API_URL=https://your-openwebui-host/api/chat/completions
|
||||
OLLAMA_API_KEY=your_api_key_here
|
||||
OLLAMA_MODEL=qwen3.5-122b-a10b
|
||||
COMFYUI_URL=http://192.168.1.124:8188
|
||||
```
|
||||
|
||||
@@ -56,17 +57,18 @@ OLLAMA_API_URL=http://localhost:11434/api/generate
|
||||
### Open WebUI API
|
||||
For Open WebUI API calls, set:
|
||||
```env
|
||||
OLLAMA_API_URL=http://localhost:3000/api/chat/completions
|
||||
OLLAMA_API_URL=https://your-openwebui-host/api/chat/completions
|
||||
OLLAMA_API_KEY=your_open_webui_api_key
|
||||
OLLAMA_MODEL=qwen3.5-122b-a10b
|
||||
```
|
||||
|
||||
> Note: The API type is automatically inferred from the endpoint URL. If the URL contains `/api/chat/completions`, it uses Open WebUI API. If it contains `/api/generate`, it uses direct Ollama API. No `OLLAMA_API_TYPE` environment variable is required.
|
||||
> Note: The API type is inferred from the URL (`/api/chat/completions` vs `/api/generate`); no `OLLAMA_API_TYPE` variable.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. Make sure your Ollama server is running and `.env` is configured.
|
||||
1. Configure `.env` and ensure the LLM endpoint is reachable.
|
||||
2. Run:
|
||||
|
||||
```bash
|
||||
@@ -81,7 +83,7 @@ Optional: update the map path in `index.js` if you have a local dungeon map.
|
||||
|
||||
## Project structure
|
||||
|
||||
- **`index.js`** – Entry point: loads env, initializes the Ollama model, runs dungeon generation, image generation, and PDF output.
|
||||
- **`index.js`** – Entry point: env, model init, dungeon + image + PDF.
|
||||
- **`src/`** – Application modules:
|
||||
- `dungeonGenerator.js` – LLM-backed dungeon content generation and validation.
|
||||
- `dungeonTemplate.js` – HTML template and layout for the PDF.
|
||||
@@ -105,7 +107,7 @@ Optional: update the map path in `index.js` if you have a local dungeon map.
|
||||
|
||||
## Notes
|
||||
|
||||
* Make sure your Ollama server is accessible and API key is valid.
|
||||
* Open WebUI needs a valid `OLLAMA_API_KEY` when the server requires it.
|
||||
* Dungeon generation can take a few minutes depending on your LLM response time.
|
||||
|
||||
---
|
||||
|
||||
@@ -2,7 +2,7 @@ import "dotenv/config";
|
||||
import { generateDungeon } from "./src/dungeonGenerator.js";
|
||||
import { generateDungeonImages } from "./src/imageGenerator.js";
|
||||
import { generatePDF } from "./src/generatePDF.js";
|
||||
import { OLLAMA_MODEL, initializeModel } from "./src/ollamaClient.js";
|
||||
import { initializeModel } from "./src/ollamaClient.js";
|
||||
|
||||
// Utility to create a filesystem-safe filename from the dungeon title
|
||||
function slugify(text) {
|
||||
@@ -18,18 +18,15 @@ function slugify(text) {
|
||||
throw new Error("OLLAMA_API_URL environment variable is required");
|
||||
}
|
||||
console.log("Using Ollama API URL:", process.env.OLLAMA_API_URL);
|
||||
|
||||
// Initialize model (will fetch default from API or use fallback)
|
||||
await initializeModel();
|
||||
console.log("Using Ollama model:", OLLAMA_MODEL);
|
||||
|
||||
const model = await initializeModel();
|
||||
console.log("Using Ollama model:", model);
|
||||
|
||||
// Generate the dungeon data
|
||||
const dungeonData = await generateDungeon();
|
||||
|
||||
// Generate dungeon map image (uses dungeonData.flavor)
|
||||
const mapPath = await generateDungeonImages(dungeonData);
|
||||
|
||||
dungeonData.map = mapPath;
|
||||
if (mapPath) dungeonData.map = mapPath;
|
||||
|
||||
// Generate PDF filename based on the title
|
||||
const filename = `${slugify(dungeonData.title)}.pdf`;
|
||||
|
||||
Generated
+684
-1321
File diff suppressed because it is too large
Load Diff
+17
-17
@@ -2,7 +2,7 @@ import sharp from 'sharp';
|
||||
import path from "path";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import { fileURLToPath } from "url";
|
||||
import { callOllama, OLLAMA_MODEL } from "./ollamaClient.js";
|
||||
import { callOllama } from "./ollamaClient.js";
|
||||
|
||||
const COMFYUI_ENABLED = process.env.COMFYUI_ENABLED !== 'false';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -63,7 +63,7 @@ Input:
|
||||
${flavor}
|
||||
|
||||
Output:`,
|
||||
OLLAMA_MODEL,
|
||||
undefined,
|
||||
3,
|
||||
"Generate Visual Prompt"
|
||||
);
|
||||
@@ -239,22 +239,22 @@ export async function generateDungeonImages({ flavor }) {
|
||||
return path.join(__dirname, "dungeon_upscaled.png");
|
||||
}
|
||||
|
||||
const finalPrompt = await generateVisualPrompt(flavor);
|
||||
console.log("Engineered visual prompt:\n", finalPrompt);
|
||||
try {
|
||||
const finalPrompt = await generateVisualPrompt(flavor);
|
||||
console.log("Engineered visual prompt:\n", finalPrompt);
|
||||
|
||||
const baseFilename = `dungeon.png`;
|
||||
const upscaledFilename = `dungeon_upscaled.png`;
|
||||
const baseFilename = `dungeon.png`;
|
||||
const upscaledFilename = `dungeon_upscaled.png`;
|
||||
|
||||
const filepath = await generateImageViaComfyUI(finalPrompt, baseFilename);
|
||||
if (!filepath) {
|
||||
throw new Error("Failed to generate dungeon image.");
|
||||
const filepath = await generateImageViaComfyUI(finalPrompt, baseFilename);
|
||||
if (!filepath) return null;
|
||||
|
||||
const upscaledPath = await upscaleImage(filepath, upscaledFilename, 1456, 1024);
|
||||
if (!upscaledPath) return null;
|
||||
|
||||
return upscaledPath;
|
||||
} catch (err) {
|
||||
console.warn("Map image failed:", err.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Upscale 2x (half of A4 at 300dpi)
|
||||
const upscaledPath = await upscaleImage(filepath, upscaledFilename, 1456, 1024);
|
||||
if (!upscaledPath) {
|
||||
throw new Error("Failed to upscale dungeon image.");
|
||||
}
|
||||
|
||||
return upscaledPath;
|
||||
}
|
||||
|
||||
+28
-14
@@ -1,10 +1,16 @@
|
||||
import { cleanText } from "./textUtils.js";
|
||||
|
||||
const OLLAMA_API_URL = process.env.OLLAMA_API_URL;
|
||||
export let OLLAMA_MODEL = process.env.OLLAMA_MODEL || "gemma3:latest";
|
||||
|
||||
export const OLLAMA_MODEL = "qwen3.5-122b-a10b";
|
||||
|
||||
function effectiveModel(explicit) {
|
||||
if (explicit !== undefined && explicit !== null) return explicit;
|
||||
return process.env.OLLAMA_MODEL || OLLAMA_MODEL;
|
||||
}
|
||||
|
||||
export async function initializeModel() {
|
||||
if (process.env.OLLAMA_MODEL) return;
|
||||
if (process.env.OLLAMA_MODEL) return process.env.OLLAMA_MODEL;
|
||||
try {
|
||||
const apiUrl = process.env.OLLAMA_API_URL;
|
||||
const isOpenWebUI = apiUrl?.includes("/api/chat/completions");
|
||||
@@ -16,17 +22,20 @@ export async function initializeModel() {
|
||||
const res = await fetch(url, { headers });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const model = isOpenWebUI
|
||||
const model = isOpenWebUI
|
||||
? data.data?.[0]?.id || data.data?.[0]?.name
|
||||
: data.models?.[0]?.name;
|
||||
if (model) {
|
||||
OLLAMA_MODEL = model;
|
||||
process.env.OLLAMA_MODEL = model;
|
||||
console.log(`Using default model: ${model}`);
|
||||
return model;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.warn(`Could not fetch default model, using: ${OLLAMA_MODEL}`);
|
||||
// fall through to warn below
|
||||
}
|
||||
console.warn(`Could not fetch default model, using: ${OLLAMA_MODEL}`);
|
||||
return OLLAMA_MODEL;
|
||||
}
|
||||
|
||||
export { cleanText };
|
||||
@@ -45,8 +54,10 @@ async function sleep(ms) {
|
||||
async function callOllamaBase(prompt, model, retries, stepName, apiType) {
|
||||
const isUsingOpenWebUI = apiType === "open-webui";
|
||||
const isUsingOllamaChat = apiType === "ollama-chat";
|
||||
const resolvedModel = effectiveModel(model);
|
||||
const attempts = Array.from({ length: retries }, (_, index) => index + 1);
|
||||
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const promptCharCount = prompt.length;
|
||||
const promptWordCount = prompt.split(/\s+/).length;
|
||||
@@ -58,14 +69,17 @@ async function callOllamaBase(prompt, model, retries, stepName, apiType) {
|
||||
`Prompt: ${promptCharCount} chars, ~${promptWordCount} words`,
|
||||
);
|
||||
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (isUsingOpenWebUI && process.env.OLLAMA_API_KEY) {
|
||||
headers["Authorization"] = `Bearer ${process.env.OLLAMA_API_KEY}`;
|
||||
}
|
||||
const headers =
|
||||
isUsingOpenWebUI && process.env.OLLAMA_API_KEY
|
||||
? {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${process.env.OLLAMA_API_KEY}`,
|
||||
}
|
||||
: { "Content-Type": "application/json" };
|
||||
|
||||
const body = isUsingOpenWebUI || isUsingOllamaChat
|
||||
? { model, messages: [{ role: "user", content: prompt }] }
|
||||
: { model, prompt, stream: false };
|
||||
? { model: resolvedModel, messages: [{ role: "user", content: prompt }] }
|
||||
: { model: resolvedModel, prompt, stream: false };
|
||||
|
||||
const response = await fetch(OLLAMA_API_URL, {
|
||||
method: "POST",
|
||||
@@ -108,7 +122,7 @@ async function callOllamaBase(prompt, model, retries, stepName, apiType) {
|
||||
|
||||
export async function callOllama(
|
||||
prompt,
|
||||
model = OLLAMA_MODEL,
|
||||
model,
|
||||
retries = 5,
|
||||
stepName = "unknown",
|
||||
) {
|
||||
@@ -118,7 +132,7 @@ export async function callOllama(
|
||||
|
||||
export async function callOllamaExplicit(
|
||||
prompt,
|
||||
model = OLLAMA_MODEL,
|
||||
model,
|
||||
retries = 5,
|
||||
stepName = "unknown",
|
||||
apiType = "ollama-generate",
|
||||
|
||||
@@ -6,46 +6,46 @@ import path from "path";
|
||||
|
||||
const hasOllama = !!process.env.OLLAMA_API_URL;
|
||||
|
||||
describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => {
|
||||
let dungeonData;
|
||||
describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", { timeout: 120000 }, () => {
|
||||
const fixture = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
dungeonData = await generateDungeon();
|
||||
}, 120000);
|
||||
fixture.dungeon = await generateDungeon();
|
||||
});
|
||||
|
||||
it("generates dungeon data", () => {
|
||||
expect(dungeonData).toBeDefined();
|
||||
expect(fixture.dungeon).toBeDefined();
|
||||
});
|
||||
|
||||
it("title is 2-4 words, no colons", () => {
|
||||
expect(dungeonData.title).toBeTruthy();
|
||||
const words = dungeonData.title.split(/\s+/);
|
||||
expect(fixture.dungeon.title).toBeTruthy();
|
||||
const words = fixture.dungeon.title.split(/\s+/);
|
||||
expect(words.length).toBeGreaterThanOrEqual(2);
|
||||
expect(words.length).toBeLessThanOrEqual(4);
|
||||
expect(dungeonData.title).not.toContain(":");
|
||||
expect(fixture.dungeon.title).not.toContain(":");
|
||||
});
|
||||
|
||||
it("flavor text is ≤60 words", () => {
|
||||
expect(dungeonData.flavor).toBeTruthy();
|
||||
const words = dungeonData.flavor.split(/\s+/);
|
||||
expect(fixture.dungeon.flavor).toBeTruthy();
|
||||
const words = fixture.dungeon.flavor.split(/\s+/);
|
||||
expect(words.length).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it("hooks have no title prefixes", () => {
|
||||
expect(dungeonData.hooksRumors).toBeDefined();
|
||||
dungeonData.hooksRumors.forEach((hook) => {
|
||||
expect(fixture.dungeon.hooksRumors).toBeDefined();
|
||||
fixture.dungeon.hooksRumors.forEach((hook) => {
|
||||
expect(hook).not.toMatch(/^[^:]+:\s/);
|
||||
});
|
||||
});
|
||||
|
||||
it("has exactly 6 random events", () => {
|
||||
expect(dungeonData.randomEvents).toBeDefined();
|
||||
expect(dungeonData.randomEvents.length).toBe(6);
|
||||
expect(fixture.dungeon.randomEvents).toBeDefined();
|
||||
expect(fixture.dungeon.randomEvents.length).toBe(6);
|
||||
});
|
||||
|
||||
it("encounter details do not start with encounter name", () => {
|
||||
expect(dungeonData.encounters).toBeDefined();
|
||||
dungeonData.encounters.forEach((encounter) => {
|
||||
expect(fixture.dungeon.encounters).toBeDefined();
|
||||
fixture.dungeon.encounters.forEach((encounter) => {
|
||||
if (encounter.details) {
|
||||
const detailsLower = encounter.details.toLowerCase();
|
||||
const nameLower = encounter.name.toLowerCase();
|
||||
@@ -55,8 +55,8 @@ describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => {
|
||||
});
|
||||
|
||||
it("treasure descriptions do not start with 'description'", () => {
|
||||
expect(dungeonData.treasure).toBeDefined();
|
||||
dungeonData.treasure.forEach((item) => {
|
||||
expect(fixture.dungeon.treasure).toBeDefined();
|
||||
fixture.dungeon.treasure.forEach((item) => {
|
||||
if (typeof item === "object" && item.description) {
|
||||
expect(item.description.toLowerCase().startsWith("description")).toBe(false);
|
||||
}
|
||||
@@ -64,8 +64,8 @@ describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => {
|
||||
});
|
||||
|
||||
it("NPC traits do not start with 'description'", () => {
|
||||
expect(dungeonData.npcs).toBeDefined();
|
||||
dungeonData.npcs.forEach((npc) => {
|
||||
expect(fixture.dungeon.npcs).toBeDefined();
|
||||
fixture.dungeon.npcs.forEach((npc) => {
|
||||
if (npc.trait) {
|
||||
expect(npc.trait.toLowerCase().startsWith("description")).toBe(false);
|
||||
}
|
||||
@@ -75,11 +75,11 @@ describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => {
|
||||
it("PDF fits on one page", async () => {
|
||||
const testPdfPath = path.join(process.cwd(), "test-output.pdf");
|
||||
try {
|
||||
await generatePDF(dungeonData, testPdfPath);
|
||||
await generatePDF(fixture.dungeon, testPdfPath);
|
||||
const pdfBuffer = await fs.readFile(testPdfPath);
|
||||
const pdfText = pdfBuffer.toString("binary");
|
||||
const pageCount = (pdfText.match(/\/Type\s*\/Page[^s]/g) || []).length;
|
||||
const expectedPages = dungeonData.map ? 2 : 1;
|
||||
const expectedPages = fixture.dungeon.map ? 2 : 1;
|
||||
expect(pageCount).toBeLessThanOrEqual(expectedPages);
|
||||
} finally {
|
||||
try {
|
||||
|
||||
@@ -203,16 +203,16 @@ describe("initializeModel (mocked fetch)", () => {
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves OLLAMA_MODEL unchanged when fetch returns not ok", async () => {
|
||||
it("does not set env when fetch returns not ok", async () => {
|
||||
process.env.OLLAMA_MODEL = "";
|
||||
const before = OLLAMA_MODEL;
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
await initializeModel();
|
||||
expect(OLLAMA_MODEL).toBe(before);
|
||||
const resolved = await initializeModel();
|
||||
expect(resolved).toBe(OLLAMA_MODEL);
|
||||
expect(process.env.OLLAMA_MODEL).toBe("");
|
||||
});
|
||||
|
||||
it("fetches /api/tags when OLLAMA_MODEL not set", async () => {
|
||||
@@ -222,11 +222,13 @@ describe("initializeModel (mocked fetch)", () => {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ models: [{ name: "test-model" }] }),
|
||||
});
|
||||
await initializeModel();
|
||||
const resolved = await initializeModel();
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
const [url, opts] = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
expect(String(url)).toMatch(/\/api\/tags$/);
|
||||
expect(opts?.method || "GET").toBe("GET");
|
||||
expect(resolved).toBe("test-model");
|
||||
expect(process.env.OLLAMA_MODEL).toBe("test-model");
|
||||
});
|
||||
|
||||
it("fetches /api/v1/models when URL has open-webui path and sets model from data.data id", async () => {
|
||||
@@ -239,7 +241,7 @@ describe("initializeModel (mocked fetch)", () => {
|
||||
await initializeModel();
|
||||
const [url] = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
expect(String(url)).toMatch(/\/api\/v1\/models$/);
|
||||
expect(OLLAMA_MODEL).toBe("webui-model");
|
||||
expect(process.env.OLLAMA_MODEL).toBe("webui-model");
|
||||
});
|
||||
|
||||
it("sets model from data.data[0].name when id missing", async () => {
|
||||
@@ -250,7 +252,7 @@ describe("initializeModel (mocked fetch)", () => {
|
||||
json: () => Promise.resolve({ data: [{ name: "webui-model-name" }] }),
|
||||
});
|
||||
await initializeModel();
|
||||
expect(OLLAMA_MODEL).toBe("webui-model-name");
|
||||
expect(process.env.OLLAMA_MODEL).toBe("webui-model-name");
|
||||
});
|
||||
|
||||
it("catches fetch failure and warns", async () => {
|
||||
|
||||
Reference in New Issue
Block a user