diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
new file mode 100644
index 000000000000..6a87d5a02222
--- /dev/null
+++ b/.github/workflows/ci-tests.yml
@@ -0,0 +1,96 @@
+---
+name: CI Tests
+permissions: {}
+
+on:
+ pull_request:
+ push:
+ branches:
+ - master
+ workflow_dispatch:
+
+concurrency:
+ group: "${{ github.workflow }}-${{ github.ref }}"
+ cancel-in-progress: true
+
+jobs:
+ tests:
+ permissions:
+ contents: write # write is required for release_setup
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Set up Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: '3.12'
+
+ - name: Set up Node
+ uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: latest
+
+ - name: Install Python Dependencies
+ shell: bash
+ run: |
+ python -m pip install --upgrade pip setuptools wheel
+ python -m pip install -e .[dev]
+
+ - name: Install Node Dependencies
+ shell: bash
+ run: npm install --ignore-scripts
+
+ - name: Test with pytest
+ id: pytest
+ shell: bash
+ run: python -m pytest
+
+ - name: Test with Jest
+ id: jest
+ if: always()
+ env:
+ FORCE_COLOR: true
+ shell: bash
+ run: npm test
+
+ - name: Upload coverage
+ # any except canceled or skipped
+ if: >-
+ always() &&
+ (
+ steps.pytest.outcome == 'success' ||
+ steps.pytest.outcome == 'failure' ||
+ steps.jest.outcome == 'success' ||
+ steps.jest.outcome == 'failure'
+ ) &&
+ startsWith(github.repository, 'LizardByte/')
+ uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
+ with:
+ fail_ci_if_error: true
+ files: ./coverage/python-coverage.xml,./coverage/coverage-final.json
+ flags: ${{ runner.os }}
+ report_type: coverage
+ token: ${{ secrets.CODECOV_TOKEN }}
+ verbose: true
+
+ - name: Upload test results
+ # any except canceled or skipped
+ if: >-
+ always() &&
+ (
+ steps.pytest.outcome == 'success' ||
+ steps.pytest.outcome == 'failure' ||
+ steps.jest.outcome == 'success' ||
+ steps.jest.outcome == 'failure'
+ ) &&
+ startsWith(github.repository, 'LizardByte/')
+ uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
+ with:
+ fail_ci_if_error: true
+ files: ./junit-python.xml,./junit.xml
+ flags: ${{ runner.os }}
+ report_type: test_results
+ token: ${{ secrets.CODECOV_TOKEN }}
+ verbose: true
diff --git a/.github/workflows/update-db.yml b/.github/workflows/update-db.yml
index 5987b33b548a..b0bf4b4a8f51 100644
--- a/.github/workflows/update-db.yml
+++ b/.github/workflows/update-db.yml
@@ -39,14 +39,14 @@ jobs:
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
- python -m pip install -r requirements.txt
+ python -m pip install -e .
- name: Update
env:
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }}
YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
- run: python -u src/update_db.py ${{ github.event_name == 'pull_request' && '-t' || '' }}
+ run: python -u ./src/update_db.py ${{ github.event_name == 'pull_request' && '-t' || '' }}
- name: Prepare Artifacts # uploading artifacts will fail if not zipped due to very large quantity of files
shell: bash
diff --git a/.gitignore b/.gitignore
index d479c2b47488..587d96667d10 100644
--- a/.gitignore
+++ b/.gitignore
@@ -159,6 +159,18 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
+# Node.js
+node_modules/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+package-lock.json
+
+# Jest
+.jest-cache/
+coverage/
+junit*.xml
+
# Project specific
gh-pages/
cache/
diff --git a/README.md b/README.md
index df633e669e76..9d448992ac3a 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,8 @@
# GameDB
[](https://github.com/LizardByte/GameDB/actions/workflows/update-db.yml?query=branch%3Amaster)
+[](https://app.codecov.io/gh/LizardByte/GameDB)
This repository clones IGDB to gh-pages to be consumed by LizardByte projects, such as LizardByte/Sunshine.
Information from YouTube API is also added to the database for videos.
-
-## Plans
-
-- [x] Build with Jekyll
- - [ ] Revamp index page
- - [x] Provide an item page for each API item
-- [ ] Add unit tests
-- [ ] Add code coverage
diff --git a/babel.config.js b/babel.config.js
new file mode 100644
index 000000000000..f95788a54ae5
--- /dev/null
+++ b/babel.config.js
@@ -0,0 +1,2 @@
+// this allows jest to use ES6 module imports
+module.exports = {presets: ['@babel/preset-env']}
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 000000000000..1da3397bff21
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,15 @@
+---
+codecov:
+ branch: master
+
+coverage:
+ status:
+ project:
+ default:
+ target: auto
+ threshold: 10%
+
+comment:
+ layout: "header, reach, diff, flags, files, footer"
+ behavior: default
+ require_changes: false
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 000000000000..9073c35f6c5c
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,32 @@
+import globals from "globals";
+import pluginJs from "@eslint/js";
+
+export default [
+ pluginJs.configs.recommended,
+ {
+ ignores: [
+ "coverage/**",
+ "node_modules/**",
+ "gh-pages/**",
+ ],
+ },
+ {
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ ...globals.jquery,
+ // cross-file globals injected by item_detail.js into the browser scope
+ "base_url": "readonly",
+ "base_path": "readonly",
+ "igdbImageUrl": "readonly",
+ "makeBadge": "readonly",
+ "addDlRow": "readonly",
+ "loadItemDetail": "readonly",
+ "renderGameList": "readonly",
+ "splitString": "readonly",
+ "require": "readonly",
+ },
+ },
+ },
+];
diff --git a/gh-pages-template/assets/js/character_detail.js b/gh-pages-template/assets/js/character_detail.js
index c4edd9b5c056..5e676d9074d0 100644
--- a/gh-pages-template/assets/js/character_detail.js
+++ b/gh-pages-template/assets/js/character_detail.js
@@ -44,3 +44,10 @@ function renderCharacter(data) {
document.addEventListener("DOMContentLoaded", () => {
loadItemDetail("characters", renderCharacter);
});
+
+/* istanbul ignore next */
+if (typeof module !== "undefined") {
+ module.exports = {
+ renderCharacter,
+ };
+}
diff --git a/gh-pages-template/assets/js/collection_detail.js b/gh-pages-template/assets/js/collection_detail.js
index cfdf91eb460c..cfa16457b6b4 100644
--- a/gh-pages-template/assets/js/collection_detail.js
+++ b/gh-pages-template/assets/js/collection_detail.js
@@ -26,3 +26,10 @@ function renderCollection(data) {
document.addEventListener("DOMContentLoaded", () => {
loadItemDetail("collections", renderCollection);
});
+
+/* istanbul ignore next */
+if (typeof module !== "undefined") {
+ module.exports = {
+ renderCollection,
+ };
+}
diff --git a/gh-pages-template/assets/js/franchise_detail.js b/gh-pages-template/assets/js/franchise_detail.js
index b0229d4089b7..f9dd768fbe47 100644
--- a/gh-pages-template/assets/js/franchise_detail.js
+++ b/gh-pages-template/assets/js/franchise_detail.js
@@ -26,3 +26,10 @@ function renderFranchise(data) {
document.addEventListener("DOMContentLoaded", () => {
loadItemDetail("franchises", renderFranchise);
});
+
+/* istanbul ignore next */
+if (typeof module !== "undefined") {
+ module.exports = {
+ renderFranchise,
+ };
+}
diff --git a/gh-pages-template/assets/js/game_detail.js b/gh-pages-template/assets/js/game_detail.js
index 6abb37961368..3a7d69af457d 100644
--- a/gh-pages-template/assets/js/game_detail.js
+++ b/gh-pages-template/assets/js/game_detail.js
@@ -19,7 +19,7 @@ function setupGameBanner(data) {
bigImgsEl.dataset.numImg = data.artworks.length;
data.artworks.forEach((artwork, index) => {
const imgNum = index + 1;
- bigImgsEl.setAttribute(`data-img-src-${imgNum}`, igdbImageUrl(artwork.url, "t_screenshot_huge_2x"));
+ bigImgsEl.dataset[`imgSrc${imgNum}`] = igdbImageUrl(artwork.url, "t_screenshot_huge_2x");
});
// Add big-img class and img-desc span to existing header
@@ -169,6 +169,7 @@ function renderCompanies(data, metaDl) {
if (data.involved_companies && data.involved_companies.length > 0) {
const devs = data.involved_companies.filter(c => c.developer).map(c => c.company?.name).filter(Boolean);
const pubs = data.involved_companies.filter(c => !c.developer).map(c => c.company?.name).filter(Boolean);
+ /* istanbul ignore else */
if (devs.length > 0) {
addDlRow(metaDl, "Developer(s)", devs.join(", "));
}
@@ -469,8 +470,8 @@ function initGameBanner() {
// Set initial image
const getImgInfo = function(imgNum) {
- const src = bigImgsEl.getAttribute(`data-img-src-${imgNum}`);
- const desc = bigImgsEl.getAttribute(`data-img-desc-${imgNum}`);
+ const src = bigImgsEl.dataset[`imgSrc${imgNum}`];
+ const desc = bigImgsEl.dataset[`imgDesc${imgNum}`];
return { src, desc };
};
@@ -532,3 +533,25 @@ function initGameBanner() {
document.addEventListener("DOMContentLoaded", () => {
loadItemDetail("games", renderGame);
});
+
+/* istanbul ignore next */
+if (typeof module !== "undefined") {
+ module.exports = {
+ setupGameBanner,
+ renderGameCover,
+ renderGameBadges,
+ renderGameRatings,
+ renderGamePlatforms,
+ renderReleaseDates,
+ renderCompanies,
+ renderCollectionsAndFranchises,
+ renderMultiplayer,
+ renderGame,
+ renderScreenshots,
+ renderVideos,
+ renderExternalLinks,
+ renderCharacters,
+ getRegionFlag,
+ initGameBanner,
+ };
+}
diff --git a/gh-pages-template/assets/js/item_detail.js b/gh-pages-template/assets/js/item_detail.js
index b51f568c0662..66542df483cf 100644
--- a/gh-pages-template/assets/js/item_detail.js
+++ b/gh-pages-template/assets/js/item_detail.js
@@ -156,6 +156,7 @@ function renderGameList(container, games) {
// Render games that already have full data
gamesWithData.forEach(game => {
+ /* istanbul ignore next */
renderGameCard(row, game.id, game.name, game.cover ? igdbImageUrl(game.cover.url, "t_cover_small_2x") : null);
});
@@ -228,3 +229,17 @@ function renderGameCard(row, gameId, gameName, coverUrl) {
cardBody.appendChild(nameEl);
}
}
+
+/* istanbul ignore next */
+if (typeof module !== "undefined") {
+ module.exports = {
+ getQueryParam,
+ igdbImageUrl,
+ makeBadge,
+ addDlRow,
+ showError,
+ loadItemDetail,
+ renderGameList,
+ renderGameCard,
+ };
+}
diff --git a/gh-pages-template/assets/js/item_loader.js b/gh-pages-template/assets/js/item_loader.js
index e658b6a22ab3..50c28f6e78c8 100644
--- a/gh-pages-template/assets/js/item_loader.js
+++ b/gh-pages-template/assets/js/item_loader.js
@@ -1,5 +1,7 @@
// setup defaults β base_path is injected by Jekyll via globalThis.GAMEDB_CONFIG
+/* istanbul ignore next */
const _cfg = globalThis.GAMEDB_CONFIG || {};
+/* istanbul ignore next */
let base_path = _cfg.base_path
? ("/" + _cfg.base_path).replaceAll(/\/+/g, "/").replace(/\/$/, "")
: "/GameDB";
@@ -23,6 +25,7 @@ function splitString(string) {
const regex = /(.{0,200})\b/;
const match = regex.exec(string);
+ /* istanbul ignore next */
if (match) {
// Split the string at the end of the last full word
const splitIndex = match[1].length;
@@ -111,7 +114,9 @@ function createGameCard(id, game, allPlatforms = null) {
const platformYears = {}
if (game.release_dates && game.release_dates.length > 0) {
game.release_dates.forEach(rd => {
+ /* istanbul ignore else */
if (rd.platform && rd.y) {
+ /* istanbul ignore else */
if (!platformYears[rd.platform] || rd.y < platformYears[rd.platform]) {
platformYears[rd.platform] = rd.y
}
@@ -532,7 +537,7 @@ function run_search() {
// Filter results by name (case-insensitive)
const term_lower = search_term.toLowerCase()
- const matches = Object.entries(bucket_data).filter(([_id, game]) =>
+ const matches = Object.entries(bucket_data).filter(([, game]) =>
game.name.toLowerCase().includes(term_lower)
)
@@ -587,3 +592,23 @@ function run_search() {
search_container.appendChild(errEl)
})
}
+
+/* istanbul ignore next */
+if (typeof module !== "undefined") {
+ module.exports = {
+ splitString,
+ fetchGameData,
+ createGameCard,
+ renderSearchResults,
+ addMoreResultsNote,
+ createPlatformBanner,
+ createPlatformCardBody,
+ getPlatformVersion,
+ addVersionMetadataToFooter,
+ addReleaseDatesToFooter,
+ addMetadataItemToFooter,
+ processPlatformsData,
+ createPlatformCardElement,
+ run_search,
+ };
+}
diff --git a/gh-pages-template/assets/js/platform_detail.js b/gh-pages-template/assets/js/platform_detail.js
index 4c7377d06d7e..0c0f80d96013 100644
--- a/gh-pages-template/assets/js/platform_detail.js
+++ b/gh-pages-template/assets/js/platform_detail.js
@@ -253,3 +253,16 @@ function renderPlatform(data) {
document.addEventListener("DOMContentLoaded", () => {
loadItemDetail("platforms", renderPlatform);
});
+
+/* istanbul ignore next */
+if (typeof module !== "undefined") {
+ module.exports = {
+ getRegionFlag,
+ renderPlatformLogo,
+ renderPlatformBadges,
+ renderPlatformMetadata,
+ createVersionAccordionItem,
+ populateVersionBody,
+ renderPlatform,
+ };
+}
diff --git a/package.json b/package.json
new file mode 100644
index 000000000000..b5e4ae4f3a14
--- /dev/null
+++ b/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "gamedb",
+ "version": "0.0.0",
+ "description": "Game Database - Jekyll static site with IGDB data",
+ "scripts": {
+ "test": "npm-run-all test:unit test:report test:lint",
+ "test:unit": "jest --coverage",
+ "test:report": "jest --reporters=jest-junit",
+ "test:lint": "eslint gh-pages-template/assets/js/**/*.js tests/**/*.js",
+ "lint": "eslint gh-pages-template/assets/js/**/*.js tests/**/*.js",
+ "lint:fix": "eslint gh-pages-template/assets/js/**/*.js tests/**/*.js --fix"
+ },
+ "jest": {
+ "testEnvironment": "jsdom",
+ "testMatch": [
+ "**/tests/**/*.test.js",
+ "**/tests/**/*.spec.js"
+ ],
+ "collectCoverageFrom": [
+ "gh-pages-template/assets/js/*.js"
+ ],
+ "coveragePathIgnorePatterns": [
+ "/node_modules/",
+ "/tests/"
+ ],
+ "coverageReporters": [
+ "text",
+ "lcov",
+ "html"
+ ],
+ "coverageThreshold": {
+ "global": {
+ "branches": 100,
+ "functions": 100,
+ "lines": 100,
+ "statements": 100
+ }
+ }
+ },
+ "devDependencies": {
+ "@babel/core": "7.29.0",
+ "@babel/preset-env": "7.29.0",
+ "@eslint/js": "10.0.1",
+ "@jest/globals": "30.2.0",
+ "babel-jest": "30.2.0",
+ "eslint": "10.0.1",
+ "globals": "17.3.0",
+ "jest": "30.2.0",
+ "jest-environment-jsdom": "30.2.0",
+ "jest-junit": "16.0.0",
+ "npm-run-all": "4.1.5"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ReenigneArcher/GameDB"
+ }
+}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 000000000000..cc2fd87e6061
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,72 @@
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "gamedb"
+dynamic = ["version"]
+description = "Game Database - Jekyll static site with IGDB data"
+readme = "README.md"
+requires-python = ">=3.12"
+license = {text = "AGPL-3.0-only"}
+authors = [
+ {name = "LizardByte", email = "lizardbyte@users.noreply.github.com"}
+]
+
+dependencies = [
+ "igdb-api-v4==0.3.3",
+ "python-dotenv==1.2.1",
+ "requests==2.32.5",
+ "requests-cache==1.3.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "flake8==7.3.0",
+ "pytest==8.4.1",
+ "pytest-cov==6.2.1",
+ "requests-mock==1.12.1",
+]
+
+[project.urls]
+Homepage = "https://LizardByte.github.io/GameDB"
+Repository = "https://github.com/LizardByte/GameDB"
+Issues = "https://github.com/LizardByte/GameDB/issues"
+
+[tool.setuptools]
+packages = ["src"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+pythonpath = ["src"]
+python_files = ["test_*.py"]
+python_classes = ["Test*"]
+python_functions = ["test_*"]
+addopts = [
+ "-rxXs",
+ "--tb=native",
+ "--verbose",
+ "--color=yes",
+ "--cov=src",
+ "--cov-report=term-missing",
+ "--junitxml=junit-python.xml",
+ "-o", "junit_family=legacy",
+]
+
+[tool.coverage.run]
+source = ["src"]
+omit = [
+ "*/tests/*",
+ "*/__pycache__/*",
+]
+
+[tool.coverage.report]
+exclude_lines = [
+ "pragma: no cover",
+ "def __repr__",
+ "raise AssertionError",
+ "raise NotImplementedError",
+ "if __name__ == .__main__.:",
+ "if TYPE_CHECKING:",
+ "@abstractmethod",
+]
diff --git a/requirements-dev.txt b/requirements-dev.txt
deleted file mode 100644
index 1f1a44bd534b..000000000000
--- a/requirements-dev.txt
+++ /dev/null
@@ -1 +0,0 @@
-flake8==7.3.0
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index f8b3ca67193c..000000000000
--- a/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-igdb-api-v4==0.3.3
-python-dotenv==1.2.1
-requests==2.32.5
-requests-cache==1.3.0
diff --git a/src/update_db.py b/src/update_db.py
index 2b2618a0f552..a35e177bd7a7 100644
--- a/src/update_db.py
+++ b/src/update_db.py
@@ -19,6 +19,10 @@
# setup environment if running locally
load_dotenv()
+# module-level globals populated by main()
+args = None
+wrapper = None
+
def igdb_authorization(client_id: str, client_secret: str) -> dict:
"""
@@ -99,6 +103,344 @@ def get_youtube(video_ids: list) -> dict:
return response.json()
+def _fetch_endpoint(endpoint: str, fields: list, limit: int, test_mode: bool, test_limit: int) -> dict:
+ """
+ Fetch all pages from IGDB for a single endpoint.
+
+ Parameters
+ ----------
+ endpoint : str
+ The IGDB endpoint name.
+ fields : list
+ List of field names to request.
+ limit : int
+ Number of items per page.
+ test_mode : bool
+ Whether to stop early after test_limit items.
+ test_limit : int
+ Maximum items to collect when test_mode is True.
+
+ Returns
+ -------
+ dict
+ Dictionary of {id: item} for all fetched items.
+ """
+ result_dict = {}
+ offset = 0
+ has_more = True
+ test_count = 0
+
+ while has_more:
+ try:
+ byte_array = wrapper.api_request(
+ endpoint=endpoint,
+ query=f'fields {", ".join(fields)}; limit {limit}; offset {offset};'
+ )
+ except requests.exceptions.HTTPError:
+ time.sleep(1)
+ continue
+
+ json_result = json.loads(byte_array)
+
+ for item in json_result:
+ result_dict[item['id']] = item
+
+ if test_mode:
+ test_count += 1
+ if test_count >= test_limit:
+ has_more = False
+ break
+
+ offset += limit
+
+ if not json_result:
+ has_more = False
+
+ return result_dict
+
+
+def _fetch_all_endpoints(request_dict: dict, limit: int, test_mode: bool, test_limit: int) -> dict:
+ """
+ Fetch data from all IGDB endpoints and write all.json files where applicable.
+
+ Parameters
+ ----------
+ request_dict : dict
+ Endpoint configuration dictionary.
+ limit : int
+ Items per IGDB page.
+ test_mode : bool
+ Whether to stop early.
+ test_limit : int
+ Max items per endpoint in test mode.
+
+ Returns
+ -------
+ dict
+ Combined dictionary of all fetched data keyed by endpoint name.
+ """
+ full_dict = {}
+
+ for endpoint, endpoint_dict in request_dict.items():
+ print(f'now processing endpoint: {endpoint}')
+ full_dict[endpoint] = _fetch_endpoint(
+ endpoint=endpoint,
+ fields=endpoint_dict['fields'],
+ limit=limit,
+ test_mode=test_mode,
+ test_limit=test_limit,
+ )
+
+ if endpoint_dict['write_all']:
+ file_path = os.path.join(args.out_dir, endpoint, 'all')
+ write_json_files(file_path=file_path, data=full_dict[endpoint])
+
+ print(f'{len(full_dict[endpoint])} items processed in endpoint: {endpoint}')
+
+ return full_dict
+
+
+def _build_related_entry(value: dict, fields: list) -> dict:
+ """
+ Build a new entry dict from value containing only the specified fields.
+
+ Parameters
+ ----------
+ value : dict
+ The source item dictionary.
+ fields : list
+ The list of field names to include.
+
+ Returns
+ -------
+ dict
+ A dictionary containing only the requested fields present in value.
+ """
+ return {field: value[field] for field in fields if field in value}
+
+
+def _append_item_to_endpoint(full_dict: dict, endpoint: str, item_type: str, value: dict, fields: list) -> None:
+ """
+ Append a single related item into all matching destination entries in the endpoint.
+
+ Parameters
+ ----------
+ full_dict : dict
+ The combined data dictionary (mutated in-place).
+ endpoint : str
+ The destination endpoint name.
+ item_type : str
+ The type key under which to append the entry.
+ value : dict
+ The source item containing a list of destination IDs under the endpoint key.
+ fields : list
+ Fields to copy from value into the new entry.
+ """
+ append_to = value.get(endpoint)
+ if not append_to:
+ return
+
+ new_entry = _build_related_entry(value=value, fields=fields)
+
+ for item_id_dest in append_to:
+ if item_id_dest not in full_dict[endpoint]:
+ continue
+ if item_type not in full_dict[endpoint][item_id_dest]:
+ full_dict[endpoint][item_id_dest][item_type] = []
+ full_dict[endpoint][item_id_dest][item_type].append(new_entry)
+
+
+def _append_item_type(full_dict: dict, endpoint: str, item_type: str, fields: list) -> None:
+ """
+ Append all items of a given type into the appropriate endpoint entries.
+
+ Parameters
+ ----------
+ full_dict : dict
+ The combined data dictionary (mutated in-place).
+ endpoint : str
+ The destination endpoint name.
+ item_type : str
+ The source item type to iterate over.
+ fields : list
+ Fields to include in each appended entry.
+ """
+ print(f'adding {item_type} to {endpoint}')
+ for value in full_dict[item_type].values():
+ _append_item_to_endpoint(
+ full_dict=full_dict,
+ endpoint=endpoint,
+ item_type=item_type,
+ value=value,
+ fields=fields,
+ )
+
+
+def _append_related_items(full_dict: dict, request_dict: dict) -> None:
+ """
+ Append related items (e.g. characters to games, games to platforms) into full_dict in-place.
+
+ Parameters
+ ----------
+ full_dict : dict
+ The combined data dictionary (mutated in-place).
+ request_dict : dict
+ Endpoint configuration containing 'append' sub-dicts.
+ """
+ for endpoint, endpoint_dict in request_dict.items():
+ append_dict = endpoint_dict.get('append')
+ if not append_dict:
+ continue
+
+ for item_type, item_type_dict in append_dict.items():
+ _append_item_type(
+ full_dict=full_dict,
+ endpoint=endpoint,
+ item_type=item_type,
+ fields=item_type_dict['fields'],
+ )
+
+
+def _add_platform_game_counts(full_dict: dict) -> None:
+ """
+ Calculate and attach game counts to each platform, then rewrite platforms/all.json.
+
+ Parameters
+ ----------
+ full_dict : dict
+ The combined data dictionary (mutated in-place).
+ """
+ print('calculating game counts for platforms')
+ for platform_data in full_dict['platforms'].values():
+ platform_data['game_count'] = len(platform_data.get('games', []))
+
+ file_path = os.path.join(args.out_dir, 'platforms', 'all')
+ write_json_files(file_path=file_path, data=full_dict['platforms'])
+
+
+def _build_buckets_and_collect_videos(full_dict: dict) -> tuple:
+ """
+ Build search buckets from game names and collect all unique video IDs.
+
+ Parameters
+ ----------
+ full_dict : dict
+ The combined data dictionary.
+
+ Returns
+ -------
+ tuple
+ A (buckets dict, all_videos list) pair.
+ """
+ print('creating buckets / collecting video ids')
+ buckets = {}
+ all_videos = []
+
+ for game_id, game_data in full_dict['games'].items():
+ bucket = "".join(x.strip().lower() for x in game_data['name'][:2] if x.isalnum())
+ if not re.fullmatch(r'[\da-z]+', bucket):
+ bucket = '@'
+
+ if bucket not in buckets:
+ buckets[bucket] = {}
+ buckets[bucket][game_id] = {'name': game_data['name']}
+
+ for video in game_data.get('videos', []):
+ video_id = video['video_id']
+ if video_id not in all_videos:
+ all_videos.append(video_id)
+
+ return buckets, all_videos
+
+
+def _resolve_video_groups(all_videos: list, cache_file: str, group_size: int) -> list:
+ """
+ Resolve the list of video groups, using and updating the cache file.
+
+ Parameters
+ ----------
+ all_videos : list
+ All video IDs that currently exist.
+ cache_file : str
+ Path to the JSON file caching previous video groups.
+ group_size : int
+ Maximum number of videos per YouTube API call.
+
+ Returns
+ -------
+ list
+ List of video-ID sub-lists ready for YouTube API requests.
+ """
+ os.makedirs(os.path.dirname(cache_file), exist_ok=True)
+
+ if not os.path.isfile(cache_file):
+ all_video_groups = [all_videos[x:x + group_size] for x in range(0, len(all_videos), group_size)]
+ else:
+ with open(cache_file, 'r') as f:
+ cached_video_groups = json.load(f)
+
+ all_video_groups = [g for g in cached_video_groups if all(v in all_videos for v in g)]
+
+ uncached_videos = [v for v in all_videos if not any(v in g for g in cached_video_groups)]
+ uncached_groups = [uncached_videos[x:x + group_size] for x in range(0, len(uncached_videos), group_size)]
+ all_video_groups.extend(uncached_groups)
+
+ with open(cache_file, 'w') as f:
+ json.dump(all_video_groups, f)
+
+ return all_video_groups
+
+
+def _fetch_youtube_metadata(full_dict: dict, all_video_groups: list) -> None:
+ """
+ Fetch YouTube metadata for all video groups and store in full_dict['videos'].
+
+ Parameters
+ ----------
+ full_dict : dict
+ The combined data dictionary (mutated in-place).
+ all_video_groups : list
+ List of video-ID sub-lists.
+ """
+ print('collecting video metadata')
+ full_dict['videos'] = {}
+
+ for video_group in all_video_groups:
+ json_result = get_youtube(video_ids=video_group)
+ try:
+ for item in json_result['items']:
+ full_dict['videos'][item['id']] = item
+ except KeyError as e:
+ print(f'KeyError: {e}\n\n{json.dumps(json_result, indent=2)}')
+
+
+def _enrich_game_videos(full_dict: dict) -> None:
+ """
+ Attach YouTube URL, title, and thumbnail to each video in game data.
+
+ Parameters
+ ----------
+ full_dict : dict
+ The combined data dictionary (mutated in-place).
+ """
+ print('adding videos to games')
+ for game_data in full_dict['games'].values():
+ for video in game_data.get('videos', []):
+ try:
+ video_details = full_dict['videos'][video['video_id']]
+ except (IndexError, KeyError):
+ continue
+
+ video_thumbs = video_details['snippet']['thumbnails']
+ video_thumbs = {k: v for k, v in video_thumbs.items() if v is not None}
+
+ video_thumbs = sorted(video_thumbs.items(), key=lambda x: x[1]['width'], reverse=True)
+
+ video['url'] = f'https://www.youtube.com/watch?v={video_details["id"]}'
+ video['title'] = video_details['snippet']['title']
+ video['thumb'] = video_thumbs[0][1]['url']
+
+
def get_data():
"""
Get data from IGDB and YouTube.
@@ -231,214 +573,41 @@ def get_data():
'write_all': True,
},
}
- limit = 500
- full_dict = {}
-
- for end_point, end_point_dict in request_dict.items():
- print(f'now processing endpoint: {end_point}')
- offset = 0
- result = True
- full_dict[end_point] = {}
- test_count = 0
-
- while result:
- try:
- byte_array = wrapper.api_request(
- endpoint=end_point,
- query=f'fields {", ".join(end_point_dict["fields"])}; limit {limit}; offset {offset};'
- )
- except requests.exceptions.HTTPError:
- # handle too many requests
- time.sleep(1)
- continue
-
- json_result = json.loads(byte_array) # this is a list of dictionaries
-
- for item in json_result:
- full_dict[end_point][item['id']] = item
- if args.test_mode:
- test_count += 1
- if test_count >= args.test_limit:
- result = False
- break
-
- offset += limit
-
- if not json_result:
- result = False
+ limit = 500
- if end_point_dict['write_all']:
- # write the end_point file
- file_path = os.path.join(args.out_dir, end_point, 'all')
- write_json_files(file_path=file_path, data=full_dict[end_point])
+ full_dict = _fetch_all_endpoints(
+ request_dict=request_dict,
+ limit=limit,
+ test_mode=args.test_mode,
+ test_limit=args.test_limit,
+ )
- print(f'{len(full_dict[end_point])} items processed in endpoint: {end_point}')
+ _append_related_items(full_dict=full_dict, request_dict=request_dict)
- for end_point, end_point_dict in request_dict.items():
- try:
- append_dict = request_dict[end_point]['append']
- except KeyError:
- pass
- else:
- for item_type, item_type_dict in append_dict.items():
- print(f'adding {item_type} to {end_point}')
- for item_id_src, value in full_dict[item_type].items():
- try:
- append_to = value[end_point]
- except KeyError:
- pass
- else:
- for item_id_dest in append_to:
- try:
- full_dict[end_point][item_id_dest]
- except KeyError:
- # the destination item doesn't exist
- pass
- else:
- try:
- full_dict[end_point][item_id_dest][item_type]
- except KeyError:
- full_dict[end_point][item_id_dest][item_type] = []
- finally:
- full_dict[end_point][item_id_dest][item_type].append({})
-
- for field in item_type_dict['fields']:
- try:
- field_value = value[field]
- except KeyError:
- # this item doesn't have the specified field, no problem
- pass
- else:
- full_dict[end_point][item_id_dest][item_type][-1][field] = field_value
-
- # Add game counts to platforms for the all.json file
- print('calculating game counts for platforms')
- for platform_id, platform_data in full_dict['platforms'].items():
- try:
- games = platform_data['games']
- platform_data['game_count'] = len(games)
- except KeyError:
- # no games for this platform
- platform_data['game_count'] = 0
+ _add_platform_game_counts(full_dict=full_dict)
- # Rewrite platforms/all.json with game counts
- file_path = os.path.join(args.out_dir, 'platforms', 'all')
- write_json_files(file_path=file_path, data=full_dict['platforms'])
+ buckets, all_videos = _build_buckets_and_collect_videos(full_dict=full_dict)
- # create buckets and get list of all videos
- print('creating buckets / collecting video ids')
- buckets = {}
- all_videos = []
- for game_id, game_data in full_dict['games'].items():
- # games
- bucket = "".join(x.strip().lower() for x in game_data['name'][:2] if x.isalnum())
- if not re.fullmatch(r'[\da-z]+', bucket):
- bucket = '@'
-
- try:
- buckets[bucket]
- except KeyError:
- buckets[bucket] = {}
- finally:
- buckets[bucket][game_id] = {'name': game_data['name']}
-
- # videos
- try:
- game_videos = game_data['videos']
- except KeyError:
- # no videos for this game
- pass
- else:
- for video in game_videos:
- video_id = video['video_id']
- if video_id not in all_videos:
- all_videos.append(video_id)
-
- # write the full game index
for bucket, bucket_data in buckets.items():
file_path = os.path.join(args.out_dir, 'buckets', str(bucket))
write_json_files(file_path=file_path, data=bucket_data)
- # get data for videos
- # we can only make 10,000 requests to YouTube api per day, so let's get as much data as possible in each request
- print('collecting video metadata')
-
- end_point = 'videos'
- full_dict[end_point] = {}
-
all_videos.sort()
+ all_video_groups = _resolve_video_groups(
+ all_videos=all_videos,
+ cache_file='cache/video_groups.json',
+ group_size=50,
+ )
- cache_file = 'cache/video_groups.json'
- os.makedirs(os.path.dirname(cache_file), exist_ok=True)
-
- group_size = 50
- if not os.path.isfile(cache_file):
- all_video_groups = [all_videos[x:x + group_size] for x in range(0, len(all_videos), group_size)]
- else:
- with open(cache_file, 'r') as f:
- cached_video_groups = json.load(f)
-
- # Filter cached video groups to include only those where all videos are in all_videos
- all_video_groups = [x for x in cached_video_groups if all(video in all_videos for video in x)]
-
- # Find videos that are not in any cached video group
- uncached_videos = [video for video in all_videos if not any(video in group for group in cached_video_groups)]
-
- # Append uncached videos in groups of up to 50 to all_video_groups
- uncached_video_groups = [uncached_videos[x:x + group_size] for x in range(0, len(uncached_videos), group_size)]
- all_video_groups.extend(uncached_video_groups)
-
- # write the new video groups to cache
- with open(cache_file, 'w') as f:
- json.dump(all_video_groups, f)
-
- for video_group in all_video_groups:
- json_result = get_youtube(video_ids=video_group)
+ _fetch_youtube_metadata(full_dict=full_dict, all_video_groups=all_video_groups)
- try:
- for item in json_result['items']:
- full_dict[end_point][item['id']] = item
- except KeyError as e:
- print(f'KeyError: {e}\n\n{json.dumps(json_result, indent=2)}')
+ _enrich_game_videos(full_dict=full_dict)
- # get video details for games
- print('adding videos to games')
- for game_id, game_data in full_dict['games'].items():
- try:
- game_videos = game_data['videos']
- except KeyError:
- # no videos for this game
- pass
- else:
- for video in game_videos:
- try:
- video_details = full_dict['videos'][video['video_id']]
- except (IndexError, KeyError):
- # no data for this video
- pass
- else:
- video_thumbs = video_details['snippet']['thumbnails']
-
- # remove keys that have no value
- # create a copy of original dictionary since we may alter it, https://stackoverflow.com/a/33815594
- for video_key, video_value in dict(video_thumbs).items():
- if video_value is None:
- del video_thumbs[video_key]
-
- # sort the video thumbnails by width into a list
- video_thumbs = sorted(video_thumbs.items(), key=lambda x: x[1]['width'], reverse=True)
-
- # the final video thumbnail
- video['url'] = f'https://www.youtube.com/watch?v={video_details["id"]}'
- video['title'] = video_details['snippet']['title']
- video['thumb'] = video_thumbs[0][1]['url']
-
- # write the individual files
- for end_point, end_point_dict in full_dict.items():
- print(f'writing individual files for {end_point}')
- for item_id, data in end_point_dict.items():
- file_path = os.path.join(args.out_dir, end_point, str(item_id))
+ for endpoint, endpoint_dict in full_dict.items():
+ print(f'writing individual files for {endpoint}')
+ for item_id, data in endpoint_dict.items():
+ file_path = os.path.join(args.out_dir, endpoint, str(item_id))
write_json_files(file_path=file_path, data=data)
@@ -453,8 +622,10 @@ def get_platform_cross_reference():
write_json_files(file_path=file_path, data=platforms.cross_reference)
-if __name__ == '__main__':
- # setup arguments using argparse
+def main():
+ """Parse CLI arguments, initialise IGDB wrapper, and run the database update."""
+ global args, wrapper
+
parser = argparse.ArgumentParser(description="Download entire igdb database.")
parser.add_argument(
'-o',
@@ -513,10 +684,12 @@ def get_platform_cross_reference():
'"YOUTUBE_API_KEY". They should be placed in org/repo secrets if using github, '
'or ".env" file if running local.')
- # setup igdb authorization and wrapper
auth = igdb_authorization(client_id=args.twitch_client_id, client_secret=args.twitch_client_secret)
wrapper = IGDBWrapper(client_id=args.twitch_client_id, auth_token=auth['access_token'])
- # get date, process dictionaries and write data
get_data()
get_platform_cross_reference()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/character_detail.test.js b/tests/character_detail.test.js
new file mode 100644
index 000000000000..6f3597e39412
--- /dev/null
+++ b/tests/character_detail.test.js
@@ -0,0 +1,121 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import {
+ describe,
+ test,
+ expect,
+ beforeEach,
+ afterEach,
+} from '@jest/globals';
+
+globalThis.GAMEDB_CONFIG = { base_path: '/GameDB' };
+
+const itemDetail = require('../gh-pages-template/assets/js/item_detail.js');
+globalThis.igdbImageUrl = itemDetail.igdbImageUrl;
+globalThis.makeBadge = itemDetail.makeBadge;
+globalThis.loadItemDetail = itemDetail.loadItemDetail;
+globalThis.renderGameList = itemDetail.renderGameList;
+globalThis.base_path = '/GameDB';
+globalThis.base_url = 'http://localhost/GameDB';
+
+const { renderCharacter } = require('../gh-pages-template/assets/js/character_detail.js');
+
+const baseDom = `
+
+
+
+
+
+
+`;
+
+describe('character_detail.js', () => {
+ beforeEach(() => {
+ document.body.innerHTML = baseDom;
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ describe('renderCharacter', () => {
+ test('sets title and name', () => {
+ renderCharacter({ name: 'Link' });
+ expect(document.title).toContain('Link');
+ expect(document.getElementById('character-name').textContent).toBe('Link');
+ });
+
+ test('uses fallback title and name when name absent', () => {
+ renderCharacter({});
+ expect(document.title).toContain('Character');
+ expect(document.getElementById('character-name').textContent).toBe('Unknown Character');
+ });
+
+ test('shows mug shot image when URL provided', () => {
+ renderCharacter({
+ name: 'Zelda',
+ mug_shot: { url: '//images.igdb.com/igdb/image/upload/t_thumb/mug.jpg' },
+ });
+ const mugEl = document.getElementById('character-mug');
+ expect(mugEl.style.display).toBe('');
+ expect(mugEl.alt).toBe('Zelda');
+ expect(mugEl.src).toContain('mug.jpg');
+ });
+
+ test('shows mug shot with empty alt when no name', () => {
+ renderCharacter({
+ mug_shot: { url: '//images.igdb.com/igdb/image/upload/t_thumb/mug.jpg' },
+ });
+ const mugEl = document.getElementById('character-mug');
+ expect(mugEl.alt).toBe('');
+ });
+
+ test('shows placeholder when no mug shot', () => {
+ renderCharacter({ name: 'NPC' });
+ const mugEl = document.getElementById('character-mug');
+ expect(mugEl.style.display).toBe('none');
+ });
+
+ test('renders gender and species badges', () => {
+ renderCharacter({
+ name: 'Samus',
+ character_gender: { name: 'Female' },
+ character_species: { name: 'Human' },
+ });
+ const badges = document.getElementById('character-badges');
+ expect(badges.textContent).toContain('Female');
+ expect(badges.textContent).toContain('Human');
+ });
+
+ test('skips badges when gender and species absent', () => {
+ renderCharacter({ name: 'Robot' });
+ expect(document.getElementById('character-badges').children.length).toBe(0);
+ });
+
+ test('shows games section when games present', () => {
+ renderCharacter({
+ name: 'Mario',
+ games: [{ id: 1, name: 'Super Mario', cover: { url: '//img.igdb.com/t_thumb/c.jpg' } }],
+ });
+ expect(document.getElementById('character-games-section').classList.contains('d-none')).toBe(false);
+ });
+
+ test('keeps games section hidden when no games', () => {
+ renderCharacter({ name: 'Ghost' });
+ expect(document.getElementById('character-games-section').classList.contains('d-none')).toBe(true);
+ });
+ });
+
+ describe('DOMContentLoaded integration', () => {
+ test('fires loadItemDetail on DOMContentLoaded', () => {
+ // loadItemDetail is already global; dispatching the event exercises the callback
+ globalThis.history.pushState(null, '', '/');
+ // Should not throw when id is absent
+ expect(() => document.dispatchEvent(new Event('DOMContentLoaded'))).not.toThrow();
+ });
+ });
+});
diff --git a/tests/collection_detail.test.js b/tests/collection_detail.test.js
new file mode 100644
index 000000000000..700a2725db67
--- /dev/null
+++ b/tests/collection_detail.test.js
@@ -0,0 +1,89 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import {
+ describe,
+ test,
+ expect,
+ beforeEach,
+ afterEach,
+} from '@jest/globals';
+
+globalThis.GAMEDB_CONFIG = { base_path: '/GameDB' };
+
+const itemDetail = require('../gh-pages-template/assets/js/item_detail.js');
+globalThis.makeBadge = itemDetail.makeBadge;
+globalThis.addDlRow = itemDetail.addDlRow;
+globalThis.loadItemDetail = itemDetail.loadItemDetail;
+globalThis.renderGameList = itemDetail.renderGameList;
+globalThis.igdbImageUrl = itemDetail.igdbImageUrl;
+globalThis.base_path = '/GameDB';
+globalThis.base_url = 'http://localhost/GameDB';
+
+const { renderCollection } = require('../gh-pages-template/assets/js/collection_detail.js');
+
+const baseDom = `
+
+
+
+
+`;
+
+describe('collection_detail.js', () => {
+ beforeEach(() => {
+ document.body.innerHTML = baseDom;
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ describe('renderCollection', () => {
+ test('sets title and name', () => {
+ renderCollection({ name: 'Sonic the Hedgehog' });
+ expect(document.title).toContain('Sonic the Hedgehog');
+ expect(document.getElementById('collection-name').textContent).toBe('Sonic the Hedgehog');
+ });
+
+ test('uses fallback title and name when name absent', () => {
+ renderCollection({});
+ expect(document.title).toContain('Series');
+ expect(document.getElementById('collection-name').textContent).toBe('Unknown Series');
+ });
+
+ test('shows IGDB link when URL provided', () => {
+ renderCollection({ name: 'Mario', url: 'https://igdb.com/collections/mario' });
+ const link = document.getElementById('collection-igdb-link');
+ expect(link.classList.contains('d-none')).toBe(false);
+ expect(link.href).toContain('igdb.com');
+ });
+
+ test('keeps IGDB link hidden when no URL', () => {
+ renderCollection({ name: 'Zelda' });
+ expect(document.getElementById('collection-igdb-link').classList.contains('d-none')).toBe(true);
+ });
+
+ test('shows games section when games present', () => {
+ renderCollection({
+ name: 'Metroid',
+ games: [{ id: 1, name: 'Metroid Prime', cover: { url: '//img.igdb.com/t_thumb/c.jpg' } }],
+ });
+ expect(document.getElementById('collection-games-section').classList.contains('d-none')).toBe(false);
+ });
+
+ test('keeps games section hidden when no games', () => {
+ renderCollection({ name: 'Empty' });
+ expect(document.getElementById('collection-games-section').classList.contains('d-none')).toBe(true);
+ });
+ });
+
+ describe('DOMContentLoaded integration', () => {
+ test('fires loadItemDetail on DOMContentLoaded', () => {
+ globalThis.history.pushState(null, '', '/');
+ expect(() => document.dispatchEvent(new Event('DOMContentLoaded'))).not.toThrow();
+ });
+ });
+});
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 000000000000..a017716c6b1e
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,48 @@
+# standard imports
+import argparse
+
+# lib imports
+import pytest
+
+
+@pytest.fixture()
+def mock_args(tmp_path):
+ """Return a minimal args Namespace that update_db functions expect."""
+ ns = argparse.Namespace(
+ out_dir=str(tmp_path / 'gh-pages'),
+ indent=None,
+ test_mode=False,
+ test_limit=1000,
+ youtube_api_key='fake_yt_key',
+ )
+ return ns
+
+
+@pytest.fixture()
+def sample_game():
+ return {
+ 'id': 1,
+ 'name': 'Halo',
+ 'platforms': [6],
+ 'videos': [{'video_id': 'abc123', 'name': 'Trailer'}],
+ 'cover': {'url': '//images.igdb.com/t_thumb/cover.jpg'},
+ }
+
+
+@pytest.fixture()
+def sample_platform():
+ return {
+ 'id': 6,
+ 'name': 'PC (Microsoft Windows)',
+ 'games': [],
+ }
+
+
+@pytest.fixture()
+def sample_character():
+ return {
+ 'id': 99,
+ 'name': 'Master Chief',
+ 'games': [1],
+ 'mug_shot': {'url': '//images.igdb.com/t_thumb/mug.jpg'},
+ }
diff --git a/tests/franchise_detail.test.js b/tests/franchise_detail.test.js
new file mode 100644
index 000000000000..047ecb645e39
--- /dev/null
+++ b/tests/franchise_detail.test.js
@@ -0,0 +1,89 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import {
+ describe,
+ test,
+ expect,
+ beforeEach,
+ afterEach,
+} from '@jest/globals';
+
+globalThis.GAMEDB_CONFIG = { base_path: '/GameDB' };
+
+const itemDetail = require('../gh-pages-template/assets/js/item_detail.js');
+globalThis.makeBadge = itemDetail.makeBadge;
+globalThis.addDlRow = itemDetail.addDlRow;
+globalThis.loadItemDetail = itemDetail.loadItemDetail;
+globalThis.renderGameList = itemDetail.renderGameList;
+globalThis.igdbImageUrl = itemDetail.igdbImageUrl;
+globalThis.base_path = '/GameDB';
+globalThis.base_url = 'http://localhost/GameDB';
+
+const { renderFranchise } = require('../gh-pages-template/assets/js/franchise_detail.js');
+
+const baseDom = `
+
+
+
+
+`;
+
+describe('franchise_detail.js', () => {
+ beforeEach(() => {
+ document.body.innerHTML = baseDom;
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ describe('renderFranchise', () => {
+ test('sets title and name', () => {
+ renderFranchise({ name: 'The Legend of Zelda' });
+ expect(document.title).toContain('The Legend of Zelda');
+ expect(document.getElementById('franchise-name').textContent).toBe('The Legend of Zelda');
+ });
+
+ test('uses fallback title and name when name absent', () => {
+ renderFranchise({});
+ expect(document.title).toContain('Franchise');
+ expect(document.getElementById('franchise-name').textContent).toBe('Unknown Franchise');
+ });
+
+ test('shows IGDB link when URL provided', () => {
+ renderFranchise({ name: 'Zelda', url: 'https://igdb.com/franchises/zelda' });
+ const link = document.getElementById('franchise-igdb-link');
+ expect(link.classList.contains('d-none')).toBe(false);
+ expect(link.href).toContain('igdb.com');
+ });
+
+ test('keeps IGDB link hidden when no URL', () => {
+ renderFranchise({ name: 'Mario' });
+ expect(document.getElementById('franchise-igdb-link').classList.contains('d-none')).toBe(true);
+ });
+
+ test('shows games section when games present', () => {
+ renderFranchise({
+ name: 'Halo',
+ games: [{ id: 5, name: 'Halo: CE', cover: { url: '//img.igdb.com/t_thumb/c.jpg' } }],
+ });
+ expect(document.getElementById('franchise-games-section').classList.contains('d-none')).toBe(false);
+ });
+
+ test('keeps games section hidden when no games', () => {
+ renderFranchise({ name: 'Empty' });
+ expect(document.getElementById('franchise-games-section').classList.contains('d-none')).toBe(true);
+ });
+ });
+
+ describe('DOMContentLoaded integration', () => {
+ test('fires loadItemDetail on DOMContentLoaded', () => {
+ globalThis.history.pushState(null, '', '/');
+ expect(() => document.dispatchEvent(new Event('DOMContentLoaded'))).not.toThrow();
+ });
+ });
+});
diff --git a/tests/game_detail.test.js b/tests/game_detail.test.js
new file mode 100644
index 000000000000..57f263eb9d2a
--- /dev/null
+++ b/tests/game_detail.test.js
@@ -0,0 +1,648 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import {
+ describe,
+ test,
+ expect,
+ jest,
+ beforeEach,
+ afterEach,
+} from '@jest/globals';
+
+// Set up global dependencies needed by item_detail.js (which game_detail depends on)
+globalThis.GAMEDB_CONFIG = { base_path: '/GameDB' };
+globalThis.openImageModal = jest.fn();
+
+// Require item_detail first (game_detail depends on it)
+const itemDetail = require('../gh-pages-template/assets/js/item_detail.js');
+
+// Make item_detail functions global so game_detail can use them
+globalThis.igdbImageUrl = itemDetail.igdbImageUrl;
+globalThis.makeBadge = itemDetail.makeBadge;
+globalThis.addDlRow = itemDetail.addDlRow;
+globalThis.loadItemDetail = itemDetail.loadItemDetail;
+globalThis.renderGameList = itemDetail.renderGameList;
+globalThis.base_path = '/GameDB';
+globalThis.base_url = 'http://localhost/GameDB';
+
+const {
+ getRegionFlag,
+ renderGameCover,
+ renderGameBadges,
+ renderGameRatings,
+ renderGamePlatforms,
+ renderReleaseDates,
+ renderCompanies,
+ renderCollectionsAndFranchises,
+ renderMultiplayer,
+ renderGame,
+ renderScreenshots,
+ renderVideos,
+ renderExternalLinks,
+ renderCharacters,
+ setupGameBanner,
+ initGameBanner,
+} = require('../gh-pages-template/assets/js/game_detail.js');
+
+// Full DOM used by most tests
+const fullDom = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+describe('game_detail.js', () => {
+ beforeEach(() => {
+ document.body.innerHTML = fullDom;
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ });
+
+ describe('getRegionFlag', () => {
+ test('returns correct flags for all known regions', () => {
+ expect(getRegionFlag('europe')).toBe('πͺπΊ');
+ expect(getRegionFlag('north_america')).toBe('πΊπΈ');
+ expect(getRegionFlag('australia')).toBe('π¦πΊ');
+ expect(getRegionFlag('new_zealand')).toBe('π³πΏ');
+ expect(getRegionFlag('japan')).toBe('π―π΅');
+ expect(getRegionFlag('china')).toBe('π¨π³');
+ expect(getRegionFlag('asia')).toBe('π');
+ expect(getRegionFlag('worldwide')).toBe('π');
+ expect(getRegionFlag('korea')).toBe('π°π·');
+ expect(getRegionFlag('brazil')).toBe('π§π·');
+ });
+
+ test('returns default globe for unknown region', () => {
+ expect(getRegionFlag('unknown')).toBe('π');
+ expect(getRegionFlag('')).toBe('π');
+ });
+ });
+
+ describe('renderGameCover', () => {
+ test('shows cover image when URL provided', () => {
+ renderGameCover({ name: 'Test', cover: { url: '//images.igdb.com/t_thumb/x.jpg' } });
+ const cover = document.getElementById('game-cover');
+ expect(cover.style.display).toBe('');
+ expect(cover.alt).toBe('Test');
+ expect(cover.src).toContain('x.jpg');
+ });
+
+ test('sets empty alt when cover present but no name', () => {
+ renderGameCover({ cover: { url: '//images.igdb.com/t_thumb/x.jpg' } });
+ expect(document.getElementById('game-cover').alt).toBe('');
+ });
+
+ test('hides cover and shows placeholder when no cover', () => {
+ renderGameCover({ name: 'No Cover' });
+ expect(document.getElementById('game-cover').style.display).toBe('none');
+ });
+ });
+
+ describe('renderGameBadges', () => {
+ test('renders all badge groups', () => {
+ renderGameBadges({
+ genres: [{ name: 'Action' }],
+ themes: [{ name: 'Sci-fi' }],
+ game_modes: [{ name: 'Single player' }],
+ player_perspectives: [{ name: 'First person' }],
+ });
+ expect(document.getElementById('game-badges').children.length).toBe(4);
+ });
+
+ test('handles missing badge arrays gracefully', () => {
+ renderGameBadges({});
+ expect(document.getElementById('game-badges').children.length).toBe(0);
+ });
+ });
+
+ describe('renderGameRatings', () => {
+ test('renders user rating, critic rating, and age ratings', () => {
+ const dl = document.getElementById('game-meta');
+ renderGameRatings({
+ rating: 85.5,
+ aggregated_rating: 78.2,
+ age_ratings: [
+ { rating_category: { rating: 'T' }, organization: { name: 'ESRB' } },
+ { rating_category: null, organization: null }, // skipped
+ ],
+ }, dl);
+ expect(dl.children.length).toBeGreaterThan(0);
+ expect(dl.textContent).toContain('86 / 100');
+ expect(dl.textContent).toContain('78 / 100');
+ expect(dl.textContent).toContain('ESRB');
+ });
+
+ test('skips when no ratings', () => {
+ const dl = document.getElementById('game-meta');
+ renderGameRatings({}, dl);
+ expect(dl.children.length).toBe(0);
+ });
+
+ test('skips age ratings row when all entries are incomplete', () => {
+ const dl = document.getElementById('game-meta');
+ renderGameRatings({
+ age_ratings: [{ rating_category: null, organization: null }],
+ }, dl);
+ expect(dl.children.length).toBe(0);
+ });
+ });
+
+ describe('renderGamePlatforms', () => {
+ test('fetches platforms and renders links on success', async () => {
+ jest.useRealTimers();
+ const dl = document.getElementById('game-meta');
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ '1': { name: 'PC' } }),
+ });
+ renderGamePlatforms({ platforms: [1, 2] }, dl);
+ await new Promise(r => setTimeout(r, 0));
+ expect(dl.textContent).toContain('PC');
+ expect(dl.textContent).toContain('Platform #2');
+ });
+
+ test('falls back to IDs when fetch fails', async () => {
+ jest.useRealTimers();
+ const dl = document.getElementById('game-meta');
+ globalThis.fetch = jest.fn().mockRejectedValue(new Error('fail'));
+ renderGamePlatforms({ platforms: [5] }, dl);
+ await new Promise(r => setTimeout(r, 0));
+ expect(dl.textContent).toContain('#5');
+ });
+
+ test('skips when no platforms', () => {
+ const dl = document.getElementById('game-meta');
+ renderGamePlatforms({}, dl);
+ expect(dl.children.length).toBe(0);
+ });
+ });
+
+ describe('renderReleaseDates', () => {
+ test('renders release dates with region flags', () => {
+ const dl = document.getElementById('game-meta');
+ renderReleaseDates({
+ release_dates: [
+ { date: '2020-01-01', human: 'Jan 1, 2020', y: 2020, release_region: { region: 'europe' } },
+ { date: null, y: 2021, human: null, release_region: null },
+ { date: null, y: null }, // no date or y β skipped
+ ],
+ }, dl);
+ expect(dl.textContent).toContain('Jan 1, 2020');
+ expect(dl.textContent).toContain('2021');
+ });
+
+ test('skips when no release dates', () => {
+ const dl = document.getElementById('game-meta');
+ renderReleaseDates({}, dl);
+ expect(dl.children.length).toBe(0);
+ });
+
+ test('skips row when all entries lack date and y', () => {
+ const dl = document.getElementById('game-meta');
+ renderReleaseDates({ release_dates: [{ date: null, y: null }] }, dl);
+ expect(dl.children.length).toBe(0);
+ });
+
+ test('renders release date with unknown release_region (empty flag)', () => {
+ const dl = document.getElementById('game-meta');
+ renderReleaseDates({
+ release_dates: [{ date: '2023', y: 2023, human: '2023', release_region: { region: 'unknown_region' } }],
+ }, dl);
+ expect(dl.textContent).toContain('2023');
+ });
+
+ test('renders release date with truthy date but no human or y (empty string fallback)', () => {
+ const dl = document.getElementById('game-meta');
+ renderReleaseDates({
+ release_dates: [{ date: '2020-01-01', human: null, y: null, release_region: null }],
+ }, dl);
+ // Should render without crashing; the div has an empty trimmed textContent
+ expect(dl.children.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('renderCompanies', () => {
+ test('renders developers and publishers', () => {
+ const dl = document.getElementById('game-meta');
+ renderCompanies({
+ involved_companies: [
+ { developer: true, company: { name: 'Dev Studio' } },
+ { developer: false, company: { name: 'Publisher Co' } },
+ { developer: true, company: null }, // filtered out
+ ],
+ }, dl);
+ expect(dl.textContent).toContain('Dev Studio');
+ expect(dl.textContent).toContain('Publisher Co');
+ });
+
+ test('skips when no involved companies', () => {
+ const dl = document.getElementById('game-meta');
+ renderCompanies({}, dl);
+ expect(dl.children.length).toBe(0);
+ });
+
+ test('skips rows when devs or pubs list is empty', () => {
+ const dl = document.getElementById('game-meta');
+ renderCompanies({ involved_companies: [] }, dl);
+ expect(dl.children.length).toBe(0);
+ });
+ });
+
+ describe('renderCollectionsAndFranchises', () => {
+ test('renders collections', () => {
+ const dl = document.getElementById('game-meta');
+ renderCollectionsAndFranchises({
+ collections: [{ id: 1, name: 'Sonic Series' }],
+ }, dl);
+ expect(dl.textContent).toContain('Sonic Series');
+ });
+
+ test('renders collections with numeric id fallback', () => {
+ const dl = document.getElementById('game-meta');
+ renderCollectionsAndFranchises({ collections: [7] }, dl);
+ expect(dl.textContent).toContain('#7');
+ });
+
+ test('renders franchises array', () => {
+ const dl = document.getElementById('game-meta');
+ renderCollectionsAndFranchises({
+ franchises: [{ id: 2, name: 'Mario' }],
+ }, dl);
+ expect(dl.textContent).toContain('Mario');
+ });
+
+ test('renders franchises array with plain numeric franchise id (no name)', () => {
+ const dl = document.getElementById('game-meta');
+ renderCollectionsAndFranchises({ franchises: [5] }, dl);
+ // f.id is undefined so falls back to f (the number), f.name is undefined so badge shows #5
+ expect(dl.textContent).toContain('#5');
+ });
+
+ test('falls back to single franchise object when no franchises array', () => {
+ const dl = document.getElementById('game-meta');
+ renderCollectionsAndFranchises({ franchise: { id: 1, name: 'Halo' } }, dl);
+ expect(dl.textContent).toContain('Halo');
+ });
+
+ test('falls back to franchise with no id (uses empty string)', () => {
+ const dl = document.getElementById('game-meta');
+ renderCollectionsAndFranchises({ franchise: { name: 'No ID Franchise' } }, dl);
+ expect(dl.textContent).toContain('No ID Franchise');
+ });
+
+ test('skips franchise when neither franchises array nor franchise object', () => {
+ const dl = document.getElementById('game-meta');
+ renderCollectionsAndFranchises({
+ franchise: { id: 10, name: 'Zelda' },
+ }, dl);
+ expect(dl.textContent).toContain('Zelda');
+ });
+
+ test('skips franchise when neither franchises array nor franchise object', () => {
+ const dl = document.getElementById('game-meta');
+ renderCollectionsAndFranchises({}, dl);
+ expect(dl.children.length).toBe(0);
+ });
+ });
+
+ describe('renderMultiplayer', () => {
+ test('renders all multiplayer modes', () => {
+ const dl = document.getElementById('game-meta');
+ renderMultiplayer({
+ multiplayer_modes: [{ offlinecoopmax: 2, onlinecoopmax: 4, offlinemax: 4, onlinemax: 8 }],
+ }, dl);
+ expect(dl.textContent).toContain('Co-op');
+ });
+
+ test('skips when no multiplayer modes', () => {
+ const dl = document.getElementById('game-meta');
+ renderMultiplayer({}, dl);
+ expect(dl.children.length).toBe(0);
+ });
+
+ test('skips when parts are empty', () => {
+ const dl = document.getElementById('game-meta');
+ renderMultiplayer({ multiplayer_modes: [{}] }, dl);
+ expect(dl.children.length).toBe(0);
+ });
+ });
+
+ describe('renderScreenshots', () => {
+ test('renders screenshot buttons that fire openImageModal on click', () => {
+ renderScreenshots({
+ screenshots: [
+ { url: '//images.igdb.com/t_thumb/ss1.jpg' },
+ { url: '//images.igdb.com/t_thumb/ss2.jpg' },
+ ],
+ });
+ const section = document.getElementById('game-screenshots-section');
+ expect(section.classList.contains('d-none')).toBe(false);
+ const buttons = document.querySelectorAll('#game-screenshots button');
+ expect(buttons.length).toBe(2);
+ buttons[0].click();
+ expect(globalThis.openImageModal).toHaveBeenCalled();
+ });
+
+ test('skips when no screenshots', () => {
+ renderScreenshots({});
+ expect(document.getElementById('game-screenshots-section').classList.contains('d-none')).toBe(true);
+ });
+ });
+
+ describe('renderVideos', () => {
+ test('renders video embeds with title', () => {
+ renderVideos({ videos: [{ video_id: 'abc123', name: 'Trailer' }] });
+ expect(document.getElementById('game-videos-section').classList.contains('d-none')).toBe(false);
+ expect(document.querySelector('#game-videos iframe').src).toContain('abc123');
+ expect(document.querySelector('#game-videos .card-body')).not.toBeNull();
+ });
+
+ test('renders video with title property when name absent', () => {
+ renderVideos({ videos: [{ video_id: 'xyz', title: 'Gameplay' }] });
+ expect(document.querySelector('#game-videos .card-body').textContent).toContain('Gameplay');
+ });
+
+ test('skips video entries without video_id', () => {
+ renderVideos({ videos: [{ name: 'No ID' }] });
+ expect(document.querySelectorAll('#game-videos iframe').length).toBe(0);
+ });
+
+ test('skips when no videos', () => {
+ renderVideos({});
+ expect(document.getElementById('game-videos-section').classList.contains('d-none')).toBe(true);
+ });
+
+ test('skips card body when no name or title', () => {
+ renderVideos({ videos: [{ video_id: 'nnn' }] });
+ expect(document.querySelector('#game-videos .card-body')).toBeNull();
+ });
+ });
+
+ describe('renderExternalLinks', () => {
+ test('renders external links with source name', () => {
+ renderExternalLinks({
+ external_games: [
+ { url: 'https://store.steampowered.com/app/1', external_game_source: { name: 'Steam' } },
+ ],
+ });
+ expect(document.getElementById('game-external-section').classList.contains('d-none')).toBe(false);
+ expect(document.getElementById('game-external').textContent).toContain('Steam');
+ });
+
+ test('renders link with uid when no url', () => {
+ renderExternalLinks({
+ external_games: [{ uid: '12345', external_game_source: { name: 'GOG' } }],
+ });
+ expect(document.getElementById('game-external').textContent).toContain('GOG');
+ });
+
+ test('uses "External" when no source name', () => {
+ renderExternalLinks({
+ external_games: [{ url: 'https://example.com' }],
+ });
+ expect(document.getElementById('game-external').textContent).toContain('External');
+ });
+
+ test('skips entries with no url and no uid', () => {
+ renderExternalLinks({ external_games: [{}] });
+ expect(document.getElementById('game-external').children.length).toBe(0);
+ });
+
+ test('skips when no external_games', () => {
+ renderExternalLinks({});
+ expect(document.getElementById('game-external-section').classList.contains('d-none')).toBe(true);
+ });
+ });
+
+ describe('renderCharacters', () => {
+ test('renders character cards with mug shot and name', () => {
+ renderCharacters({
+ characters: [{ id: 1, name: 'Hero', mug_shot: { url: '//images.igdb.com/t_thumb/mug.jpg' } }],
+ });
+ expect(document.getElementById('game-characters-section').classList.contains('d-none')).toBe(false);
+ expect(document.querySelector('#game-characters img')).not.toBeNull();
+ expect(document.getElementById('game-characters').textContent).toContain('Hero');
+ });
+
+ test('renders character with mug shot but no name (empty alt)', () => {
+ renderCharacters({ characters: [{ id: 3, mug_shot: { url: '//images.igdb.com/t_thumb/mug.jpg' } }] });
+ expect(document.querySelector('#game-characters img').alt).toBe('');
+ });
+
+ test('renders character placeholder when no mug shot', () => {
+ renderCharacters({ characters: [{ id: 2, name: 'NPC' }] });
+ expect(document.querySelector('#game-characters .material-symbols-outlined')).not.toBeNull();
+ });
+
+ test('renders character with numeric id (no name or mug)', () => {
+ renderCharacters({ characters: [99] });
+ const card = document.querySelector('#game-characters a');
+ expect(card.href).toContain('id=99');
+ });
+
+ test('skips when no characters', () => {
+ renderCharacters({});
+ expect(document.getElementById('game-characters-section').classList.contains('d-none')).toBe(true);
+ });
+ });
+
+ describe('setupGameBanner', () => {
+ test('sets up banner attributes when artworks present', () => {
+ renderGameBadges({}); // ensure intro-header is present
+ setupGameBanner({
+ artworks: [
+ { url: '//images.igdb.com/t_thumb/art1.jpg' },
+ { url: '//images.igdb.com/t_thumb/art2.jpg' },
+ ],
+ });
+ const bigImgs = document.getElementById('header-big-imgs');
+ expect(bigImgs.dataset.numImg).toBe('2');
+ expect(bigImgs.dataset.imgSrc1).toContain('art1');
+ });
+
+ test('hides page heading when no artworks and heading exists', () => {
+ setupGameBanner({});
+ const heading = document.querySelector('.page-heading');
+ expect(heading.style.display).toBe('none');
+ });
+
+ test('does nothing when no artworks and no .page-heading in DOM', () => {
+ const pageHeading = document.querySelector('.page-heading');
+ pageHeading.remove();
+ // Should not throw and should not affect any element
+ expect(() => setupGameBanner({})).not.toThrow();
+ });
+
+ test('calls initGameBanner when intro-header is present', () => {
+ document.querySelector('.intro-header').classList.add('big-img');
+ setupGameBanner({
+ artworks: [{ url: '//images.igdb.com/t_thumb/art.jpg' }],
+ });
+ const bigImgs = document.getElementById('header-big-imgs');
+ expect(bigImgs.dataset.numImg).toBe('1');
+ });
+
+ test('does not throw when artworks present but no intro-header.big-img', () => {
+ // Remove intro-header from DOM entirely
+ const introHeader = document.querySelector('.intro-header');
+ introHeader.remove();
+ expect(() => setupGameBanner({
+ artworks: [{ url: '//images.igdb.com/t_thumb/art.jpg' }],
+ })).not.toThrow();
+ });
+
+ test('skips pageHeading visibility when pageHeading absent (no .page-heading)', () => {
+ // Remove page-heading from DOM
+ const pageHeading = document.querySelector('.page-heading');
+ pageHeading.remove();
+ document.querySelector('.intro-header').classList.add('big-img');
+ expect(() => setupGameBanner({
+ artworks: [{ url: '//images.igdb.com/t_thumb/art.jpg' }],
+ })).not.toThrow();
+ });
+
+ test('skips adding img-desc when it already exists', () => {
+ const introHeader = document.querySelector('.intro-header');
+ const existingDesc = document.createElement('span');
+ existingDesc.className = 'img-desc';
+ introHeader.appendChild(existingDesc);
+ // Should not add a second img-desc
+ setupGameBanner({ artworks: [{ url: '//images.igdb.com/t_thumb/art.jpg' }] });
+ expect(document.querySelectorAll('.intro-header .img-desc').length).toBe(1);
+ });
+
+ test('does not throw when artworks present but no .page-heading anywhere', () => {
+ // Remove header.header-section entirely
+ const header = document.querySelector('header.header-section');
+ header.remove();
+ expect(() => setupGameBanner({ artworks: [{ url: '//img.igdb.com/t.jpg' }] })).not.toThrow();
+ });
+ });
+
+ describe('initGameBanner', () => {
+ test('returns early when numImgs is 0', () => {
+ document.getElementById('header-big-imgs').dataset.numImg = '0';
+ expect(() => initGameBanner()).not.toThrow();
+ });
+
+ test('sets initial image and cycles when multiple artworks', () => {
+ const bigImgs = document.getElementById('header-big-imgs');
+ bigImgs.dataset.numImg = '2';
+ bigImgs.dataset.imgSrc1 = 'https://example.com/art1.jpg';
+ bigImgs.dataset.imgSrc2 = 'https://example.com/art2.jpg';
+ bigImgs.dataset.imgDesc1 = 'null';
+ bigImgs.dataset.imgDesc2 = 'Artwork 2';
+
+ const introHeader = document.querySelector('.intro-header');
+ introHeader.classList.add('big-img');
+ const imgDesc = document.createElement('span');
+ imgDesc.className = 'img-desc';
+ introHeader.appendChild(imgDesc);
+
+ initGameBanner();
+ expect(introHeader.style.backgroundImage).toContain('art1');
+ // desc is "null" so imgDesc should be hidden
+ expect(imgDesc.style.display).toBe('none');
+
+ jest.advanceTimersByTime(7100);
+ expect(introHeader.style.backgroundImage).toBeDefined();
+ // second image has a real desc
+ expect(imgDesc.style.display).toBe('block');
+ });
+
+ test('returns early when no intro-header.big-img found', () => {
+ const bigImgs = document.getElementById('header-big-imgs');
+ bigImgs.dataset.numImg = '1';
+ bigImgs.dataset.imgSrc1 = 'https://example.com/art.jpg';
+ // intro-header does NOT have big-img class β should return early
+ expect(() => initGameBanner()).not.toThrow();
+ });
+
+ test('sets background image without crashing when no .img-desc inside intro-header', () => {
+ const bigImgs = document.getElementById('header-big-imgs');
+ bigImgs.dataset.numImg = '1';
+ bigImgs.dataset.imgSrc1 = 'https://example.com/art.jpg';
+ bigImgs.dataset.imgDesc1 = 'Some description';
+ const introHeader = document.querySelector('.intro-header');
+ introHeader.classList.add('big-img');
+ // Do NOT add .img-desc β covers the if(imgDesc) false branch
+ expect(() => initGameBanner()).not.toThrow();
+ expect(introHeader.style.backgroundImage).toContain('art.jpg');
+ });
+ });
+
+ describe('renderGame', () => {
+ test('renders full game with all fields', async () => {
+ jest.useRealTimers();
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ '1': { name: 'PC' } }),
+ });
+ renderGame({
+ name: 'Full Game',
+ summary: 'A summary',
+ storyline: 'A storyline',
+ url: 'https://igdb.com/game/1',
+ cover: { url: '//images.igdb.com/t_thumb/c.jpg' },
+ genres: [{ name: 'RPG' }],
+ platforms: [1],
+ release_dates: [{ date: '2020', y: 2020, human: '2020' }],
+ involved_companies: [{ developer: true, company: { name: 'Dev' } }],
+ collections: [{ id: 1, name: 'Series' }],
+ multiplayer_modes: [{ onlinemax: 4 }],
+ screenshots: [{ url: '//images.igdb.com/t_thumb/s.jpg' }],
+ videos: [{ video_id: 'v1', name: 'Trailer' }],
+ external_games: [{ url: 'https://steam.com', external_game_source: { name: 'Steam' } }],
+ characters: [{ id: 1, name: 'Hero', mug_shot: { url: '//images.igdb.com/t_thumb/m.jpg' } }],
+ artworks: [{ url: '//images.igdb.com/t_thumb/a.jpg' }],
+ });
+ expect(document.getElementById('game-name').textContent).toBe('Full Game');
+ expect(document.title).toContain('Full Game');
+ expect(document.getElementById('game-summary-section').classList.contains('d-none')).toBe(false);
+ expect(document.getElementById('game-storyline-section').classList.contains('d-none')).toBe(false);
+ expect(document.getElementById('game-igdb-link').classList.contains('d-none')).toBe(false);
+ });
+
+ test('renders minimal game with no optional fields', () => {
+ renderGame({});
+ expect(document.getElementById('game-name').textContent).toBe('Unknown Game');
+ expect(document.title).toContain('Game');
+ });
+
+ test('renders game without header h1 (pageHeaderH1 is null)', () => {
+ // Remove .page-heading h1 from DOM
+ const h1 = document.querySelector('header.header-section .page-heading h1');
+ if (h1) h1.remove();
+ expect(() => renderGame({ name: 'No Header Game' })).not.toThrow();
+ expect(document.getElementById('game-name').textContent).toBe('No Header Game');
+ });
+ });
+
+ describe('DOMContentLoaded integration', () => {
+ test('fires loadItemDetail on DOMContentLoaded', () => {
+ globalThis.history.pushState(null, '', '/');
+ expect(() => document.dispatchEvent(new Event('DOMContentLoaded'))).not.toThrow();
+ });
+ });
+});
diff --git a/tests/item_detail.test.js b/tests/item_detail.test.js
new file mode 100644
index 000000000000..a6608340cc00
--- /dev/null
+++ b/tests/item_detail.test.js
@@ -0,0 +1,342 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import {
+ describe,
+ expect,
+ test,
+ jest,
+ beforeEach,
+ afterEach,
+} from '@jest/globals';
+
+// Set up global dependencies before requiring the module
+globalThis.GAMEDB_CONFIG = { base_path: '/GameDB' };
+
+const {
+ igdbImageUrl,
+ makeBadge,
+ addDlRow,
+ showError,
+ loadItemDetail,
+ renderGameList,
+ renderGameCard,
+} = require('../gh-pages-template/assets/js/item_detail.js');
+
+describe('item_detail.js', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ jest.restoreAllMocks();
+ });
+
+ describe('getQueryParam', () => {
+ test('returns param value when present', () => {
+ globalThis.history.pushState(null, '', '?id=42');
+ const { getQueryParam: gqp } = require('../gh-pages-template/assets/js/item_detail.js');
+ expect(gqp('id')).toBe('42');
+ globalThis.history.pushState(null, '', '/');
+ });
+
+ test('returns null when param is absent', () => {
+ globalThis.history.pushState(null, '', '/');
+ const { getQueryParam: gqp } = require('../gh-pages-template/assets/js/item_detail.js');
+ expect(gqp('id')).toBeNull();
+ });
+ });
+
+ describe('igdbImageUrl', () => {
+ test('replaces t_thumb with the requested size and adds https scheme', () => {
+ const url = '//images.igdb.com/igdb/image/upload/t_thumb/abc123.jpg';
+ const result = igdbImageUrl(url, 't_cover_big_2x');
+ expect(result).toBe('https://images.igdb.com/igdb/image/upload/t_cover_big_2x/abc123.jpg');
+ });
+
+ test('uses default size t_cover_big when no size provided', () => {
+ const url = '//images.igdb.com/igdb/image/upload/t_thumb/abc123.jpg';
+ expect(igdbImageUrl(url)).toContain('t_cover_big');
+ });
+
+ test('returns null for null input', () => {
+ expect(igdbImageUrl(null, 't_cover_big_2x')).toBeNull();
+ });
+
+ test('returns null for undefined input', () => {
+ expect(igdbImageUrl(undefined, 't_cover_big_2x')).toBeNull();
+ });
+
+ test('returns null for empty string', () => {
+ expect(igdbImageUrl('', 't_cover_big_2x')).toBeNull();
+ });
+ });
+
+ describe('makeBadge', () => {
+ test('creates badge with provided class', () => {
+ const badge = makeBadge('Action', 'bg-primary');
+ expect(badge.tagName).toBe('SPAN');
+ expect(badge.className).toContain('badge');
+ expect(badge.className).toContain('bg-primary');
+ expect(badge.textContent).toBe('Action');
+ });
+
+ test('uses bg-secondary as default class', () => {
+ const badge = makeBadge('Default');
+ expect(badge.className).toContain('bg-secondary');
+ });
+ });
+
+ describe('addDlRow', () => {
+ test('appends dt and dd with string value', () => {
+ const dl = document.createElement('dl');
+ addDlRow(dl, 'Developer', 'Test Studio');
+ expect(dl.children.length).toBe(2);
+ expect(dl.children[0].tagName).toBe('DT');
+ expect(dl.children[1].tagName).toBe('DD');
+ expect(dl.children[0].textContent).toBe('Developer');
+ expect(dl.children[1].textContent).toBe('Test Studio');
+ });
+
+ test('appends child element when value is HTMLElement', () => {
+ const dl = document.createElement('dl');
+ const link = document.createElement('a');
+ link.textContent = 'Click';
+ addDlRow(dl, 'Link', link);
+ expect(dl.children[1].querySelector('a')).not.toBeNull();
+ });
+
+ test('appends child when value is DocumentFragment', () => {
+ const dl = document.createElement('dl');
+ const frag = document.createDocumentFragment();
+ const span = document.createElement('span');
+ span.textContent = 'frag content';
+ frag.appendChild(span);
+ addDlRow(dl, 'Frag', frag);
+ expect(dl.children[1].querySelector('span')).not.toBeNull();
+ });
+ });
+
+ describe('showError', () => {
+ test('shows error element and hides content and loading', () => {
+ document.body.innerHTML = `
+
+
+
+ `;
+ showError('Something went wrong');
+ const errEl = document.getElementById('item-error');
+ expect(errEl.textContent).toBe('Something went wrong');
+ expect(errEl.classList.contains('d-none')).toBe(false);
+ expect(document.getElementById('item-content').classList.contains('d-none')).toBe(true);
+ expect(document.getElementById('item-loading').classList.contains('d-none')).toBe(true);
+ });
+
+ test('works when optional elements are absent', () => {
+ document.body.innerHTML = '';
+ // Should not throw
+ expect(() => showError('Missing elements')).not.toThrow();
+ });
+ });
+
+ describe('loadItemDetail', () => {
+ // Helper to flush the promise chain (fetch -> .then -> .then -> .catch)
+ const flushPromises = () => new Promise(r => setTimeout(r, 0));
+
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+ `;
+ });
+
+ test('calls showError when no id param', () => {
+ globalThis.history.pushState(null, '', '/');
+ loadItemDetail('games', jest.fn());
+ const errEl = document.getElementById('item-error');
+ expect(errEl.classList.contains('d-none')).toBe(false);
+ });
+
+ test('fetches data and calls renderFn on success', async () => {
+ globalThis.history.pushState(null, '', '?id=1');
+ const mockData = { name: 'Test Game' };
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve(mockData),
+ });
+ const renderFn = jest.fn();
+ loadItemDetail('games', renderFn);
+ await flushPromises();
+ await flushPromises();
+ expect(renderFn).toHaveBeenCalledWith(mockData);
+ });
+
+ test('calls showError when fetch response is not ok', async () => {
+ globalThis.history.pushState(null, '', '?id=999');
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ });
+ loadItemDetail('games', jest.fn());
+ await flushPromises();
+ await flushPromises();
+ const errEl = document.getElementById('item-error');
+ expect(errEl.classList.contains('d-none')).toBe(false);
+ });
+
+ test('calls showError when fetch rejects', async () => {
+ globalThis.history.pushState(null, '', '?id=1');
+ globalThis.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
+ loadItemDetail('games', jest.fn());
+ await flushPromises();
+ await flushPromises();
+ const errEl = document.getElementById('item-error');
+ expect(errEl.classList.contains('d-none')).toBe(false);
+ });
+
+ test('hides loading and shows content on success', async () => {
+ globalThis.history.pushState(null, '', '?id=1');
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ name: 'Game' }),
+ });
+ loadItemDetail('games', jest.fn());
+ await flushPromises();
+ await flushPromises();
+ expect(document.getElementById('item-loading').classList.contains('d-none')).toBe(true);
+ expect(document.getElementById('item-content').classList.contains('d-none')).toBe(false);
+ });
+
+ test('succeeds when item-loading and item-content elements are absent', async () => {
+ document.body.innerHTML = '';
+ globalThis.history.pushState(null, '', '?id=1');
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ name: 'Game' }),
+ });
+ const renderFn = jest.fn();
+ loadItemDetail('games', renderFn);
+ await flushPromises();
+ await flushPromises();
+ expect(renderFn).toHaveBeenCalled();
+ });
+ });
+
+ describe('renderGameList', () => {
+ test('shows "No games listed." for null', () => {
+ const container = document.createElement('div');
+ renderGameList(container, null);
+ expect(container.textContent).toBe('No games listed.');
+ });
+
+ test('shows "No games listed." for empty array', () => {
+ const container = document.createElement('div');
+ renderGameList(container, []);
+ expect(container.textContent).toBe('No games listed.');
+ });
+
+ test('renders game cards for objects with full data', () => {
+ const container = document.createElement('div');
+ const games = [
+ { id: 1, name: 'Game One', cover: { url: '//images.igdb.com/t_thumb/1.jpg' } },
+ ];
+ renderGameList(container, games);
+ expect(container.querySelector('.game-card')).not.toBeNull();
+ });
+
+ test('renders game cards for raw IDs (fetches data)', async () => {
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ name: 'Fetched Game', cover: { url: '//images.igdb.com/t_thumb/x.jpg' } }),
+ });
+ const container = document.createElement('div');
+ renderGameList(container, [42]);
+ await new Promise(r => setTimeout(r, 0));
+ expect(globalThis.fetch).toHaveBeenCalled();
+ });
+
+ test('handles failed fetch for raw IDs gracefully', async () => {
+ globalThis.fetch = jest.fn().mockRejectedValue(new Error('fail'));
+ const container = document.createElement('div');
+ renderGameList(container, [99]);
+ await new Promise(r => setTimeout(r, 0));
+ // Should not throw; container still has a row
+ expect(container.querySelector('.row')).not.toBeNull();
+ });
+
+ test('renders game without cover (fetch returns not-ok)', async () => {
+ globalThis.fetch = jest.fn().mockResolvedValue({ ok: false });
+ const container = document.createElement('div');
+ renderGameList(container, [7]);
+ await new Promise(r => setTimeout(r, 0));
+ expect(container.querySelector('.row')).not.toBeNull();
+ });
+
+ test('renders game object with cover but no URL (null cover url)', () => {
+ const container = document.createElement('div');
+ renderGameList(container, [{ id: 10, name: 'No URL Game', cover: {} }]);
+ expect(container.querySelector('.game-card')).not.toBeNull();
+ });
+
+ test('renders fetched game with no cover (cover is null in response)', async () => {
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ name: 'No Cover Game', cover: null }),
+ });
+ const container = document.createElement('div');
+ renderGameList(container, [15]);
+ // Flush all microtask ticks for the full promise chain
+ for (let i = 0; i < 10; i++) await Promise.resolve();
+ expect(container.querySelector('.row')).not.toBeNull();
+ });
+ });
+
+ describe('renderGameCard', () => {
+ test('renders card with cover image', () => {
+ const row = document.createElement('div');
+ renderGameCard(row, 1, 'My Game', 'https://images.igdb.com/cover.jpg');
+ const img = row.querySelector('img');
+ expect(img).not.toBeNull();
+ expect(img.src).toContain('cover.jpg');
+ });
+
+ test('renders placeholder when no cover URL', () => {
+ const row = document.createElement('div');
+ renderGameCard(row, 2, 'No Cover', null);
+ expect(row.querySelector('img')).toBeNull();
+ expect(row.querySelector('.material-symbols-outlined')).not.toBeNull();
+ });
+
+ test('renders game name', () => {
+ const row = document.createElement('div');
+ renderGameCard(row, 3, 'Named Game', null);
+ expect(row.textContent).toContain('Named Game');
+ });
+
+ test('renders game ID when no name provided', () => {
+ const row = document.createElement('div');
+ renderGameCard(row, 55, null, null);
+ expect(row.textContent).toContain('Game #55');
+ });
+
+ test('renders cover img with empty alt when cover present but no name', () => {
+ const row = document.createElement('div');
+ renderGameCard(row, 10, null, 'https://images.igdb.com/cover.jpg');
+ const img = row.querySelector('img');
+ expect(img).not.toBeNull();
+ expect(img.alt).toBe('');
+ });
+ });
+
+ describe('base_path defaults to /GameDB when GAMEDB_CONFIG has no base_path', () => {
+ test('getQueryParam still works without GAMEDB_CONFIG.base_path', () => {
+ // Reset modules and require without base_path to exercise the else branch
+ jest.resetModules();
+ globalThis.GAMEDB_CONFIG = {};
+ const freshModule = require('../gh-pages-template/assets/js/item_detail.js');
+ globalThis.history.pushState(null, '', '?id=5');
+ expect(freshModule.getQueryParam('id')).toBe('5');
+ // Restore
+ globalThis.GAMEDB_CONFIG = { base_path: '/GameDB' };
+ });
+ });
+});
diff --git a/tests/item_loader.test.js b/tests/item_loader.test.js
new file mode 100644
index 000000000000..c67ffabc6a0b
--- /dev/null
+++ b/tests/item_loader.test.js
@@ -0,0 +1,499 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import {
+ describe,
+ test,
+ expect,
+ jest,
+ beforeEach,
+ afterEach,
+} from '@jest/globals';
+
+globalThis.GAMEDB_CONFIG = { base_path: '/GameDB' };
+globalThis.rankingSorter = () => () => 0;
+
+// item_loader.js reads platforms_container at module load time via getElementById
+// so we need the element in the DOM before require()
+document.body.innerHTML = `
+
+
+
+`;
+
+// Mock jQuery β fire ready callback synchronously, and fire ajax success immediately
+globalThis.$ = function() {
+ return { ready: (fn) => fn() };
+};
+globalThis.$.ajaxSetup = () => {};
+globalThis.$.ajax = function(opts) {
+ if (opts?.success) {
+ // Return mock data appropriate to the URL
+ if (opts?.url?.includes('cross-reference')) {
+ opts.success({});
+ } else if (opts?.url?.includes('all.json')) {
+ opts.success({
+ '1': {
+ id: 1, name: 'PC', url: 'https://igdb.com', summary: 'Personal computer.',
+ screenscraper_id: null, screenscraper_region: null,
+ },
+ });
+ }
+ }
+};
+globalThis.jQuery = globalThis.$;
+
+const {
+ splitString,
+ fetchGameData,
+ createGameCard,
+ renderSearchResults,
+ addMoreResultsNote,
+ createPlatformBanner,
+ createPlatformCardBody,
+ getPlatformVersion,
+ addVersionMetadataToFooter,
+ addReleaseDatesToFooter,
+ addMetadataItemToFooter,
+ processPlatformsData,
+ createPlatformCardElement,
+ run_search,
+} = require('../gh-pages-template/assets/js/item_loader.js');
+
+describe('item_loader.js', () => {
+ beforeEach(() => {
+ // Reset just the dynamic containers, not the full body (platforms-container needed at load time)
+ const sc = document.getElementById('search-container');
+ if (sc) sc.innerHTML = '';
+ const st = document.getElementById('search_term');
+ if (st) st.value = '';
+ const pc = document.getElementById('platforms-container');
+ if (pc) pc.innerHTML = '';
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('splitString', () => {
+ test('returns string as-is when short', () => {
+ expect(splitString('hello')).toEqual(['hello']);
+ });
+
+ test('returns [undefined] for undefined', () => {
+ expect(splitString(undefined)).toEqual([undefined]);
+ });
+
+ test('splits at word boundary when longer than 200 chars', () => {
+ const long = 'word '.repeat(50); // 250 chars
+ const result = splitString(long);
+ expect(result.length).toBe(2);
+ expect(result[0].length).toBeLessThanOrEqual(200);
+ expect(result[1]).toBe(long);
+ });
+ });
+
+ describe('fetchGameData', () => {
+ test('fetches and returns game data on success', async () => {
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ name: 'Halo' }),
+ });
+ const result = await fetchGameData(1, 'Halo');
+ expect(result).toEqual({ id: 1, game: { name: 'Halo' } });
+ });
+
+ test('returns fallback with gameName when response is not ok', async () => {
+ globalThis.fetch = jest.fn().mockResolvedValue({ ok: false });
+ const result = await fetchGameData(2, 'Fallback Game');
+ expect(result).toEqual({ id: 2, game: { name: 'Fallback Game' } });
+ });
+
+ test('returns fallback with gameName when fetch rejects', async () => {
+ globalThis.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
+ const result = await fetchGameData(3, 'Error Game');
+ expect(result).toEqual({ id: 3, game: { name: 'Error Game' } });
+ });
+ });
+
+ describe('createGameCard', () => {
+ test('renders card with cover image', () => {
+ const col = createGameCard(1, {
+ name: 'Test Game',
+ cover: { url: '//images.igdb.com/igdb/image/upload/t_thumb/cover.jpg' },
+ });
+ expect(col.querySelector('img')).not.toBeNull();
+ expect(col.querySelector('img').src).toContain('t_cover_big_2x');
+ });
+
+ test('renders placeholder when no cover', () => {
+ const col = createGameCard(2, { name: 'No Cover' });
+ expect(col.querySelector('img')).toBeNull();
+ expect(col.querySelector('.material-symbols-outlined')).not.toBeNull();
+ });
+
+ test('renders platforms with release years when allPlatforms provided', () => {
+ const allPlatforms = { '1': { name: 'PC' }, '2': { name: 'PS5' }, '3': { name: 'Xbox' }, '4': { name: 'Switch' } };
+ const col = createGameCard(3, {
+ name: 'Multi Platform',
+ platforms: [1, 2, 3, 4],
+ release_dates: [
+ { platform: 1, y: 2020 },
+ { platform: 2, y: 2021 },
+ { platform: 1, y: 2019 }, // earlier year replaces
+ { platform: 2, y: 2022 }, // later year does NOT replace (covers false branch of rd.y < platformYears)
+ ],
+ }, allPlatforms);
+ // Should show first 3 platforms + "+1 more"
+ const badges = col.querySelectorAll('.badge');
+ expect(badges.length).toBe(4); // 3 platforms + "+1 more"
+ expect(col.textContent).toContain('+1 more');
+ });
+
+ test('renders platforms without release year when no release dates', () => {
+ const allPlatforms = { '1': { name: 'PC' } };
+ const col = createGameCard(4, {
+ name: 'PC Game',
+ platforms: [1],
+ }, allPlatforms);
+ expect(col.textContent).toContain('PC');
+ });
+
+ test('uses platform ID as name when platform not in allPlatforms', () => {
+ const col = createGameCard(5, {
+ name: 'Unknown Platform Game',
+ platforms: [999],
+ }, {});
+ expect(col.textContent).toContain('999');
+ });
+ });
+
+ describe('renderSearchResults', () => {
+ test('appends game cards to row', () => {
+ const row = document.createElement('div');
+ renderSearchResults(
+ [{ id: 1, game: { name: 'Game A' } }, { id: 2, game: { name: 'Game B' } }],
+ row,
+ null,
+ );
+ expect(row.children.length).toBe(2);
+ });
+ });
+
+ describe('addMoreResultsNote', () => {
+ test('appends note when total exceeds shown', () => {
+ const container = document.createElement('div');
+ addMoreResultsNote(container, 100, 60);
+ expect(container.children.length).toBe(1);
+ expect(container.textContent).toContain('60');
+ });
+
+ test('does nothing when total equals shown', () => {
+ const container = document.createElement('div');
+ addMoreResultsNote(container, 60, 60);
+ expect(container.children.length).toBe(0);
+ });
+ });
+
+ describe('createPlatformBanner', () => {
+ test('creates banner with screenscraper image', () => {
+ const banner = createPlatformBanner({ screenscraper_id: 123, screenscraper_region: 'us', id: 1 }, '/test');
+ expect(banner.src).toContain('screenscraper.fr');
+ expect(banner.src).toContain('123');
+ });
+
+ test('creates banner with IGDB logo (t_thumb replaced)', () => {
+ const banner = createPlatformBanner({
+ screenscraper_id: null,
+ screenscraper_region: null,
+ platform_logo: { url: '//images.igdb.com/igdb/image/upload/t_thumb/logo.jpg' },
+ id: 1,
+ }, '/test');
+ expect(banner.src).toContain('t_720p');
+ });
+
+ test('creates placeholder when no images', () => {
+ const banner = createPlatformBanner({ screenscraper_id: null, screenscraper_region: null, id: 1 }, '/test');
+ expect(banner.src).toContain('no-logo.png');
+ });
+ });
+
+ describe('createPlatformCardBody', () => {
+ test('creates card body with title and link', () => {
+ const body = createPlatformCardBody({ id: 1, name: 'PS5', url: 'https://igdb.com' }, '/test');
+ expect(body.textContent).toContain('PS5');
+ expect(body.textContent).toContain('View on IGDB');
+ expect(body.querySelector('a').href).toContain('id=1');
+ });
+
+ test('includes game count (plural)', () => {
+ const body = createPlatformCardBody({ id: 1, name: 'PS5', url: '', game_count: 42 }, '/test');
+ expect(body.textContent).toContain('42 games');
+ });
+
+ test('includes game count (singular)', () => {
+ const body = createPlatformCardBody({ id: 1, name: 'PS5', url: '', game_count: 1 }, '/test');
+ expect(body.textContent).toContain('1 game');
+ expect(body.textContent).not.toContain('1 games');
+ });
+ });
+
+ describe('getPlatformVersion', () => {
+ test('returns null when no versions', () => {
+ expect(getPlatformVersion({ versions: [] })).toBeNull();
+ expect(getPlatformVersion({ category: 1 })).toBeNull();
+ });
+
+ test('returns last version for OS (category 4)', () => {
+ const p = { category: 4, versions: [{ name: 'v1' }, { name: 'v3' }] };
+ expect(getPlatformVersion(p).name).toBe('v3');
+ });
+
+ test('returns first version for Console (category 1)', () => {
+ const p = { category: 1, versions: [{ name: 'Original' }, { name: 'Slim' }] };
+ expect(getPlatformVersion(p).name).toBe('Original');
+ });
+ });
+
+ describe('addVersionMetadataToFooter', () => {
+ const regionMap = { north_america: { code: 'πΊπΈ', size: 'fs-2' } };
+ const iconMap = {
+ cpu: 'memory',
+ platform_version_release_dates: null,
+ summary: null,
+ };
+
+ test('adds cpu metadata item', () => {
+ const para = document.createElement('p');
+ const footer = document.createElement('div');
+ addVersionMetadataToFooter({ cpu: 'Intel 486' }, para, footer, {}, regionMap, iconMap);
+ expect(footer.textContent).toContain('Intel 486');
+ });
+
+ test('sets summary on card_paragraph when platform has no summary', () => {
+ const para = document.createElement('p');
+ const footer = document.createElement('div');
+ addVersionMetadataToFooter({ summary: 'Version summary' }, para, footer, {}, regionMap, iconMap);
+ expect(para.textContent).toContain('Version summary');
+ });
+
+ test('skips summary when platform already has summary', () => {
+ const para = document.createElement('p');
+ const footer = document.createElement('div');
+ addVersionMetadataToFooter(
+ { summary: 'Version summary' },
+ para, footer,
+ { summary: 'Platform summary' }, // platform has its own summary
+ regionMap, iconMap,
+ );
+ expect(para.textContent).toBe('');
+ });
+
+ test('adds release dates to footer', () => {
+ const para = document.createElement('p');
+ const footer = document.createElement('div');
+ addVersionMetadataToFooter(
+ { platform_version_release_dates: [{ release_region: { region: 'north_america' }, human: 'Nov 2001' }] },
+ para, footer, {}, regionMap, iconMap,
+ );
+ expect(footer.textContent).toContain('Nov 2001');
+ });
+
+ test('skips keys not present in version', () => {
+ const para = document.createElement('p');
+ const footer = document.createElement('div');
+ addVersionMetadataToFooter({}, para, footer, {}, regionMap, iconMap);
+ expect(footer.children.length).toBe(0);
+ });
+
+ test('skips metadata key when it has null icon and is not a special key', () => {
+ const para = document.createElement('p');
+ const footer = document.createElement('div');
+ // 'media' key has a value but null icon in the map passed
+ const customIconMap = { media: null, platform_version_release_dates: null, summary: null };
+ addVersionMetadataToFooter({ media: 'Blu-ray' }, para, footer, {}, regionMap, customIconMap);
+ // Should not add to footer since icon is null and not a special key
+ expect(footer.children.length).toBe(0);
+ });
+ });
+
+ describe('addReleaseDatesToFooter', () => {
+ test('adds release dates with regions', () => {
+ const footer = document.createElement('div');
+ addReleaseDatesToFooter(
+ [
+ { release_region: { region: 'north_america' }, human: 'Nov 15, 2013' },
+ { release_region: { region: 'unknown_region' }, y: 2014 },
+ { release_region: null, human: null, y: 2015 },
+ ],
+ footer,
+ { north_america: { code: 'πΊπΈ', size: 'fs-2' } },
+ );
+ expect(footer.textContent).toContain('Nov 15, 2013');
+ expect(footer.textContent).toContain('2015');
+ });
+
+ test('renders empty string when release date has no human or y', () => {
+ const footer = document.createElement('div');
+ addReleaseDatesToFooter(
+ [{ release_region: null, human: null, y: null }],
+ footer,
+ {},
+ );
+ // Should render the div without crashing
+ expect(footer.querySelector('div')).not.toBeNull();
+ });
+ });
+
+ describe('addMetadataItemToFooter', () => {
+ test('adds metadata item with icon', () => {
+ const footer = document.createElement('div');
+ addMetadataItemToFooter('cpu', 'Intel Core i7', footer, 'memory');
+ expect(footer.textContent).toContain('Intel Core i7');
+ expect(footer.querySelector('.material-symbols-outlined').textContent).toBe('memory');
+ });
+ });
+
+ describe('processPlatformsData', () => {
+ test('maps screenscraper IDs from xref', () => {
+ const result = processPlatformsData(
+ { ps5: { id: 167, name: 'PS5' }, xbox: { id: 169, name: 'Xbox' } },
+ { ps5: { ids: { igdb: 167, screenscraper: 1000 }, variables: { screenscraper: { region: 'us' } } } },
+ );
+ expect(result[0].screenscraper_id).toBe(1000);
+ expect(result[0].screenscraper_region).toBe('us');
+ expect(result[1].screenscraper_id).toBeNull();
+ });
+ });
+
+ describe('createPlatformCardElement', () => {
+ const regionMap = { north_america: { code: 'πΊπΈ', size: 'fs-2' } };
+ const iconMap = { cpu: 'memory', platform_version_release_dates: null, summary: null };
+
+ test('creates full card with version metadata', () => {
+ const platform = {
+ id: 1,
+ name: 'PS5',
+ url: 'https://igdb.com',
+ summary: 'A great console.',
+ category: 1,
+ screenscraper_id: null,
+ screenscraper_region: null,
+ versions: [{ name: 'Launch', cpu: 'AMD Zen 2' }],
+ };
+ const col = createPlatformCardElement(platform, regionMap, iconMap, '/test');
+ expect(col.textContent).toContain('PS5');
+ expect(col.textContent).toContain('AMD Zen 2');
+ });
+
+ test('creates card without version when no versions', () => {
+ const platform = {
+ id: 2, name: 'Switch', url: '', summary: 'Nintendo Switch.',
+ screenscraper_id: 555, screenscraper_region: 'us',
+ platform_logo: { url: '//images.igdb.com/t_thumb/sw.jpg' },
+ };
+ const col = createPlatformCardElement(platform, regionMap, iconMap, '/test');
+ expect(col.textContent).toContain('Switch');
+ });
+ });
+
+ describe('run_search', () => {
+ const flushPromises = () => new Promise(r => setTimeout(r, 0));
+
+ test('does nothing when search term is empty', () => {
+ document.getElementById('search_term').value = ' ';
+ run_search();
+ expect(document.getElementById('search-container').innerHTML).toBe('');
+ });
+
+ test('shows loading, then renders results on success', async () => {
+ document.getElementById('search_term').value = 'halo';
+ globalThis.fetch = jest.fn()
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({
+ '1': { name: 'Halo: CE' },
+ '2': { name: 'Halo 2' },
+ }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({}), // platforms
+ })
+ .mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ name: 'Halo: CE', cover: null }),
+ });
+ run_search();
+ await flushPromises();
+ await flushPromises();
+ await flushPromises();
+ const container = document.getElementById('search-container');
+ expect(container.textContent).toContain('Halo');
+ });
+
+ test('shows no results message when no matches', async () => {
+ document.getElementById('search_term').value = 'zzznomatch';
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ '1': { name: 'Unrelated' } }),
+ });
+ run_search();
+ await flushPromises();
+ await flushPromises();
+ expect(document.getElementById('search-container').textContent).toContain('No games found');
+ });
+
+ test('shows error when bucket fetch fails', async () => {
+ document.getElementById('search_term').value = 'fail';
+ globalThis.fetch = jest.fn().mockResolvedValue({ ok: false });
+ run_search();
+ await flushPromises();
+ await flushPromises();
+ expect(document.getElementById('search-container').textContent).toContain('Search failed');
+ });
+
+ test('shows error when fetch rejects', async () => {
+ document.getElementById('search_term').value = 'crash';
+ globalThis.fetch = jest.fn().mockRejectedValue(new Error('Network down'));
+ run_search();
+ await flushPromises();
+ await flushPromises();
+ expect(document.getElementById('search-container').textContent).toContain('Search failed');
+ });
+
+ test('uses @ bucket when search term has no alphanumeric characters', async () => {
+ document.getElementById('search_term').value = '!!!';
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({}),
+ });
+ run_search();
+ await flushPromises();
+ await flushPromises();
+ const fetchUrl = globalThis.fetch.mock.calls[0][0];
+ expect(fetchUrl).toContain('%40'); // @ URL-encoded
+ });
+
+ test('renders results even when platform fetch fails', async () => {
+ document.getElementById('search_term').value = 'mario';
+ globalThis.fetch = jest.fn()
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ '10': { name: 'Mario' } }),
+ })
+ .mockRejectedValueOnce(new Error('Platform fetch failed'))
+ .mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ name: 'Mario' }),
+ });
+ run_search();
+ await flushPromises();
+ await flushPromises();
+ await flushPromises();
+ const container = document.getElementById('search-container');
+ expect(container.textContent).toContain('Mario');
+ });
+ });
+});
diff --git a/tests/platform_detail.test.js b/tests/platform_detail.test.js
new file mode 100644
index 000000000000..1593ddc1e908
--- /dev/null
+++ b/tests/platform_detail.test.js
@@ -0,0 +1,314 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import {
+ describe,
+ test,
+ expect,
+ beforeEach,
+ afterEach,
+} from '@jest/globals';
+
+globalThis.GAMEDB_CONFIG = { base_path: '/GameDB' };
+
+const itemDetail = require('../gh-pages-template/assets/js/item_detail.js');
+globalThis.igdbImageUrl = itemDetail.igdbImageUrl;
+globalThis.makeBadge = itemDetail.makeBadge;
+globalThis.addDlRow = itemDetail.addDlRow;
+globalThis.loadItemDetail = itemDetail.loadItemDetail;
+globalThis.renderGameList = itemDetail.renderGameList;
+globalThis.base_path = '/GameDB';
+globalThis.base_url = 'http://localhost/GameDB';
+
+const {
+ getRegionFlag,
+ renderPlatformLogo,
+ renderPlatformBadges,
+ renderPlatformMetadata,
+ createVersionAccordionItem,
+ populateVersionBody,
+ renderPlatform,
+} = require('../gh-pages-template/assets/js/platform_detail.js');
+
+const baseDom = `
+
+
+
+
+
+
+
+
+
+
+`;
+
+describe('platform_detail.js', () => {
+ beforeEach(() => {
+ document.body.innerHTML = baseDom;
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ describe('getRegionFlag', () => {
+ test('returns correct flags for all known regions', () => {
+ expect(getRegionFlag('europe')).toBe('πͺπΊ');
+ expect(getRegionFlag('north_america')).toBe('πΊπΈ');
+ expect(getRegionFlag('australia')).toBe('π¦πΊ');
+ expect(getRegionFlag('new_zealand')).toBe('π³πΏ');
+ expect(getRegionFlag('japan')).toBe('π―π΅');
+ expect(getRegionFlag('china')).toBe('π¨π³');
+ expect(getRegionFlag('asia')).toBe('π');
+ expect(getRegionFlag('worldwide')).toBe('π');
+ expect(getRegionFlag('korea')).toBe('π°π·');
+ expect(getRegionFlag('brazil')).toBe('π§π·');
+ });
+
+ test('returns default globe for unknown region', () => {
+ expect(getRegionFlag('unknown')).toBe('π');
+ expect(getRegionFlag('')).toBe('π');
+ });
+ });
+
+ describe('renderPlatformLogo', () => {
+ test('renders logo when URL provided', () => {
+ renderPlatformLogo({
+ name: 'PlayStation 5',
+ platform_logo: { url: '//images.igdb.com/igdb/image/upload/t_logo_med_2x/ps5.jpg' },
+ });
+ const logo = document.getElementById('platform-logo');
+ expect(logo.style.display).toBe('');
+ expect(logo.alt).toBe('PlayStation 5');
+ });
+
+ test('renders logo with empty alt when no name', () => {
+ renderPlatformLogo({
+ platform_logo: { url: '//images.igdb.com/igdb/image/upload/t_logo_med_2x/ps5.jpg' },
+ });
+ expect(document.getElementById('platform-logo').alt).toBe('');
+ });
+
+ test('shows placeholder when no logo URL', () => {
+ renderPlatformLogo({ name: 'Test Platform' });
+ expect(document.getElementById('platform-logo').style.display).toBe('none');
+ });
+
+ test('shows placeholder when platform_logo present but no url', () => {
+ renderPlatformLogo({ name: 'No URL', platform_logo: {} });
+ expect(document.getElementById('platform-logo').style.display).toBe('none');
+ });
+ });
+
+ describe('renderPlatformBadges', () => {
+ test('renders category and generation badges', () => {
+ renderPlatformBadges({ category: 1, generation: 9 });
+ const badges = document.getElementById('platform-badges');
+ expect(badges.children.length).toBe(2);
+ });
+
+ test('renders only category when no generation', () => {
+ renderPlatformBadges({ category: 2 });
+ expect(document.getElementById('platform-badges').children.length).toBe(1);
+ });
+
+ test('renders only generation when no category', () => {
+ renderPlatformBadges({ generation: 8 });
+ expect(document.getElementById('platform-badges').children.length).toBe(1);
+ });
+
+ test('renders nothing when no category or generation', () => {
+ renderPlatformBadges({});
+ expect(document.getElementById('platform-badges').children.length).toBe(0);
+ });
+
+ test('renders fallback text for unknown category number', () => {
+ renderPlatformBadges({ category: 999 });
+ expect(document.getElementById('platform-badges').textContent).toContain('Category 999');
+ });
+ });
+
+ describe('renderPlatformMetadata', () => {
+ test('renders game count (plural)', () => {
+ renderPlatformMetadata({ games: new Array(42).fill({}) });
+ expect(document.getElementById('platform-meta').textContent).toContain('42 games');
+ });
+
+ test('renders game count (singular)', () => {
+ renderPlatformMetadata({ games: [{}] });
+ expect(document.getElementById('platform-meta').textContent).toContain('1 game');
+ });
+
+ test('renders abbreviation', () => {
+ renderPlatformMetadata({ abbreviation: 'PS5' });
+ expect(document.getElementById('platform-meta').textContent).toContain('PS5');
+ });
+
+ test('renders alternative name', () => {
+ renderPlatformMetadata({ alternative_name: 'PlayStation 5 Digital Edition' });
+ expect(document.getElementById('platform-meta').textContent).toContain('Digital Edition');
+ });
+
+ test('renders platform type', () => {
+ renderPlatformMetadata({ platform_type: { name: 'Console' } });
+ expect(document.getElementById('platform-meta').textContent).toContain('Console');
+ });
+
+ test('renders nothing when no data', () => {
+ renderPlatformMetadata({});
+ expect(document.getElementById('platform-meta').children.length).toBe(0);
+ });
+ });
+
+ describe('createVersionAccordionItem', () => {
+ test('creates accordion item for first version (expanded)', () => {
+ const item = createVersionAccordionItem({ name: 'Original' }, 0);
+ expect(item.className).toContain('accordion-item');
+ const button = item.querySelector('button');
+ expect(button.getAttribute('aria-expanded')).toBe('true');
+ expect(button.textContent).toContain('Original');
+ });
+
+ test('creates collapsed accordion item for subsequent versions', () => {
+ const item = createVersionAccordionItem({ name: 'Slim' }, 1);
+ const button = item.querySelector('button');
+ expect(button.className).toContain('collapsed');
+ expect(button.getAttribute('aria-expanded')).toBe('false');
+ });
+
+ test('uses fallback name when version has no name', () => {
+ const item = createVersionAccordionItem({}, 0);
+ const button = item.querySelector('button');
+ expect(button.textContent).toContain('Version 1');
+ });
+
+ test('adds logo img when version has platform_logo url', () => {
+ const item = createVersionAccordionItem({
+ name: 'Pro',
+ platform_logo: { url: '//images.igdb.com/igdb/image/upload/t_thumb/logo.jpg' },
+ }, 0);
+ expect(item.querySelector('button img')).not.toBeNull();
+ });
+ });
+
+ describe('populateVersionBody', () => {
+ test('renders summary paragraph', () => {
+ const body = document.createElement('div');
+ populateVersionBody(body, { summary: 'A hardware version.' });
+ expect(body.querySelector('p').textContent).toBe('A hardware version.');
+ });
+
+ test('renders release dates list', () => {
+ const body = document.createElement('div');
+ populateVersionBody(body, {
+ platform_version_release_dates: [
+ { release_region: { region: 'north_america' }, human: 'Sep 9, 1999' },
+ { release_region: null, human: '2000' },
+ ],
+ });
+ expect(body.querySelector('ul')).not.toBeNull();
+ expect(body.textContent).toContain('Sep 9, 1999');
+ });
+
+ test('renders release date with null region (uses globe fallback)', () => {
+ const body = document.createElement('div');
+ populateVersionBody(body, {
+ platform_version_release_dates: [
+ { release_region: null, human: 'Nov 2001' },
+ ],
+ });
+ expect(body.textContent).toContain('π');
+ expect(body.textContent).toContain('Nov 2001');
+ });
+
+ test('renders release date using y fallback when no human', () => {
+ const body = document.createElement('div');
+ populateVersionBody(body, {
+ platform_version_release_dates: [
+ { release_region: null, human: null, y: 1999 },
+ ],
+ });
+ expect(body.textContent).toContain('1999');
+ });
+
+ test('renders release date with empty string when no human or y', () => {
+ const body = document.createElement('div');
+ populateVersionBody(body, {
+ platform_version_release_dates: [
+ { release_region: null, human: null, y: null },
+ ],
+ });
+ // Should render without crashing
+ expect(body.querySelector('ul')).not.toBeNull();
+ });
+
+ test('renders spec table when specs present', () => {
+ const body = document.createElement('div');
+ populateVersionBody(body, { cpu: 'Intel 286', os: 'DOS' });
+ expect(body.querySelector('dl')).not.toBeNull();
+ expect(body.textContent).toContain('Intel 286');
+ });
+
+ test('renders IGDB link when url present', () => {
+ const body = document.createElement('div');
+ populateVersionBody(body, { url: 'https://igdb.com/platform_versions/1' });
+ const link = body.querySelector('a');
+ expect(link).not.toBeNull();
+ expect(link.href).toContain('igdb.com');
+ });
+
+ test('renders nothing extra when version is empty', () => {
+ const body = document.createElement('div');
+ populateVersionBody(body, {});
+ expect(body.children.length).toBe(0);
+ });
+ });
+
+ describe('renderPlatform', () => {
+ test('renders full platform with all fields', () => {
+ renderPlatform({
+ name: 'Super Nintendo',
+ summary: 'A classic console.',
+ url: 'https://igdb.com/platforms/snes',
+ platform_logo: { url: '//images.igdb.com/igdb/image/upload/t_logo_med_2x/snes.jpg' },
+ category: 1,
+ generation: 4,
+ games: [{ id: 1, name: 'Super Mario World', cover: { url: '//img.igdb.com/t_thumb/c.jpg' } }],
+ versions: [
+ { name: 'Original', summary: 'The original SNES.' },
+ { name: 'Mini', summary: 'The SNES Mini.' },
+ ],
+ });
+ expect(document.getElementById('platform-name').textContent).toBe('Super Nintendo');
+ expect(document.title).toContain('Super Nintendo');
+ expect(document.getElementById('platform-summary-section').classList.contains('d-none')).toBe(false);
+ expect(document.getElementById('platform-versions-section').classList.contains('d-none')).toBe(false);
+ expect(document.getElementById('platform-games-section').classList.contains('d-none')).toBe(false);
+ expect(document.getElementById('platform-igdb-link').classList.contains('d-none')).toBe(false);
+ });
+
+ test('renders minimal platform with no optional fields', () => {
+ renderPlatform({});
+ expect(document.getElementById('platform-name').textContent).toBe('Unknown Platform');
+ expect(document.getElementById('platform-summary-section').classList.contains('d-none')).toBe(true);
+ expect(document.getElementById('platform-versions-section').classList.contains('d-none')).toBe(true);
+ expect(document.getElementById('platform-games-section').classList.contains('d-none')).toBe(true);
+ expect(document.getElementById('platform-igdb-link').classList.contains('d-none')).toBe(true);
+ });
+ });
+
+ describe('DOMContentLoaded integration', () => {
+ test('fires loadItemDetail on DOMContentLoaded', () => {
+ globalThis.history.pushState(null, '', '/');
+ expect(() => document.dispatchEvent(new Event('DOMContentLoaded'))).not.toThrow();
+ });
+ });
+});
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/unit/test_platforms.py b/tests/unit/test_platforms.py
new file mode 100644
index 000000000000..6e2820a5a714
--- /dev/null
+++ b/tests/unit/test_platforms.py
@@ -0,0 +1,82 @@
+# lib imports
+import pytest
+
+# local imports
+import src.platforms as platforms_module
+
+# Screenscraper IDs that are intentionally shared between multiple IGDB platforms
+# because screenscraper treats them as the same platform entry.
+ALLOWED_DUPLICATE_SCREENSCRAPER_IDS = {
+ 3, # NES and Family Computer share the same screenscraper platform
+ 4, # SNES and Super Famicom share the same screenscraper platform
+ 144, # TRS-80 and TRS-80 Color Computer share the same screenscraper platform
+}
+
+
+def test_cross_reference_is_list():
+ assert isinstance(platforms_module.cross_reference, list)
+
+
+def test_cross_reference_is_not_empty():
+ assert len(platforms_module.cross_reference) > 0
+
+
+@pytest.mark.parametrize('entry', platforms_module.cross_reference)
+def test_cross_reference_entry_has_required_keys(entry):
+ assert 'ids' in entry, f"Entry missing 'ids': {entry}"
+ assert 'name' in entry, f"Entry missing 'name': {entry}"
+ assert 'variables' in entry, f"Entry missing 'variables': {entry}"
+
+
+@pytest.mark.parametrize('entry', platforms_module.cross_reference)
+def test_cross_reference_entry_ids_has_igdb(entry):
+ assert 'igdb' in entry['ids'], f"Entry 'ids' missing 'igdb': {entry}"
+ assert isinstance(entry['ids']['igdb'], int), f"'igdb' id must be int: {entry}"
+
+
+@pytest.mark.parametrize('entry', platforms_module.cross_reference)
+def test_cross_reference_entry_ids_has_screenscraper(entry):
+ # screenscraper id may be None for platforms not on screenscraper, but key must exist
+ assert 'screenscraper' in entry['ids'], f"Entry 'ids' missing 'screenscraper': {entry}"
+
+
+@pytest.mark.parametrize('entry', platforms_module.cross_reference)
+def test_cross_reference_entry_name_is_non_empty_string(entry):
+ assert isinstance(entry['name'], str), f"'name' must be str: {entry}"
+ assert entry['name'].strip() != '', f"'name' must not be blank: {entry}"
+
+
+@pytest.mark.parametrize('entry', platforms_module.cross_reference)
+def test_cross_reference_entry_variables_has_screenscraper(entry):
+ assert 'screenscraper' in entry['variables'], f"'variables' missing 'screenscraper': {entry}"
+
+
+@pytest.mark.parametrize('entry', platforms_module.cross_reference)
+def test_cross_reference_entry_screenscraper_variables_has_region(entry):
+ assert 'region' in entry['variables']['screenscraper'], (
+ f"'variables.screenscraper' missing 'region': {entry}"
+ )
+
+
+def test_cross_reference_igdb_ids_unique():
+ igdb_ids = [e['ids']['igdb'] for e in platforms_module.cross_reference]
+ assert len(igdb_ids) == len(set(igdb_ids)), "Duplicate IGDB IDs found in cross_reference"
+
+
+def test_cross_reference_screenscraper_ids_unique():
+ """Screenscraper IDs must be unique except for explicitly allowed duplicates."""
+ from collections import Counter
+ ss_ids = [
+ e['ids']['screenscraper']
+ for e in platforms_module.cross_reference
+ if e['ids']['screenscraper'] is not None
+ ]
+ counts = Counter(ss_ids)
+ unexpected_dupes = {
+ sid for sid, count in counts.items() if count > 1 and sid not in ALLOWED_DUPLICATE_SCREENSCRAPER_IDS}
+ assert not unexpected_dupes, f"Unexpected duplicate Screenscraper IDs: {unexpected_dupes}"
+
+
+def test_cross_reference_names_unique():
+ names = [e['name'] for e in platforms_module.cross_reference]
+ assert len(names) == len(set(names)), "Duplicate names found in cross_reference"
diff --git a/tests/unit/test_update_db.py b/tests/unit/test_update_db.py
new file mode 100644
index 000000000000..ee1b2b5f42e0
--- /dev/null
+++ b/tests/unit/test_update_db.py
@@ -0,0 +1,521 @@
+# standard imports
+import argparse
+import json
+import os
+from unittest.mock import MagicMock, patch
+
+# lib imports
+import pytest
+
+# module under test β imported after patching module-level globals
+import src.update_db as udb
+
+
+def _make_args(tmp_path, **overrides):
+ """Return a minimal args Namespace."""
+ ns = argparse.Namespace(
+ out_dir=str(tmp_path / 'out'),
+ indent=None,
+ test_mode=False,
+ test_limit=1000,
+ youtube_api_key='fake_yt_key',
+ )
+ for k, v in overrides.items():
+ setattr(ns, k, v)
+ return ns
+
+
+def test_igdb_authorization(requests_mock):
+ requests_mock.post(
+ 'https://id.twitch.tv/oauth2/token',
+ json={'access_token': 'tok123', 'expires_in': 3600},
+ )
+ result = udb.igdb_authorization(client_id='cid', client_secret='csec')
+ assert result['access_token'] == 'tok123'
+ assert requests_mock.last_request.method == 'POST'
+
+
+@pytest.mark.parametrize('indent', [None, 4])
+def test_write_json_files(tmp_path, indent):
+ udb.args = _make_args(tmp_path, indent=indent)
+ data = {'key': 'value', 'num': 42}
+ file_path = str(tmp_path / 'test_dir' / 'myfile')
+
+ udb.write_json_files(file_path=file_path, data=data)
+
+ written = json.loads((tmp_path / 'test_dir' / 'myfile.json').read_text())
+ assert written == data
+
+
+def test_write_json_files_creates_directory(tmp_path):
+ udb.args = _make_args(tmp_path)
+ nested = str(tmp_path / 'a' / 'b' / 'c' / 'file')
+ udb.write_json_files(file_path=nested, data={'x': 1})
+ assert (tmp_path / 'a' / 'b' / 'c' / 'file.json').exists()
+
+
+@pytest.mark.parametrize('test_mode,test_limit,expected_count', [
+ (False, 1000, 3),
+ (True, 2, 2),
+])
+def test_fetch_endpoint_pagination(tmp_path, test_mode, test_limit, expected_count):
+ udb.args = _make_args(tmp_path, test_mode=test_mode, test_limit=test_limit)
+
+ page1 = json.dumps([{'id': 1, 'name': 'A'}, {'id': 2, 'name': 'B'}]).encode()
+ page2 = json.dumps([{'id': 3, 'name': 'C'}]).encode()
+ empty = json.dumps([]).encode()
+
+ mock_wrapper = MagicMock()
+ mock_wrapper.api_request.side_effect = [page1, page2, empty]
+ udb.wrapper = mock_wrapper
+
+ result = udb._fetch_endpoint(
+ endpoint='games',
+ fields=['name'],
+ limit=2,
+ test_mode=test_mode,
+ test_limit=test_limit,
+ )
+
+ assert len(result) == expected_count
+
+
+def test_fetch_endpoint_http_retry(tmp_path):
+ udb.args = _make_args(tmp_path)
+
+ good_page = json.dumps([{'id': 1, 'name': 'X'}]).encode()
+ empty = json.dumps([]).encode()
+
+ mock_wrapper = MagicMock()
+ mock_wrapper.api_request.side_effect = [
+ __import__('requests').exceptions.HTTPError('429'),
+ good_page,
+ empty,
+ ]
+ udb.wrapper = mock_wrapper
+
+ with patch('src.update_db.time.sleep') as mock_sleep:
+ result = udb._fetch_endpoint(
+ endpoint='games',
+ fields=['name'],
+ limit=500,
+ test_mode=False,
+ test_limit=1000,
+ )
+
+ mock_sleep.assert_called_once_with(1)
+ assert 1 in result
+
+
+def test_fetch_all_endpoints_writes_all_json_only_when_flagged(tmp_path):
+ udb.args = _make_args(tmp_path)
+
+ mock_wrapper = MagicMock()
+ mock_wrapper.api_request.return_value = json.dumps([]).encode()
+ udb.wrapper = mock_wrapper
+
+ request_dict = {
+ 'characters': {'fields': ['name'], 'write_all': True},
+ 'games': {'fields': ['name'], 'write_all': False},
+ }
+
+ with patch('src.update_db.write_json_files') as mock_write:
+ udb._fetch_all_endpoints(
+ request_dict=request_dict,
+ limit=500,
+ test_mode=False,
+ test_limit=1000,
+ )
+
+ assert mock_write.call_count == 1
+ written_path = mock_write.call_args[1]['file_path']
+ assert 'characters' in written_path
+ assert 'all' in written_path
+
+
+def test_append_characters_to_games():
+ full_dict = {
+ 'games': {
+ 1: {'id': 1, 'name': 'Halo'},
+ },
+ 'characters': {
+ 99: {'id': 99, 'name': 'Chief', 'mug_shot': {'url': '//x'}, 'games': [1]},
+ },
+ }
+ request_dict = {
+ 'games': {
+ 'fields': ['name'],
+ 'write_all': False,
+ 'append': {
+ 'characters': {
+ 'fields': ['id', 'name', 'mug_shot'],
+ },
+ },
+ },
+ }
+
+ udb._append_related_items(full_dict=full_dict, request_dict=request_dict)
+
+ assert 'characters' in full_dict['games'][1]
+ assert full_dict['games'][1]['characters'][0]['name'] == 'Chief'
+ assert full_dict['games'][1]['characters'][0]['mug_shot'] == {'url': '//x'}
+
+
+def test_append_second_item_to_existing_list():
+ """When the list already exists in the dest, append without reinitialising."""
+ full_dict = {
+ 'games': {
+ 1: {'id': 1, 'name': 'Halo', 'characters': [{'id': 1, 'name': 'Existing'}]},
+ },
+ 'characters': {
+ 99: {'id': 99, 'name': 'Chief', 'games': [1]},
+ },
+ }
+ request_dict = {
+ 'games': {
+ 'fields': ['name'],
+ 'write_all': False,
+ 'append': {'characters': {'fields': ['id', 'name']}},
+ },
+ }
+
+ udb._append_related_items(full_dict=full_dict, request_dict=request_dict)
+
+ assert len(full_dict['games'][1]['characters']) == 2
+
+
+def test_append_games_to_platforms():
+ full_dict = {
+ 'platforms': {
+ 6: {'id': 6, 'name': 'PC'},
+ },
+ 'games': {
+ 1: {'id': 1, 'name': 'Halo', 'cover': None, 'release_dates': [], 'platforms': [6]},
+ },
+ }
+ request_dict = {
+ 'platforms': {
+ 'fields': ['name'],
+ 'write_all': True,
+ 'append': {
+ 'games': {
+ 'fields': ['id', 'name', 'cover', 'release_dates'],
+ },
+ },
+ },
+ }
+
+ udb._append_related_items(full_dict=full_dict, request_dict=request_dict)
+
+ assert 'games' in full_dict['platforms'][6]
+ assert full_dict['platforms'][6]['games'][0]['name'] == 'Halo'
+
+
+def test_append_skips_missing_dest():
+ full_dict = {
+ 'games': {},
+ 'characters': {
+ 1: {'id': 1, 'name': 'X', 'games': [999]},
+ },
+ }
+ request_dict = {
+ 'games': {
+ 'fields': ['name'],
+ 'write_all': False,
+ 'append': {'characters': {'fields': ['id', 'name']}},
+ },
+ }
+ udb._append_related_items(full_dict=full_dict, request_dict=request_dict)
+ assert full_dict['games'] == {}
+
+
+def test_append_skips_source_with_no_endpoint_key():
+ """Source item missing the endpoint key (e.g. character with no 'games') is skipped."""
+ full_dict = {
+ 'games': {1: {'id': 1, 'name': 'Halo'}},
+ 'characters': {
+ 99: {'id': 99, 'name': 'Chief'}, # no 'games' key
+ },
+ }
+ request_dict = {
+ 'games': {
+ 'fields': ['name'],
+ 'write_all': False,
+ 'append': {'characters': {'fields': ['id', 'name']}},
+ },
+ }
+ udb._append_related_items(full_dict=full_dict, request_dict=request_dict)
+ assert 'characters' not in full_dict['games'][1]
+
+
+def test_append_skips_endpoint_without_append_key():
+ full_dict = {'characters': {1: {'id': 1, 'name': 'X'}}}
+ request_dict = {
+ 'characters': {'fields': ['name'], 'write_all': True},
+ }
+ udb._append_related_items(full_dict=full_dict, request_dict=request_dict)
+
+
+def test_add_platform_game_counts(tmp_path):
+ udb.args = _make_args(tmp_path)
+ full_dict = {
+ 'platforms': {
+ 6: {'id': 6, 'name': 'PC', 'games': [{'id': 1}, {'id': 2}]},
+ 48: {'id': 48, 'name': 'PS4'},
+ },
+ }
+
+ with patch('src.update_db.write_json_files') as mock_write:
+ udb._add_platform_game_counts(full_dict=full_dict)
+
+ assert full_dict['platforms'][6]['game_count'] == 2
+ assert full_dict['platforms'][48]['game_count'] == 0
+ mock_write.assert_called_once()
+
+
+@pytest.mark.parametrize('name,expected_bucket', [
+ ('Halo', 'ha'),
+ ('123 Game', '12'),
+ (' !! Special', '@'),
+ ('A', 'a'),
+])
+def test_build_buckets_bucket_names(name, expected_bucket):
+ full_dict = {
+ 'games': {1: {'id': 1, 'name': name}},
+ }
+ buckets, _ = udb._build_buckets_and_collect_videos(full_dict=full_dict)
+ assert expected_bucket in buckets
+ assert 1 in buckets[expected_bucket]
+
+
+def test_build_buckets_deduplicates_videos():
+ full_dict = {
+ 'games': {
+ 1: {'id': 1, 'name': 'Alpha', 'videos': [
+ {'video_id': 'vid1'},
+ {'video_id': 'vid2'},
+ ]},
+ 2: {'id': 2, 'name': 'Beta', 'videos': [
+ {'video_id': 'vid1'},
+ ]},
+ },
+ }
+ _, all_videos = udb._build_buckets_and_collect_videos(full_dict=full_dict)
+ assert all_videos.count('vid1') == 1
+ assert 'vid2' in all_videos
+
+
+def test_build_buckets_no_videos():
+ full_dict = {'games': {1: {'id': 1, 'name': 'Silent'}}}
+ _, all_videos = udb._build_buckets_and_collect_videos(full_dict=full_dict)
+ assert all_videos == []
+
+
+def test_resolve_video_groups_no_cache(tmp_path):
+ cache = str(tmp_path / 'cache' / 'vg.json')
+ all_videos = [f'v{i}' for i in range(5)]
+
+ groups = udb._resolve_video_groups(all_videos=all_videos, cache_file=cache, group_size=2)
+
+ assert groups == [['v0', 'v1'], ['v2', 'v3'], ['v4']]
+ assert os.path.isfile(cache)
+ saved = json.loads(open(cache).read())
+ assert saved == groups
+
+
+def test_resolve_video_groups_with_cache_keeps_valid_filters_stale(tmp_path):
+ cache = str(tmp_path / 'cache' / 'vg.json')
+ os.makedirs(os.path.dirname(cache))
+
+ cached = [['v0', 'v1'], ['old', 'v2']]
+ with open(cache, 'w') as f:
+ json.dump(cached, f)
+
+ all_videos = ['v0', 'v1', 'v2', 'v3']
+
+ groups = udb._resolve_video_groups(all_videos=all_videos, cache_file=cache, group_size=50)
+
+ assert ['v0', 'v1'] in groups
+ for g in groups:
+ assert 'old' not in g
+ all_in_groups = [v for g in groups for v in g]
+ assert 'v3' in all_in_groups
+
+
+def test_fetch_youtube_metadata(tmp_path):
+ udb.args = _make_args(tmp_path)
+ full_dict = {'videos': {}}
+
+ yt_response = {
+ 'items': [
+ {'id': 'abc', 'snippet': {'title': 'Trailer', 'thumbnails': {'default': {'url': '//t.jpg', 'width': 120}}}},
+ ]
+ }
+
+ with patch('src.update_db.get_youtube', return_value=yt_response):
+ udb._fetch_youtube_metadata(full_dict=full_dict, all_video_groups=[['abc']])
+
+ assert 'abc' in full_dict['videos']
+ assert full_dict['videos']['abc']['snippet']['title'] == 'Trailer'
+
+
+def test_fetch_youtube_metadata_handles_missing_items_key(tmp_path, capsys):
+ udb.args = _make_args(tmp_path)
+ full_dict = {'videos': {}}
+
+ with patch('src.update_db.get_youtube', return_value={'error': 'quota exceeded'}):
+ udb._fetch_youtube_metadata(full_dict=full_dict, all_video_groups=[['abc']])
+
+ captured = capsys.readouterr()
+ assert 'KeyError' in captured.out
+ assert full_dict['videos'] == {}
+
+
+def test_enrich_game_videos():
+ full_dict = {
+ 'games': {
+ 1: {
+ 'name': 'Halo',
+ 'videos': [{'video_id': 'abc', 'name': 'Trailer'}],
+ },
+ },
+ 'videos': {
+ 'abc': {
+ 'id': 'abc',
+ 'snippet': {
+ 'title': 'Halo Trailer',
+ 'thumbnails': {
+ 'default': {'url': '//small.jpg', 'width': 120},
+ 'high': {'url': '//big.jpg', 'width': 480},
+ 'null_thumb': None,
+ },
+ },
+ },
+ },
+ }
+
+ udb._enrich_game_videos(full_dict=full_dict)
+
+ video = full_dict['games'][1]['videos'][0]
+ assert video['url'] == 'https://www.youtube.com/watch?v=abc'
+ assert video['title'] == 'Halo Trailer'
+ assert video['thumb'] == '//big.jpg'
+
+
+def test_enrich_game_videos_skips_missing_video_id():
+ full_dict = {
+ 'games': {
+ 1: {'name': 'Game', 'videos': [{'video_id': 'missing_id'}]},
+ },
+ 'videos': {},
+ }
+ udb._enrich_game_videos(full_dict=full_dict)
+ assert 'url' not in full_dict['games'][1]['videos'][0]
+
+
+def test_enrich_game_videos_skips_games_with_no_videos():
+ full_dict = {
+ 'games': {1: {'name': 'No Videos'}},
+ 'videos': {},
+ }
+ udb._enrich_game_videos(full_dict=full_dict)
+
+
+def test_get_youtube(tmp_path):
+ udb.args = _make_args(tmp_path, youtube_api_key='yt_key_123')
+
+ mock_response = MagicMock()
+ mock_response.json.return_value = {'items': []}
+
+ mock_session = MagicMock()
+ mock_session.get.return_value = mock_response
+
+ with patch('src.update_db.requests_cache.CachedSession', return_value=mock_session):
+ result = udb.get_youtube(video_ids=['abc', 'def'])
+
+ assert result == {'items': []}
+ called_url = mock_session.get.call_args[1]['url']
+ assert 'abc,def' in called_url
+ assert 'yt_key_123' in called_url
+
+
+def test_get_platform_cross_reference(tmp_path):
+ udb.args = _make_args(tmp_path)
+
+ with patch('src.update_db.write_json_files') as mock_write:
+ udb.get_platform_cross_reference()
+
+ mock_write.assert_called_once()
+ call_kwargs = mock_write.call_args[1]
+ assert 'cross-reference' in call_kwargs['file_path']
+ assert call_kwargs['data'] is udb.platforms.cross_reference
+
+
+def test_get_data_orchestration(tmp_path):
+ """get_data() calls all helpers in order with correct data flow."""
+ udb.args = _make_args(tmp_path)
+
+ mock_full_dict = {
+ 'games': {1: {'id': 1, 'name': 'TestGame'}},
+ 'platforms': {6: {'id': 6, 'name': 'PC'}},
+ }
+
+ with patch('src.update_db._fetch_all_endpoints', return_value=mock_full_dict) as m_fetch, \
+ patch('src.update_db._append_related_items') as m_append, \
+ patch('src.update_db._add_platform_game_counts') as m_counts, \
+ patch('src.update_db._build_buckets_and_collect_videos',
+ return_value=({'ha': {1: {'name': 'TestGame'}}}, ['vid1'])) as m_buckets, \
+ patch('src.update_db._resolve_video_groups', return_value=[['vid1']]) as m_vgroups, \
+ patch('src.update_db._fetch_youtube_metadata') as m_yt, \
+ patch('src.update_db._enrich_game_videos') as m_enrich, \
+ patch('src.update_db.write_json_files') as m_write:
+ udb.get_data()
+
+ m_fetch.assert_called_once()
+ m_append.assert_called_once()
+ m_counts.assert_called_once()
+ m_buckets.assert_called_once()
+ m_vgroups.assert_called_once()
+ m_yt.assert_called_once()
+ m_enrich.assert_called_once()
+ assert m_write.call_count >= 2 # bucket files + individual files
+
+
+def test_main_raises_on_missing_secrets(tmp_path):
+ """main() raises SystemExit when required secrets are absent."""
+ with patch('src.update_db.argparse.ArgumentParser.parse_args') as mock_parse:
+ mock_parse.return_value = argparse.Namespace(
+ out_dir=str(tmp_path),
+ indent_json=False,
+ twitch_client_id=None,
+ twitch_client_secret=None,
+ youtube_api_key=None,
+ test_mode=False,
+ test_limit=1000,
+ )
+ with pytest.raises(SystemExit):
+ udb.main()
+
+
+def test_main_success(tmp_path):
+ """main() sets args/wrapper globals and runs update functions."""
+ with patch('src.update_db.argparse.ArgumentParser.parse_args') as mock_parse, \
+ patch('src.update_db.igdb_authorization', return_value={'access_token': 'tok'}) as m_auth, \
+ patch('src.update_db.IGDBWrapper') as m_wrapper_cls, \
+ patch('src.update_db.get_data') as m_get_data, \
+ patch('src.update_db.get_platform_cross_reference') as m_xref:
+ mock_parse.return_value = argparse.Namespace(
+ out_dir=str(tmp_path),
+ indent_json=False,
+ twitch_client_id='cid',
+ twitch_client_secret='csec',
+ youtube_api_key='yt',
+ test_mode=False,
+ test_limit=1000,
+ )
+ udb.main()
+
+ m_auth.assert_called_once_with(client_id='cid', client_secret='csec')
+ m_wrapper_cls.assert_called_once()
+ m_get_data.assert_called_once()
+ m_xref.assert_called_once()