From 91b7bd94d06021e30347142e8ed380b271a9daa1 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:22:38 +0100 Subject: [PATCH] feat: refuse placeholder versions in the schema core_version is a string the code states, null or keyed by build mode, and the schema refuses Git, SVN, unknown, master, main, latest, dev and HEAD, alone or annotated. version_basis is defined per kind with the fields each one needs, and kind none requires a null version. tests/test_version_basis.py reads a recorded fixture of forge answers offline and fails on drift, holds every profile to a basis (tag versions in describe form, aliases equal to their profile, none as null), checks that a pin advance reads the version again or is refused, and that the schema refuses placeholders. --- schemas/emulator.schema.json | 259 +++++++++++++++++++++- scripts/generate_site.py | 1 + scripts/profile_sync.py | 3 +- scripts/version_basis.py | 6 +- tests/fixtures/version_basis.json | 94 ++++++++ tests/test_version_basis.py | 351 ++++++++++++++++++++++++++++++ wiki/testing-guide.md | 2 +- 7 files changed, 708 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/version_basis.json create mode 100644 tests/test_version_basis.py diff --git a/schemas/emulator.schema.json b/schemas/emulator.schema.json index cada7d28d..d1d2b4097 100644 --- a/schemas/emulator.schema.json +++ b/schemas/emulator.schema.json @@ -63,6 +63,33 @@ } } } + }, + { + "comment": "A profile whose code states no version says so with version_basis kind none and a null core_version, never a placeholder.", + "if": { + "properties": { + "version_basis": { + "properties": { + "kind": { + "const": "none" + } + }, + "required": [ + "kind" + ] + } + }, + "required": [ + "version_basis" + ] + }, + "then": { + "properties": { + "core_version": { + "type": "null" + } + } + } } ], "properties": { @@ -155,11 +182,50 @@ ] }, "core_version": { - "type": [ - "string", - "number", - "object", - "null" + "description": "The version of the code at the pin, read where version_basis says: the string the program states, git describe's form of the nearest tag, or the package read. Never a branch or VCS name (Git, SVN, master, latest, dev, unknown). null when version_basis is kind none. An object keys it by build mode, like source.", + "oneOf": [ + { + "type": "string", + "pattern": "^(?!([Gg][Ii][Tt]|[Ss][Vv][Nn]|[Uu][Nn][Kk][Nn][Oo][Ww][Nn]|[Mm][Aa][Ss][Tt][Ee][Rr]|[Mm][Aa][Ii][Nn]|[Ll][Aa][Tt][Ee][Ss][Tt]|[Dd][Ee][Vv]|[Hh][Ee][Aa][Dd])([\\s(]|$)).*\\S" + }, + { + "type": "null" + }, + { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "oneOf": [ + { + "type": "string", + "pattern": "^(?!([Gg][Ii][Tt]|[Ss][Vv][Nn]|[Uu][Nn][Kk][Nn][Oo][Ww][Nn]|[Mm][Aa][Ss][Tt][Ee][Rr]|[Mm][Aa][Ii][Nn]|[Ll][Aa][Tt][Ee][Ss][Tt]|[Dd][Ee][Vv]|[Hh][Ee][Aa][Dd])([\\s(]|$)).*\\S" + }, + { + "type": "null" + } + ] + } + } + ] + }, + "version_basis": { + "description": "Where core_version is read, so a tool reads it again at any pin. An object keyed by build mode when core_version is.", + "oneOf": [ + { + "$ref": "#/$defs/version_basis" + }, + { + "type": "object", + "minProperties": 1, + "not": { + "required": [ + "kind" + ] + }, + "additionalProperties": { + "$ref": "#/$defs/version_basis" + } + } ] }, "alias_of": { @@ -963,5 +1029,188 @@ "type": "string", "description": "Repository carrying the same tree, consulted after source and upstream. The only thing that keeps a profile checkable once its own forge is withdrawn or its host stops resolving." } + }, + "$defs": { + "version_basis": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "enum": [ + "file", + "tag", + "alias", + "package", + "none", + "unverified" + ], + "description": "file: a pattern in one file at the pin. tag: the nearest tag in the pin's history. alias: the version of the profile it aliases. package: an artefact read outside any source tree. none: the code at the pin states no version and no tag reaches it. unverified: no reading could be made, for the reason why gives. core_version stays as written, unconfirmed." + }, + "why": { + "enum": [ + "no_pin", + "host_unsupported", + "host_gone", + "host_unreachable", + "pin_off_branch", + "no_artifact", + "no_source" + ], + "description": "Why a basis is unverified: no pinned commit, a host the tools cannot read, a forge gone, a forge that did not answer, a pin on another branch than the build the profile describes, no build or package named as read, no source declared." + }, + "repo": { + "enum": [ + "source", + "upstream", + "source_mirror" + ], + "description": "The declared repository the version describes, pinned by source_commit (source, source_mirror) or upstream_commit. Default source." + }, + "url": { + "type": "string", + "format": "uri", + "description": "kind file: a repository outside the profile's own, read at commit (the package file of a build a platform ships). kind package: where the artefact was obtained." + }, + "commit": { + "type": "string", + "pattern": "^[0-9a-f]{7,40}$", + "description": "The revision of url the file is read at." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "File read at the pin." + }, + "pattern": { + "type": "string", + "minLength": 1, + "description": "Regular expression, multiline, whose groups hold the version." + }, + "format": { + "type": "string", + "pattern": "\\{\\d+", + "description": "How the groups are laid out, {1}.{2} or {4:02d}. Default: group 1." + }, + "match": { + "type": "string", + "minLength": 1, + "description": "Glob a tag must fit to count, as git describe --match takes it." + }, + "tag": { + "type": "string", + "minLength": 1, + "description": "The nearest tag in the pin's history." + }, + "distance": { + "type": "integer", + "minimum": 0, + "description": "Commits the pin has that the tag lacks." + }, + "of": { + "type": "string", + "minLength": 1, + "description": "The profile this one aliases." + }, + "artifact": { + "type": "string", + "minLength": 1, + "description": "The package, binary or archive the version was read from." + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "file" + } + } + }, + "then": { + "required": [ + "path", + "pattern" + ] + } + }, + { + "if": { + "properties": { + "kind": { + "const": "tag" + } + } + }, + "then": { + "required": [ + "tag", + "distance" + ] + } + }, + { + "if": { + "properties": { + "kind": { + "const": "alias" + } + } + }, + "then": { + "required": [ + "of" + ] + } + }, + { + "if": { + "properties": { + "kind": { + "const": "package" + } + } + }, + "then": { + "required": [ + "artifact" + ] + } + }, + { + "if": { + "properties": { + "kind": { + "const": "file" + } + }, + "required": [ + "url" + ] + }, + "then": { + "required": [ + "commit" + ] + } + }, + { + "if": { + "properties": { + "kind": { + "const": "unverified" + } + } + }, + "then": { + "required": [ + "why" + ] + } + } + ] + } } } diff --git a/scripts/generate_site.py b/scripts/generate_site.py index f20054bf3..4164908ee 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -7799,6 +7799,7 @@ _UNVERIFIED = { "host_unsupported": "its host cannot be read", "host_gone": "its source is gone", "host_unreachable": "its host did not answer", + "pin_off_branch": "its recorded revision is not the build described", "no_artifact": "no build recorded as read", "no_source": "no source declared", } diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index c44b3c1c1..741ac22b7 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -2736,7 +2736,8 @@ def set_version_fields(text: str, version: object, basis: dict | None) -> str: if span is None: anchor = _top_block(lines, "upstream_commit") or _top_block(lines, "source_commit") \ or _top_block(lines, "profiled_date") or _top_block(lines, "upstream") \ - or _top_block(lines, "source") + or _top_block(lines, "source") or _top_block(lines, "alias_of") \ + or _top_block(lines, "type") if anchor is None: raise YamlWriteError("core_version: no line to write it after") lines[anchor[1]:anchor[1]] = rendered diff --git a/scripts/version_basis.py b/scripts/version_basis.py index 884007423..c4e169d2a 100644 --- a/scripts/version_basis.py +++ b/scripts/version_basis.py @@ -93,7 +93,11 @@ def _by_mode(profile: dict, field: str, mode: str) -> str | None: def is_placeholder(value: object) -> bool: - return isinstance(value, str) and value.strip().casefold() in PLACEHOLDERS + """A VCS or branch word standing for a version, alone or annotated + ("SVN (2015 snapshot)"). The schema refuses the same strings.""" + if not isinstance(value, str): + return False + return re.split(r"[\s(]", value.strip(), maxsplit=1)[0].casefold() in PLACEHOLDERS def format_version(match: re.Match, template: str | None) -> str: diff --git a/tests/fixtures/version_basis.json b/tests/fixtures/version_basis.json new file mode 100644 index 000000000..51c47f76f --- /dev/null +++ b/tests/fixtures/version_basis.json @@ -0,0 +1,94 @@ +{ + "api": { + "https://api.github.com/repos/libretro/dolphin/compare/f84df02055ab9610feec48e65648cac5a3c098fa...3aac3f052f733f8df2f1616843a4d2e14002ed11": { + "ahead_by": 437, + "behind_by": 0, + "status": "ahead" + }, + "https://api.github.com/repos/libretro/dolphin/git/ref/tags/2609": { + "object": { + "sha": "b134d026ab4e71df13b3657ea6787d499962664b", + "type": "tag" + } + }, + "https://api.github.com/repos/libretro/dolphin/git/tags/b134d026ab4e71df13b3657ea6787d499962664b": { + "object": { + "sha": "f84df02055ab9610feec48e65648cac5a3c098fa", + "type": "commit" + } + } + }, + "files": { + "github.com/libretro/FBNeo/32958a89abd645ed20387c8d31d21ede3bc48161/src/burn/version.h": "// Version number, written as vV.V.BB or vV.V.BBaa\n// (0xVVBBaa, in BCD notation)\n\n#define VER_MAJOR 1\n#define VER_MINOR 0\n#define VER_BETA 0\n#define VER_ALPHA 3\n\n#define BURN_VERSION (VER_MAJOR * 0x100000) + (VER_MINOR * 0x010000) + (((VER_BETA / 10) * 0x001000) + ((VER_BETA % 10) * 0x000100)) + (((VER_ALPHA / 10) * 0x000010) + (VER_ALPHA % 10))\n\n\n\n\n", + "github.com/libretro/snes9x/fae2fea08f74180759ef540ee94259213f503480/snes9x.h": "/*****************************************************************************\\\n Snes9x - Portable Super Nintendo Entertainment System (TM) emulator.\n This file is licensed under the Snes9x License.\n For further information, consult the LICENSE file in the root directory.\n\\*****************************************************************************/\n\n#ifndef _SNES9X_H_\n#define _SNES9X_H_\n\n#ifndef VERSION\n#define VERSION\t\"1.63\"\n#endif\n\n#include \"port.h\"\n#include \"65c816.h\"\n#include \"messages.h\"\n\n/* All core file I/O goes through the libretro VFS via the\n file_stream_transforms wrappers (RFILE). The frontend supplies the\n VFS interface when available (see retro_set_environment);\n filestream falls back to its local implementation otherwise.\n Compressed (.gz) stream support from the old ZLIB build is gone --\n under libretro the frontend handles compressed content itself. */\n#include \n#define FSTREAM\t\t\t\t\tRFILE *\n#define READ_FSTREAM(p, l, s)\trfread(p, 1, l, s)\n#define WRITE_FSTREAM(p, l, s)\trfwrite(p, 1, l, s)\n#define GETS_FSTREAM(p, l, s)\trfgets(p, l, s)\n#define GETC_FSTREAM(s)\t\t\trfgetc(s)\n#define OPEN_FSTREAM(f, m)\t\trfopen(f, m)\n#define FIND_FSTREAM(s)\t\t\trftell(s)\n#define REVERT_FSTREAM(s, o, p)\trfseek(s, o, p)\n#define CLOSE_FSTREAM(s)\t\t\trfclose(s)\n\n\n#define SNES_WIDTH\t\t\t\t\t256\n#define SNES_HEIGHT\t\t\t\t\t224\n#define SNES_HEIGHT_EXTENDED\t\t239\n#define MAX_SNES_WIDTH\t\t\t\t(SNES_WIDTH * 2)\n#define MAX_SNES_HEIGHT\t\t\t\t(SNES_HEIGHT_EXTENDED * 2)\n\n#define\tNTSC_MASTER_CLOCK\t\t\t21477272.727272 // 21477272 + 8/11 exact\n#define\tPAL_MASTER_CLOCK\t\t\t21281370.0\n#define NTSC_PROGRESSIVE_FRAME_RATE\t60.09881389744051\n#define NTSC_INTERLACED_FRAME_RATE\t59.94005994\n#define PAL_PROGRESSIVE_FRAME_RATE\t50.006977968\n\n\n#define SNES_MAX_NTSC_VCOUNTER\t\t262\n#define SNES_MAX_PAL_VCOUNTER\t\t312\n#define SNES_HCOUNTER_MAX\t\t\t341\n\n#ifndef ALLOW_CPU_OVERCLOCK\n#define ONE_CYCLE\t\t\t\t\t6\n#define SLOW_ONE_CYCLE\t\t\t\t8\n#define TWO_CYCLES\t\t\t\t\t12\n#else\n#define ONE_CYCLE (Settings.OneClockCycle)\n#define SLOW_ONE_CYCLE (Settings.OneSlowClockCycle)\n#define TWO_CYCLES (Settings.TwoClockCycles)\n#endif\n#define\tONE_DOT_CYCLE\t\t\t\t4\n\n#define SNES_CYCLES_PER_SCANLINE\t(SNES_HCOUNTER_MAX * ONE_DOT_CYCLE)\n#define SNES_SCANLINE_TIME\t\t\t(SNES_CYCLES_PER_SCANLINE / NTSC_MASTER_CLOCK)\n\n#define SNES_WRAM_REFRESH_HC_v1\t\t530\n#define SNES_WRAM_REFRESH_HC_v2\t\t538\n#define SNES_WRAM_REFRESH_CYCLES\t40\n\n#define SNES_HBLANK_START_HC\t\t1096\t\t\t\t\t// H=274\n#define\tSNES_HDMA_START_HC\t\t\t1106\t\t\t\t\t// FIXME: not true\n#define\tSNES_HBLANK_END_HC\t\t\t4\t\t\t\t\t\t// H=1\n#define\tSNES_HDMA_INIT_HC\t\t\t20\t\t\t\t\t\t// FIXME: not true\n#define\tSNES_RENDER_START_HC\t\t(128 * ONE_DOT_CYCLE)\t// FIXME: Snes9x renders a line at a time.\n\n#define SNES_TR_MASK\t\t(1 << 4)\n#define SNES_TL_MASK\t\t(1 << 5)\n#define SNES_X_MASK\t\t\t(1 << 6)\n#define SNES_A_MASK\t\t\t(1 << 7)\n#define SNES_RIGHT_MASK\t\t(1 << 8)\n#define SNES_LEFT_MASK\t\t(1 << 9)\n#define SNES_DOWN_MASK\t\t(1 << 10)\n#define SNES_UP_MASK\t\t(1 << 11)\n#define SNES_START_MASK\t\t(1 << 12)\n#define SNES_SELECT_MASK\t(1 << 13)\n#define SNES_Y_MASK\t\t\t(1 << 14)\n#define SNES_B_MASK\t\t\t(1 << 15)\n\n#define DEBUG_MODE_FLAG\t\t(1 << 0)\t// debugger\n#define TRACE_FLAG\t\t\t(1 << 1)\t// debugger\n#define SINGLE_STEP_FLAG\t(1 << 2)\t// debugger\n#define BREAK_FLAG\t\t\t(1 << 3)\t// debugger\n#define SCAN_KEYS_FLAG\t\t(1 << 4)\t// CPU\n#define HALTED_FLAG\t\t\t(1 << 12)\t// APU\n#define FRAME_ADVANCE_FLAG\t(1 << 9)\n\n#define ROM_NAME_LEN\t23\n#define AUTO_FRAMERATE\t200\n\nstruct SCPUState\n{\n\tuint32\tFlags;\n\tint32\tCycles;\n\tint32\tPrevCycles;\n\tint32\tV_Counter;\n\tuint8\t*PCBase;\n\tbool8\tNMIPending;\n\tbool8\tIRQLine;\n\tbool8\tIRQTransition;\n\tbool8\tIRQLastState;\n\tbool8\tIRQExternal;\n\tint32\tIRQPending;\n\tint32\tMemSpeed;\n\tint32\tMemSpeedx2;\n\tint32\tFastROMSpeed;\n\tbool8\tInDMA;\n\tbool8\tInHDMA;\n\tbool8\tInDMAorHDMA;\n\tbool8\tInWRAMDMAorHDMA;\n\tuint8\tHDMARanInDMA;\n\tint32\tCurrentDMAorHDMAChannel;\n\tuint8\tWhichEvent;\n\tint32\tNextEvent;\n\tbool8\tWaitingForInterrupt;\n\tuint32\tAutoSaveTimer;\n\tbool8\tSRAMModified;\n};\n\nenum\n{\n\tHC_HBLANK_START_EVENT = 1,\n\tHC_HDMA_START_EVENT = 2,\n\tHC_HCOUNTER_MAX_EVENT = 3,\n\tHC_HDMA_INIT_EVENT = 4,\n\tHC_RENDER_EVENT = 5,\n\tHC_WRAM_REFRESH_EVENT = 6\n};\n\nenum\n{\n\tIRQ_NONE = 0x0,\n\tIRQ_SET_FLAG = 0x1,\n\tIRQ_CLEAR_FLAG = 0x2,\n\tIRQ_TRIGGER_NMI = 0x4\n};\n\nstruct STimings\n{\n\tint32\tH_Max_Master;\n\tint32\tH_Max;\n\tint32\tV_Max_Master;\n\tint32\tV_Max;\n\tint32\tHBlankStart;\n\tint32\tHBlankEnd;\n\tint32\tHDMAInit;\n\tint32\tHDMAStart;\n\tint32\tNMITriggerPos;\n\tint32\tNextIRQTimer;\n\tint32\tIRQTriggerCycles;\n\tint32\tWRAMRefreshPos;\n\tint32\tRenderPos;\n\tbool8\tInterlaceField;\n\tint32\tDMACPUSync;\t\t// The cycles to synchronize DMA and CPU. Snes9x cannot emulate correctly.\n\tint32\tNMIDMADelay;\t// The delay of NMI trigger after DMA transfers. Snes9x cannot emulate correctly.\n\tint32\tIRQFlagChanging;\t// This value is just a hack.\n\tint32\tAPUSpeedup;\n\tbool8\tAPUAllowTimeOverflow;\n};\n\nstruct SSettings\n{\n\tbool8\tTraceDMA;\n\tbool8\tTraceHDMA;\n\tbool8\tTraceVRAM;\n\tbool8\tTraceUnknownRegisters;\n\tbool8\tTraceDSP;\n\tbool8\tTraceHCEvent;\n\tbool8\tTraceSMP;\n\n\tbool8\tSuperFX;\n\tuint8\tDSP;\n\tbool8\tSA1;\n\tbool8\tC4;\n\tbool8\tSDD1;\n\tbool8\tSPC7110;\n\tbool8\tSPC7110RTC;\n\tbool8\tOBC1;\n\tuint8\tSETA;\n\tbool8\tSRTC;\n\tbool8\tBS;\n\tbool8\tBSXItself;\n\tbool8\tBSXBootup;\n\tbool8\tMSU1;\n\tbool8\tMouseMaster;\n\tbool8\tSuperScopeMaster;\n\tbool8\tJustifierMaster;\n\tbool8\tMultiPlayer5Master;\n\tbool8\tMacsRifleMaster;\n\n\tbool8\tForceLoROM;\n\tbool8\tForceHiROM;\n\tbool8\tForceHeader;\n\tbool8\tForceNoHeader;\n\tbool8\tForceInterleaved;\n\tbool8\tForceInterleaved2;\n\tbool8\tForceInterleaveGD24;\n\tbool8\tForceNotInterleaved;\n\tbool8\tForcePAL;\n\tbool8\tForceNTSC;\n\tbool8\tPAL;\n\tuint32\tFrameTimePAL;\n\tuint32\tFrameTimeNTSC;\n\tuint32\tFrameTime;\n\n\tbool8\tStereo;\n\tbool8\tReverseStereo;\n\tbool8\tMute;\n\t/* The frontend has stated it will never need this frame's audio and that\n\t the state left behind will be discarded or restored. The DSP declines to\n\t run at all while this is set - see SPC_DSP::run. Distinct from Mute,\n\t which only says the output is not wanted. */\n\tbool8\tHardDisableAudio;\n\tint32\tInterpolationMethod;\n\n\tbool8\tTransparency;\n\tint32\tMode7Hires;\t\t\t\t// 0 off, 2 = 2x, 4 = 4x Mode 7 hires\n\tint32\tMode7HiresVertical;\t\t// 2x vertical resample post-pass\n\tint32\tMode7HiresBilinear;\t\t// 0 nearest, 1 stable, 2 smooth\n\tuint8\tBG_Forced;\n\tuint16 ForcedBackdrop;\n\n\tuint32\tInitialInfoStringTimeout;\n\tuint16\tDisplayColor;\n\tbool8\tBilinearFilter;\n\tbool\tShowOverscan;\n\n\tbool8\tMulti;\n\tchar\tCartAName[PATH_MAX + 1];\n\tchar\tCartBName[PATH_MAX + 1];\n\n\tbool8\tDisableGameSpecificHacks;\n\tbool8\tBlockInvalidVRAMAccessMaster;\n\tbool8\tBlockInvalidVRAMAccess;\n\tint32\tHDMATimingHack;\n\n\tuint32\tSkipFrames;\n\tuint32\tTurboSkipFrames;\n\tuint32\tAutoMaxSkipFrames;\n\tbool8\tTurboMode;\n\tuint32\tHighSpeedSeek;\n\n\tbool8\tDumpStreams;\n\tint\t\tDumpStreamsMaxFrames;\n\n\tchar InitialSnapshotFilename[PATH_MAX + 1];\n\tbool8\tFastSavestates;\n\n\tbool8\tApplyCheats;\n\tbool8\tNoPatch;\n\tbool8\tIgnorePatchChecksum;\n\tbool8\tIsPatched;\n\tint32\tAutoSaveDelay;\n\tbool8\tDontSaveOopsSnapshot;\n\tbool8\tUpAndDown;\n\n bool8 SeparateEchoBuffer;\n\tuint32\tSuperFXClockMultiplier;\n int OverclockMode;\n\tint\tOneClockCycle;\n\tint\tOneSlowClockCycle;\n\tint\tTwoClockCycles;\n\tint\tMaxSpriteTilesPerLine;\n\tint\tChannelsVolumePercent[9];\n};\n\nstruct SSNESGameFixes\n{\n\tuint8\tSRAMInitialValue;\n\tuint8\tUniracers;\n};\n\nvoid S9xMessage(int, int, const char *);\n\nextern struct SSettings\t\t\tSettings;\nextern struct SCPUState\t\t\tCPU;\nextern struct STimings\t\t\tTimings;\nextern struct SSNESGameFixes\tSNESGameFixes;\nextern char\t\t\t\t\t\tString[513];\n\n#endif\n" + }, + "profiles": { + "anarch": { + "core_version": null, + "emulator": "Anarch", + "type": "libretro", + "version_basis": { + "kind": "none" + } + }, + "beetle_psx": { + "core_version": "v0.9.44.1", + "emulator": "Beetle PSX", + "type": "libretro", + "version_basis": { + "artifact": "mednafen_psx_libretro v0.9.44.1.zip", + "kind": "package", + "pattern": "libretro (v[0-9.]+)\\.zip" + } + }, + "dolphin": { + "core_version": "2609-437-g3aac3f0", + "emulator": "Dolphin", + "source": "https://github.com/libretro/dolphin", + "source_commit": "3aac3f052f733f8df2f1616843a4d2e14002ed11", + "type": "standalone + libretro", + "upstream": "https://github.com/dolphin-emu/dolphin", + "version_basis": { + "distance": 437, + "kind": "tag", + "tag": "2609" + } + }, + "fbneo": { + "core_version": "v1.0.0.03", + "emulator": "FinalBurn Neo", + "source": "https://github.com/libretro/FBNeo", + "source_commit": "32958a89abd645ed20387c8d31d21ede3bc48161", + "type": "libretro", + "version_basis": { + "format": "v{1}.{2}.{3}.{4:02d}", + "kind": "file", + "path": "src/burn/version.h", + "pattern": "VER_MAJOR\\s+(\\d+)\\s+#define\\s+VER_MINOR\\s+(\\d+)\\s+#define\\s+VER_BETA\\s+(\\d+)\\s+#define\\s+VER_ALPHA\\s+(\\d+)" + } + }, + "mednafen_psx": { + "alias_of": "beetle_psx", + "core_version": "v0.9.44.1", + "emulator": "Beetle PSX", + "type": "alias", + "version_basis": { + "kind": "alias", + "of": "beetle_psx" + } + }, + "snes9x": { + "core_version": "1.63", + "emulator": "Snes9x", + "source": "https://github.com/libretro/snes9x", + "source_commit": "fae2fea08f74180759ef540ee94259213f503480", + "type": "libretro", + "upstream": "https://github.com/snes9xgit/snes9x", + "version_basis": { + "kind": "file", + "path": "snes9x.h", + "pattern": "#define\\s+VERSION\\s+\"([^\"]+)\"" + } + } + } +} diff --git a/tests/test_version_basis.py b/tests/test_version_basis.py new file mode 100644 index 000000000..bb0f01511 --- /dev/null +++ b/tests/test_version_basis.py @@ -0,0 +1,351 @@ +"""core_version is read from the code at the pin, where version_basis says. + +Four bulk pin advances moved 295 pins and no version, and 54 profiles said +"Git" or "SVN" where the code states a release. These tests read the +recorded sources offline from a fixture of forge answers, hold every profile +to a basis, and hold a pin advance to reading the version again. +""" + +from __future__ import annotations + +import contextlib +import copy +import hashlib +import io +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) + +import profile_sync # noqa: E402 +import upstream # noqa: E402 +import version_basis # noqa: E402 +from common import load_emulator_profiles # noqa: E402 +from profile_sync import ProfileReport, bump_commit # noqa: E402 + +FIXTURE = ROOT / "tests" / "fixtures" / "version_basis.json" + + +def _fixture_cache(directory: Path) -> dict: + """The fixture's forge answers, laid out as upstream caches them.""" + data = json.loads(FIXTURE.read_text(encoding="utf-8")) + for url, payload in data["api"].items(): + target = directory / "_api" / f"{hashlib.sha256(url.encode()).hexdigest()}.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(upstream.ABSENT if payload is None else json.dumps(payload), + encoding="utf-8") + for key, text in data["files"].items(): + host, owner, name, sha, path = key.split("/", 4) + repo = upstream.make_repo(host, owner, name) + target = upstream.cache_path(str(directory), repo, sha, path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(upstream.ABSENT if text is None else text, encoding="utf-8") + return data["profiles"] + + +class ReadsTheRecordedSourceOffline(unittest.TestCase): + """The check needs nothing the cache does not hold.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.cache = Path(self.tmp.name) / "cache" + self.profiles = _fixture_cache(self.cache) + + def tearDown(self): + self.tmp.cleanup() + + def _check(self, name: str, profiles: dict | None = None) -> list: + profiles = profiles or self.profiles + return version_basis.check(profiles[name], str(self.cache), True, profiles) + + def test_every_kind_reads_its_version(self): + for name in self.profiles: + with self.subTest(profile=name): + readings = self._check(name) + self.assertEqual([r.status for r in readings], [version_basis.MATCH], + readings) + + def test_a_file_basis_names_the_line_it_read(self): + (reading,) = self._check("snes9x") + self.assertEqual(reading.read, "1.63") + self.assertTrue(reading.where.endswith("snes9x.h:11"), reading.where) + + def test_groups_are_laid_out_by_the_format(self): + (reading,) = self._check("fbneo") + self.assertEqual(reading.read, "v1.0.0.03") + + def test_a_version_the_code_does_not_state_is_drift(self): + for name, stale in (("snes9x", "1.61"), ("fbneo", "v1.0.0.2"), + ("dolphin", "2609"), ("mednafen_psx", "v0.9.44"), + ("anarch", "Git")): + with self.subTest(profile=name): + profiles = copy.deepcopy(self.profiles) + profiles[name]["core_version"] = stale + (reading,) = self._check(name, profiles) + self.assertEqual(reading.status, version_basis.DRIFT) + + def test_a_tag_distance_the_forge_contradicts_is_drift(self): + profiles = copy.deepcopy(self.profiles) + profiles["dolphin"]["version_basis"]["distance"] = 400 + (reading,) = self._check("dolphin", profiles) + self.assertEqual(reading.status, version_basis.DRIFT) + self.assertEqual(dict(reading.updates)["distance"], 437) + + def test_a_pattern_that_no_longer_matches_is_unreadable(self): + profiles = copy.deepcopy(self.profiles) + profiles["snes9x"]["version_basis"]["pattern"] = "#define\\s+NOPE\\s+(\\S+)" + (reading,) = self._check("snes9x", profiles) + self.assertEqual(reading.status, version_basis.UNREADABLE) + + def test_a_profile_without_basis_is_reported(self): + profiles = copy.deepcopy(self.profiles) + del profiles["snes9x"]["version_basis"] + (reading,) = self._check("snes9x", profiles) + self.assertEqual(reading.status, version_basis.NO_BASIS) + + def test_the_command_fails_on_drift_and_passes_clean(self): + emulators = Path(self.tmp.name) / "emulators" + emulators.mkdir() + for name, profile in self.profiles.items(): + body = dict(profile, files=[] if profile["type"] != "alias" else None) + body = {k: v for k, v in body.items() if v is not None or k == "core_version"} + (emulators / f"{name}.yml").write_text(yaml.safe_dump(body), encoding="utf-8") + argv = ["profile_sync.py", "--all", "--verify-version", "--offline", + "--emulators-dir", str(emulators), "--cache-dir", str(self.cache)] + + def run() -> int: + out, err = io.StringIO(), io.StringIO() + with mock.patch.object(sys, "argv", argv), contextlib.redirect_stdout(out), \ + contextlib.redirect_stderr(err): + try: + profile_sync.main() + except SystemExit as exc: + return int(exc.code or 0) + return 0 + + import common # noqa: PLC0415 + + common._emulator_profiles_cache.clear() + self.assertEqual(run(), 0) + stale = emulators / "snes9x.yml" + stale.write_text(stale.read_text(encoding="utf-8").replace("1.63", "1.61"), + encoding="utf-8") + common._emulator_profiles_cache.clear() + self.assertEqual(run(), 1) + + +class EveryProfileSaysWhereItsVersionIsRead(unittest.TestCase): + """What can be held without a forge is held here, for every profile.""" + + @classmethod + def setUpClass(cls): + cls.profiles = load_emulator_profiles(str(ROOT / "emulators"), skip_aliases=False) + + def test_every_profile_has_a_basis(self): + missing = sorted(name for name, p in self.profiles.items() + if not version_basis.bases(p)) + self.assertEqual(missing, [], "profiles without version_basis") + + def test_no_placeholder_is_a_version(self): + named = sorted( + name for name, p in self.profiles.items() + for mode in (version_basis.bases(p) or {"": {}}) + if version_basis.is_placeholder(version_basis.declared(p, mode)) + ) + self.assertEqual(named, []) + + def test_keyed_bases_follow_the_keyed_version(self): + for name, profile in self.profiles.items(): + found = version_basis.bases(profile) + if not found or "" in found: + continue + with self.subTest(profile=name): + self.assertIsInstance(profile.get("core_version"), dict) + self.assertEqual(set(found), set(profile["core_version"])) + + def test_none_states_no_version(self): + for name, profile in self.profiles.items(): + for mode, basis in version_basis.bases(profile).items(): + if basis.get("kind") == "none": + with self.subTest(profile=name, mode=mode): + self.assertIsNone(version_basis.declared(profile, mode)) + + def test_a_tag_version_is_the_describe_form_of_its_pin(self): + for name, profile in self.profiles.items(): + for mode, basis in version_basis.bases(profile).items(): + if basis.get("kind") != "tag" or basis.get("url"): + continue + field = version_basis.PIN_FIELDS[str(basis.get("repo") or "source")] + pin = version_basis._by_mode(profile, field, mode) + if not pin: + continue + with self.subTest(profile=name, mode=mode): + self.assertEqual( + version_basis.declared(profile, mode), + version_basis.describe_form(basis["tag"], basis["distance"], pin), + ) + + def test_an_alias_carries_the_version_of_its_profile(self): + for name, profile in self.profiles.items(): + for mode, basis in version_basis.bases(profile).items(): + if basis.get("kind") != "alias": + continue + with self.subTest(profile=name): + (reading,) = [r for r in version_basis.check( + profile, "", True, self.profiles) if r.mode == mode] + self.assertEqual(reading.status, version_basis.MATCH, reading) + + def test_a_package_named_after_its_version_states_it(self): + for name, profile in self.profiles.items(): + for mode, basis in version_basis.bases(profile).items(): + if basis.get("kind") != "package" or not basis.get("pattern"): + continue + with self.subTest(profile=name): + (reading,) = [r for r in version_basis.check(profile, "", True) + if r.mode == mode] + self.assertEqual(reading.status, version_basis.MATCH, reading) + + +SAMPLE = """emulator: Sample +type: libretro +source: "https://github.com/o/n" +profiled_date: "2026-01-01" +source_commit: "aaaaaaa" +core_version: "1.0" +version_basis: + kind: file + path: "version.h" + pattern: '#define\\s+VERSION\\s+"([^"]+)"' +files: [] +""" + + +class AnAdvanceReadsTheVersionAgain(unittest.TestCase): + """A pin never moves without its version: read again, or refused.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name) / "p.yml" + self.path.write_text(SAMPLE, encoding="utf-8") + self.report = ProfileReport( + name="p", repo="o/n", pin="aaaaaaa", head="bbbbbbb", entries=[], counts={}, + skipped=profile_sync.NO_SOURCE_REF, + ) + + def tearDown(self): + self.tmp.cleanup() + + def _reading(self, status: str, value: str | None): + return version_basis.Reading("", "file", "1.0", value, "o/n@bbbbbbb:version.h:3", status) + + def test_the_version_moves_with_the_pin(self): + seen = [] + + def check(profile, cache_dir, offline, profiles=None, recompute=False): + seen.append((profile["source_commit"], recompute)) + return [self._reading(version_basis.DRIFT, "2.0")] + + with mock.patch.object(version_basis, "check", check): + self.assertTrue(bump_commit(self.path, self.report)) + written = yaml.safe_load(self.path.read_text(encoding="utf-8")) + self.assertEqual(written["source_commit"], "bbbbbbb") + self.assertEqual(written["core_version"], "2.0") + # Read at the new pin, the nearest tag asked again. + self.assertEqual(seen, [("bbbbbbb", True)]) + + def test_an_unreadable_version_holds_the_pin(self): + with mock.patch.object(version_basis, "check", + return_value=[self._reading(version_basis.UNREADABLE, None)]): + self.assertFalse(bump_commit(self.path, self.report)) + self.assertEqual(self.path.read_text(encoding="utf-8"), SAMPLE) + + def test_a_profile_without_basis_is_not_advanced(self): + self.path.write_text(SAMPLE.split("version_basis:")[0] + "files: []\n", encoding="utf-8") + before = self.path.read_text(encoding="utf-8") + self.assertFalse(bump_commit(self.path, self.report)) + self.assertEqual(self.path.read_text(encoding="utf-8"), before) + + def test_a_package_version_is_advanced_by_hand(self): + reading = version_basis.Reading("", "package", "1.0", None, "read from x.apk", + version_basis.UNCHECKED) + with mock.patch.object(version_basis, "check", return_value=[reading]): + self.assertFalse(bump_commit(self.path, self.report)) + + +class TheWriterKeepsTheDocument(unittest.TestCase): + def test_keyed_versions_and_bases_round_trip(self): + text = ('emulator: X\nsource_commit: "abc"\ncore_version:\n standalone: "1"\n' + ' libretro: "2"\ndisplay_name: X\n') + basis = {"standalone": {"kind": "tag", "tag": "2407", "distance": 0}, + "libretro": {"kind": "none"}} + written = profile_sync.set_version_fields( + text, {"standalone": "2407", "libretro": None}, basis) + document = yaml.safe_load(written) + self.assertEqual(document["core_version"], {"standalone": "2407", "libretro": None}) + self.assertEqual(document["version_basis"], basis) + self.assertEqual(document["display_name"], "X") + self.assertIn('tag: "2407"', written) + + def test_a_missing_version_is_written_after_the_pins(self): + text = 'emulator: X\nsource_commit: "abc"\nfiles: []\n' + written = profile_sync.set_version_fields(text, None, {"kind": "none"}) + self.assertEqual(written.splitlines()[2:4], ["core_version: null", "version_basis:"]) + + +class TheSchemaRefusesPlaceholders(unittest.TestCase): + def setUp(self): + try: + import jsonschema # noqa: PLC0415 + except ImportError: + self.skipTest("jsonschema is not installed") + schema = json.loads((ROOT / "schemas" / "emulator.schema.json").read_text()) + self.validator = jsonschema.Draft202012Validator(schema) + + def _valid(self, **fields) -> bool: + profile = {"emulator": "X", "type": "libretro", "files": [], **fields} + return self.validator.is_valid(profile) + + def test_placeholders(self): + for value in ("Git", "GIT", "svn", "SVN (2015 snapshot)", "unknown", "master", + "latest", "dev", "main", "HEAD", ""): + with self.subTest(value=value): + self.assertFalse(self._valid(core_version=value)) + + def test_versions(self): + for value in ("1.63", "v1.0.0.03", "2609-437-g3aac3f0", "0.37b5", "r22", + "2026.4.1git", "developer-1", "Dev-4.0.0"): + with self.subTest(value=value): + self.assertTrue(self._valid(core_version=value)) + + def test_none_means_null(self): + self.assertTrue(self._valid(core_version=None, version_basis={"kind": "none"})) + self.assertFalse(self._valid(core_version="1.0", version_basis={"kind": "none"})) + + def test_each_kind_names_what_it_reads(self): + self.assertFalse(self._valid(version_basis={"kind": "file", "path": "v.h"})) + self.assertFalse(self._valid(version_basis={"kind": "tag", "tag": "v1"})) + self.assertFalse(self._valid(version_basis={"kind": "package"})) + self.assertTrue(self._valid(core_version="1", version_basis={"kind": "tag", "tag": "1", + "distance": 0})) + + +class VersionModesRefuseOtherFlags(unittest.TestCase): + def test_refused(self): + for extra in (["--bump-commit"], ["--check-version"], ["--strict"]): + argv = ["profile_sync.py", "--all", "--verify-version", *extra] + with self.subTest(flags=extra), mock.patch.object(sys, "argv", argv), \ + contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as ctx: + profile_sync.main() + self.assertEqual(ctx.exception.code, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 84c1bd8cf..bd9a39108 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -61,7 +61,7 @@ Every module in `tests/`, by the area it guards. | Real-condition campaign | synthetic profiles, records and content folders, a fake host for load, disk, clock and commands, and whole passes against a libretro core built from C when a compiler and strace exist | `test_campaign` | | Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` | -| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | +| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_version_basis`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | ## Test architecture