From 84c04a6dab08e381becf9f3661bfff2eb6416652 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:55:20 -0500 Subject: [PATCH 1/5] test: add tests --- .github/workflows/ci-tests.yml | 96 +++ .github/workflows/update-db.yml | 4 +- .gitignore | 12 + README.md | 8 - babel.config.js | 2 + codecov.yml | 15 + eslint.config.mjs | 32 + .../assets/js/character_detail.js | 7 + .../assets/js/collection_detail.js | 7 + .../assets/js/franchise_detail.js | 7 + gh-pages-template/assets/js/game_detail.js | 23 + gh-pages-template/assets/js/item_detail.js | 15 + gh-pages-template/assets/js/item_loader.js | 27 +- .../assets/js/platform_detail.js | 13 + package.json | 57 ++ pyproject.toml | 72 ++ requirements-dev.txt | 1 - requirements.txt | 4 - src/update_db.py | 508 ++++++++------ tests/__init__.py | 0 tests/character_detail.test.js | 121 ++++ tests/collection_detail.test.js | 89 +++ tests/conftest.py | 48 ++ tests/franchise_detail.test.js | 89 +++ tests/game_detail.test.js | 648 ++++++++++++++++++ tests/item_detail.test.js | 342 +++++++++ tests/item_loader.test.js | 499 ++++++++++++++ tests/platform_detail.test.js | 314 +++++++++ tests/unit/__init__.py | 0 tests/unit/test_platforms.py | 82 +++ tests/unit/test_update_db.py | 521 ++++++++++++++ 31 files changed, 3449 insertions(+), 214 deletions(-) create mode 100644 .github/workflows/ci-tests.yml create mode 100644 babel.config.js create mode 100644 codecov.yml create mode 100644 eslint.config.mjs create mode 100644 package.json create mode 100644 pyproject.toml delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/character_detail.test.js create mode 100644 tests/collection_detail.test.js create mode 100644 tests/conftest.py create mode 100644 tests/franchise_detail.test.js create mode 100644 tests/game_detail.test.js create mode 100644 tests/item_detail.test.js create mode 100644 tests/item_loader.test.js create mode 100644 tests/platform_detail.test.js create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_platforms.py create mode 100644 tests/unit/test_update_db.py diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 000000000000..f48352878518 --- /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: ${{ matrix.runner }} + 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..b208b14fd7fe 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 update-db ${{ 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..61f3fbf90344 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,3 @@ 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..4de1af1ceb8d 100644 --- a/gh-pages-template/assets/js/game_detail.js +++ b/gh-pages-template/assets/js/game_detail.js @@ -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(", ")); } @@ -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..f03b41ab3f99 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,283 @@ 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 _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(): + try: + append_dict = endpoint_dict['append'] + except KeyError: + continue + + for item_type, item_type_dict in append_dict.items(): + print(f'adding {item_type} to {endpoint}') + for item_id_src, value in full_dict[item_type].items(): + try: + append_to = value[endpoint] + except KeyError: + continue + + 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] = [] + + new_entry = {} + for field in item_type_dict['fields']: + if field in value: + new_entry[field] = value[field] + + full_dict[endpoint][item_id_dest][item_type].append(new_entry) + + +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 +512,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 - - 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]) - - print(f'{len(full_dict[end_point])} items processed in endpoint: {end_point}') + limit = 500 - 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 + full_dict = _fetch_all_endpoints( + request_dict=request_dict, + limit=limit, + test_mode=args.test_mode, + test_limit=args.test_limit, + ) - # 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']) + _append_related_items(full_dict=full_dict, request_dict=request_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 = '@' + _add_platform_game_counts(full_dict=full_dict) - try: - buckets[bucket] - except KeyError: - buckets[bucket] = {} - finally: - buckets[bucket][game_id] = {'name': game_data['name']} + buckets, all_videos = _build_buckets_and_collect_videos(full_dict=full_dict) - # 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) + _fetch_youtube_metadata(full_dict=full_dict, all_video_groups=all_video_groups) - # write the new video groups to cache - with open(cache_file, 'w') as f: - json.dump(all_video_groups, f) + _enrich_game_videos(full_dict=full_dict) - for video_group in all_video_groups: - json_result = get_youtube(video_ids=video_group) - - 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)}') - - # 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 +561,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 +623,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..efe7a7b7af1a --- /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.getAttribute('data-img-src-1')).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.parentNode.removeChild(pageHeading); + // 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.parentNode.removeChild(introHeader); + 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.parentNode.removeChild(pageHeading); + 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.parentNode.removeChild(header); + 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.setAttribute('data-img-src-1', 'https://example.com/art1.jpg'); + bigImgs.setAttribute('data-img-src-2', 'https://example.com/art2.jpg'); + bigImgs.setAttribute('data-img-desc-1', 'null'); + bigImgs.setAttribute('data-img-desc-2', '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.setAttribute('data-img-src-1', '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.setAttribute('data-img-src-1', 'https://example.com/art.jpg'); + bigImgs.setAttribute('data-img-desc-1', '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.parentNode.removeChild(h1); + 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..9e3c1ed02b46 --- /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 && opts.success) { + // Return mock data appropriate to the URL + if (opts.url && opts.url.includes('cross-reference')) { + opts.success({}); + } else if (opts.url && 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..f9a30f422d1d --- /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: 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() From 0fc066b919f5258369b67dee8d74715071339067 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:02:03 -0500 Subject: [PATCH 2/5] sonar fixes --- src/update_db.py | 107 ++++++++++++++++++++++++++-------- tests/game_detail.test.js | 26 ++++----- tests/item_loader.test.js | 6 +- tests/platform_detail.test.js | 2 +- 4 files changed, 101 insertions(+), 40 deletions(-) diff --git a/src/update_db.py b/src/update_db.py index f03b41ab3f99..a35e177bd7a7 100644 --- a/src/update_db.py +++ b/src/update_db.py @@ -200,6 +200,82 @@ def _fetch_all_endpoints(request_dict: dict, limit: int, test_mode: bool, test_l 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. @@ -212,32 +288,17 @@ def _append_related_items(full_dict: dict, request_dict: dict) -> None: Endpoint configuration containing 'append' sub-dicts. """ for endpoint, endpoint_dict in request_dict.items(): - try: - append_dict = endpoint_dict['append'] - except KeyError: + append_dict = endpoint_dict.get('append') + if not append_dict: continue for item_type, item_type_dict in append_dict.items(): - print(f'adding {item_type} to {endpoint}') - for item_id_src, value in full_dict[item_type].items(): - try: - append_to = value[endpoint] - except KeyError: - continue - - 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] = [] - - new_entry = {} - for field in item_type_dict['fields']: - if field in value: - new_entry[field] = value[field] - - full_dict[endpoint][item_id_dest][item_type].append(new_entry) + _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: diff --git a/tests/game_detail.test.js b/tests/game_detail.test.js index efe7a7b7af1a..57f263eb9d2a 100644 --- a/tests/game_detail.test.js +++ b/tests/game_detail.test.js @@ -478,7 +478,7 @@ describe('game_detail.js', () => { }); const bigImgs = document.getElementById('header-big-imgs'); expect(bigImgs.dataset.numImg).toBe('2'); - expect(bigImgs.getAttribute('data-img-src-1')).toContain('art1'); + expect(bigImgs.dataset.imgSrc1).toContain('art1'); }); test('hides page heading when no artworks and heading exists', () => { @@ -489,7 +489,7 @@ describe('game_detail.js', () => { test('does nothing when no artworks and no .page-heading in DOM', () => { const pageHeading = document.querySelector('.page-heading'); - pageHeading.parentNode.removeChild(pageHeading); + pageHeading.remove(); // Should not throw and should not affect any element expect(() => setupGameBanner({})).not.toThrow(); }); @@ -506,7 +506,7 @@ describe('game_detail.js', () => { 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.parentNode.removeChild(introHeader); + introHeader.remove(); expect(() => setupGameBanner({ artworks: [{ url: '//images.igdb.com/t_thumb/art.jpg' }], })).not.toThrow(); @@ -515,7 +515,7 @@ describe('game_detail.js', () => { test('skips pageHeading visibility when pageHeading absent (no .page-heading)', () => { // Remove page-heading from DOM const pageHeading = document.querySelector('.page-heading'); - pageHeading.parentNode.removeChild(pageHeading); + pageHeading.remove(); document.querySelector('.intro-header').classList.add('big-img'); expect(() => setupGameBanner({ artworks: [{ url: '//images.igdb.com/t_thumb/art.jpg' }], @@ -535,7 +535,7 @@ describe('game_detail.js', () => { 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.parentNode.removeChild(header); + header.remove(); expect(() => setupGameBanner({ artworks: [{ url: '//img.igdb.com/t.jpg' }] })).not.toThrow(); }); }); @@ -549,10 +549,10 @@ describe('game_detail.js', () => { test('sets initial image and cycles when multiple artworks', () => { const bigImgs = document.getElementById('header-big-imgs'); bigImgs.dataset.numImg = '2'; - bigImgs.setAttribute('data-img-src-1', 'https://example.com/art1.jpg'); - bigImgs.setAttribute('data-img-src-2', 'https://example.com/art2.jpg'); - bigImgs.setAttribute('data-img-desc-1', 'null'); - bigImgs.setAttribute('data-img-desc-2', 'Artwork 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'); @@ -574,7 +574,7 @@ describe('game_detail.js', () => { test('returns early when no intro-header.big-img found', () => { const bigImgs = document.getElementById('header-big-imgs'); bigImgs.dataset.numImg = '1'; - bigImgs.setAttribute('data-img-src-1', 'https://example.com/art.jpg'); + bigImgs.dataset.imgSrc1 = 'https://example.com/art.jpg'; // intro-header does NOT have big-img class β€” should return early expect(() => initGameBanner()).not.toThrow(); }); @@ -582,8 +582,8 @@ describe('game_detail.js', () => { 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.setAttribute('data-img-src-1', 'https://example.com/art.jpg'); - bigImgs.setAttribute('data-img-desc-1', 'Some description'); + 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 @@ -633,7 +633,7 @@ describe('game_detail.js', () => { 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.parentNode.removeChild(h1); + if (h1) h1.remove(); expect(() => renderGame({ name: 'No Header Game' })).not.toThrow(); expect(document.getElementById('game-name').textContent).toBe('No Header Game'); }); diff --git a/tests/item_loader.test.js b/tests/item_loader.test.js index 9e3c1ed02b46..c67ffabc6a0b 100644 --- a/tests/item_loader.test.js +++ b/tests/item_loader.test.js @@ -28,11 +28,11 @@ globalThis.$ = function() { }; globalThis.$.ajaxSetup = () => {}; globalThis.$.ajax = function(opts) { - if (opts && opts.success) { + if (opts?.success) { // Return mock data appropriate to the URL - if (opts.url && opts.url.includes('cross-reference')) { + if (opts?.url?.includes('cross-reference')) { opts.success({}); - } else if (opts.url && opts.url.includes('all.json')) { + } else if (opts?.url?.includes('all.json')) { opts.success({ '1': { id: 1, name: 'PC', url: 'https://igdb.com', summary: 'Personal computer.', diff --git a/tests/platform_detail.test.js b/tests/platform_detail.test.js index f9a30f422d1d..1593ddc1e908 100644 --- a/tests/platform_detail.test.js +++ b/tests/platform_detail.test.js @@ -138,7 +138,7 @@ describe('platform_detail.js', () => { describe('renderPlatformMetadata', () => { test('renders game count (plural)', () => { - renderPlatformMetadata({ games: Array(42).fill({}) }); + renderPlatformMetadata({ games: new Array(42).fill({}) }); expect(document.getElementById('platform-meta').textContent).toContain('42 games'); }); From 1922f17a4b520c89dae547d85ee9f87d1261e34d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:03:55 -0500 Subject: [PATCH 3/5] Use ubuntu-latest and fix update-db invocation Set the CI job to run on ubuntu-latest instead of the matrix runner, standardizing the environment. Fix the update-db workflow to invoke the actual script path (./src/update_db.py) rather than an ambiguous `update-db` command; preserves the conditional '-t' flag for pull_request events. --- .github/workflows/ci-tests.yml | 2 +- .github/workflows/update-db.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index f48352878518..6a87d5a02222 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -17,7 +17,7 @@ jobs: tests: permissions: contents: write # write is required for release_setup - runs-on: ${{ matrix.runner }} + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/update-db.yml b/.github/workflows/update-db.yml index b208b14fd7fe..b0bf4b4a8f51 100644 --- a/.github/workflows/update-db.yml +++ b/.github/workflows/update-db.yml @@ -46,7 +46,7 @@ jobs: TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }} TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }} YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }} - run: python -u update-db ${{ 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 From 9ea7ea4c472522926dfbe0b3a5b4e2d3962140c9 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:14:41 -0500 Subject: [PATCH 4/5] Use dataset properties for banner images Replace setAttribute/getAttribute usage for per-image data attributes with element.dataset access in game_detail.js. Values are now written to bigImgsEl.dataset.imgSrc{n} / imgDesc{n} and read back via dataset, simplifying the code and aligning with standard DOM dataset usage. --- gh-pages-template/assets/js/game_detail.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gh-pages-template/assets/js/game_detail.js b/gh-pages-template/assets/js/game_detail.js index 4de1af1ceb8d..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 @@ -470,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 }; }; From 3229839ed506ca1df26d4870aeec505975080550 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:21:17 -0500 Subject: [PATCH 5/5] Add Codecov badge to README Add a Codecov status badge to the README to display the repository's coverage report. The badge links to the project's Codecov page and uses the for-the-badge style for prominent visibility. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 61f3fbf90344..9d448992ac3a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # GameDB [![GitHub Workflow Status (DB)](https://img.shields.io/github/actions/workflow/status/lizardbyte/gamedb/update-db.yml.svg?branch=master&label=update%20db&logo=github&style=for-the-badge)](https://github.com/LizardByte/GameDB/actions/workflows/update-db.yml?query=branch%3Amaster) +[![Codecov](https://img.shields.io/codecov/c/gh/LizardByte/GameDB.svg?token=AG91ICECDX&style=for-the-badge&logo=codecov&label=codecov)](https://app.codecov.io/gh/LizardByte/GameDB) This repository clones IGDB to gh-pages to be consumed by LizardByte projects, such as LizardByte/Sunshine.