//! The rung ladder against the patches this tier actually ships. //! //! `src/patch.rs` proves the engine's rules on synthetic diffs, which is the right place to make a //! rule fail on purpose. This file proves the same engine handles the three real ones — because //! every property that matters here is a property of *those* files rather than of unified diffs in //! general: //! //! - `commandlogging-event.patch` has no `diff --git` and no `index` line, so rung 1 is //! structurally unavailable for it and it must still apply through rung 2. //! - `playervendor-sale-eventsink.patch` carries four hunks against one file, so the all-or-nothing //! rule, the descending-offset splice and the overlap check all get exercised at once. //! - Every one of the three is CRLF in a Windows checkout and LF in the tarball CI builds, and both //! spellings have to behave identically. //! //! The fixtures are copies of `servuo-plugins/patches/*.patch`. The *targets* are synthesized //! rather than vendored: the real ones are ServUO's own sources, and reproducing the region a hunk //! expects — surrounded by filler that is deliberately not ServUO — is a stricter test of a //! content match than pasting in 2,600 lines that happen to contain it. use rgdeploy::diff; use rgdeploy::patch::{self, Refusal, Resolution, Rung}; use rgdeploy::util::git_blob_hash; const PATCHES: &[(&str, &str)] = &[ ("commandlogging-event.patch", "Scripts/Commands/Logging.cs"), ("playervendor-sale-eventsink.patch", "Server/EventSink.cs"), ( "playervendor-sale-gump.patch", "Scripts/Gumps/PlayerVendorGumps.cs", ), ]; fn fixture(name: &str) -> Vec { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("fixtures") .join(name); std::fs::read(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) } fn parse(name: &str) -> diff::FilePatch { diff::parse(&fixture(name)) .unwrap_or_else(|e| panic!("{name} did not parse: {e}")) .single_file() .unwrap_or_else(|e| panic!("{name} is not single-target: {e}")) .clone() } /// A stand-in for the stock ServUO file: each hunk's pre-image, in order, separated by filler that /// could not be mistaken for context. fn synthesize_target(file: &diff::FilePatch, eol: &str) -> Vec { let mut out = String::new(); for (i, hunk) in file.hunks.iter().enumerate() { for f in 0..12 { out.push_str(&format!("// unrelated shard code {i}/{f}{eol}")); } for line in hunk.pre_image() { out.push_str(&String::from_utf8_lossy(line)); out.push_str(eol); } } out.push_str(&format!("// end of file{eol}")); out.into_bytes() } fn edits_of(resolution: &Resolution) -> &[patch::Edit] { match resolution { Resolution::Applicable { edits, .. } => edits, other => panic!("expected an applicable resolution, got {other:?}"), } } #[test] fn every_shipped_patch_parses_and_names_its_declared_target() { for (name, target) in PATCHES { let file = parse(name); assert_eq!(&file.path, target, "{name}"); assert!(!file.hunks.is_empty(), "{name}"); assert!( file.hunks.iter().any(|h| !h.is_noop()), "{name} changes nothing" ); } } #[test] fn a_patch_without_an_index_line_still_applies_through_rung_two() { // commandlogging-event.patch is a plain ---/+++ diff. Rung 1 cannot be reached for it at all, // which must be a fact the tier reports rather than a reason to skip the patch. let file = parse("commandlogging-event.patch"); assert_eq!(file.pre_blob, None, "this fixture has no index line"); let target = synthesize_target(&file, "\r\n"); let resolution = patch::resolve(&file, &target); assert_eq!(resolution.rung(), Some(Rung::RegionMatch)); let patched = patch::apply(&target, edits_of(&resolution)); let text = String::from_utf8(patched).unwrap(); assert!( text.contains("public static event Action OnWrite;"), "{text}" ); // The `m_Enabled` early return is deleted from the two-argument overload — a removal, not just // an insertion, so the splice is doing more than appending. assert_eq!(text.matches("if (!m_Enabled)").count(), 1, "{text}"); } #[test] fn the_four_hunk_patch_applies_all_of_them_at_the_right_offsets() { // EventSink.cs is the one with several hunks in one file: the descending-offset splice, the // overlap check and the all-or-nothing rule are all exercised together here. let file = parse("playervendor-sale-eventsink.patch"); assert_eq!(file.hunks.len(), 4); let target = synthesize_target(&file, "\r\n"); let resolution = patch::resolve(&file, &target); assert_eq!(resolution.rung(), Some(Rung::RegionMatch)); assert_eq!(resolution.placements().len(), 4); // Every hunk landed somewhere different, and in the order the diff declares them. let lines: Vec = resolution .placements() .iter() .map(|p| p.matched_line) .collect(); assert!(lines.windows(2).all(|w| w[0] < w[1]), "{lines:?}"); let text = String::from_utf8(patch::apply(&target, edits_of(&resolution))).unwrap(); for expected in [ "public delegate void PlayerVendorSaleEventHandler(PlayerVendorSaleEventArgs e);", "public class PlayerVendorSaleEventArgs : EventArgs", "public static event PlayerVendorSaleEventHandler PlayerVendorSale;", "public static void InvokePlayerVendorSale(PlayerVendorSaleEventArgs e)", ] { assert!(text.contains(expected), "missing: {expected}"); } } #[test] fn a_stock_target_reaches_rung_one() { // The synthesized file is not ServUO's, so its blob hash is not the one the diff records. Feed // the diff the hash of the file it is about to be resolved against, which is exactly the // situation on a genuinely stock tree. for (name, _) in PATCHES { let file = parse(name); let target = synthesize_target(&file, "\r\n"); let stated = diff::FilePatch { pre_blob: Some(git_blob_hash(&target)[..7].to_string()), ..file }; assert_eq!( patch::resolve(&stated, &target).rung(), Some(Rung::StockHash), "{name}" ); } } #[test] fn applying_twice_is_a_no_op_for_every_shipped_patch() { // The idempotence promise: `install` is documented as safe to re-run, and the tier is the part // of it that edits files the operator owns. for (name, _) in PATCHES { let file = parse(name); let target = synthesize_target(&file, "\r\n"); let first = patch::apply(&target, edits_of(&patch::resolve(&file, &target))); let second = patch::resolve(&file, &first); assert_eq!(second.rung(), Some(Rung::AlreadyPresent), "{name}"); assert!( matches!(second, Resolution::AlreadyPresent { .. }), "{name} would be applied a second time" ); } } #[test] fn an_edit_inside_a_patched_region_is_refused_for_every_shipped_patch() { // Rung 3 is the outcome most real shards will see on at least one patch, so it must be the one // that never writes. The edit is placed on the first line the hunk actually removes or keeps. for (name, _) in PATCHES { let file = parse(name); let target = synthesize_target(&file, "\n"); let anchor = file.hunks[0] .pre_image() .iter() .map(|l| String::from_utf8_lossy(l).to_string()) .find(|l| l.trim().len() > 12) .expect("a substantial context line to vandalize"); let vandalized = String::from_utf8_lossy(&target) .replacen(&anchor, &format!("{anchor} /* operator's own change */"), 1) .into_bytes(); assert_ne!(vandalized, target, "{name}: the fixture was not modified"); match patch::resolve(&file, &vandalized) { Resolution::Refused(Refusal::RegionModified { .. }) => {} other => panic!("{name}: expected a refusal, got {other:?}"), } } } #[test] fn line_endings_do_not_change_the_verdict_or_the_result() { // A patch is CRLF in a Windows checkout and LF in the tarball, and a target may be either. The // rung reached must not depend on that, and the patched file must keep the ending it had. for (name, _) in PATCHES { let raw = fixture(name); let as_lf = String::from_utf8_lossy(&raw) .replace("\r\n", "\n") .into_bytes(); let as_crlf = String::from_utf8_lossy(&as_lf) .replace('\n', "\r\n") .into_bytes(); let from_lf = diff::parse(&as_lf).unwrap().single_file().unwrap().clone(); let from_crlf = diff::parse(&as_crlf) .unwrap() .single_file() .unwrap() .clone(); assert_eq!( from_lf, from_crlf, "{name}: the two spellings parsed differently" ); for eol in ["\n", "\r\n"] { let target = synthesize_target(&from_lf, eol); let resolution = patch::resolve(&from_lf, &target); assert_eq!(resolution.rung(), Some(Rung::RegionMatch), "{name} {eol:?}"); let patched = patch::apply(&target, edits_of(&resolution)); let newlines = patched.iter().filter(|b| **b == b'\n').count(); let crlfs = patched.windows(2).filter(|w| w == b"\r\n").count(); if eol == "\r\n" { assert_eq!(crlfs, newlines, "{name}: LF islands in a CRLF file"); } else { assert_eq!(crlfs, 0, "{name}: CR appeared in an LF file"); } } } } #[test] fn the_tier_the_release_workflow_emits_is_the_one_this_installer_reads() { // `patch_tier.json` is the literal output of the jq filter in // servuo-plugins/.gitea/workflows/release.yml, run over that repo's patches/tier.json. It is // checked in so the two repos cannot drift apart quietly: a renamed field there would fail // here rather than producing an empty tier on an operator's shard, where the only symptom is // a patch tier that is never offered. let json = std::fs::read_to_string( std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("fixtures") .join("patch_tier.json"), ) .unwrap(); // Deserialized through Manifest, not through Tier, because the field's name and its // `Option`-ness are half of the contract. let manifest: serde_json::Value = serde_json::json!({ "component": "servuo-plugins-overlay", "version": "0.2.0", "commit": "0000000", "repo": "RunicGateway/servuo-plugins", "protocol": 3, "servuo": { "min_version": "57.4", "patches_verified_against": "57.4" }, "patch_tier": serde_json::from_str::(&json).unwrap(), "files": {} }); let manifest: rgdeploy::overlay::Manifest = serde_json::from_value(manifest).unwrap(); let declared = manifest.patch_tier.expect("patch_tier must deserialize"); // The declared tier and the built-in fallback have to describe the same release, or an // installer would behave differently against v0.1.1 and v0.2.0 of the same overlay. assert_eq!(declared, rgdeploy::patch::Tier::builtin()); // ...and everything it names must be resolvable against the patches that ship. for feature in &declared.features { for declared_patch in &feature.patches { let file = parse(&declared_patch.file.replace("patches/", "")); assert_eq!(file.path, declared_patch.target, "{}", declared_patch.name); } for companion in &feature.companions { assert!(companion.file.starts_with("patches/"), "{companion:?}"); } } } #[test] fn the_builtin_tier_names_exactly_the_patches_that_ship() { // The fallback for overlay releases older than `patch_tier` in the manifest. It has to describe // the release it stands in for, and the fixtures here are that release's patches. let tier = rgdeploy::patch::Tier::resolve(None); let mut declared: Vec = tier .features .iter() .flat_map(|f| f.patches.iter()) .map(|p| p.file.replace("patches/", "")) .collect(); declared.sort(); let mut shipped: Vec = PATCHES.iter().map(|(n, _)| n.to_string()).collect(); shipped.sort(); assert_eq!(declared, shipped); for feature in &tier.features { for declared in &feature.patches { let file = parse(&declared.file.replace("patches/", "")); assert_eq!( file.path, declared.target, "{}: the fallback declares a target the diff does not edit", declared.name ); } } }