From b3d104f628f6b9343036cd7e0af08153a601a4f7 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 15 Feb 2026 04:26:33 +0800 Subject: [PATCH 1/3] test: add comprehensive tests --- patchable/tests/impl_from.rs | 52 +++++++++++++++ patchable/tests/no_serde.rs | 77 ++++++++++++++++++++++ patchable/tests/serde.rs | 120 +++++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 patchable/tests/no_serde.rs diff --git a/patchable/tests/impl_from.rs b/patchable/tests/impl_from.rs index 9df87de..fb3c940 100644 --- a/patchable/tests/impl_from.rs +++ b/patchable/tests/impl_from.rs @@ -14,6 +14,22 @@ struct Outer { extra: u32, } +#[patchable_model] +#[derive(Clone, Debug, PartialEq)] +struct TupleOuter(#[patchable] InnerType, u32); + +#[patchable_model] +#[derive(Clone, Debug, PartialEq)] +struct UnitOuter; + +#[patchable_model] +#[derive(Clone, Debug, PartialEq)] +struct SkipOuter { + value: i32, + #[patchable(skip)] + untouched: u32, +} + #[test] fn test_from_struct_to_patch() { let original = Outer { @@ -30,3 +46,39 @@ fn test_from_struct_to_patch() { target.patch(patch); assert_eq!(target, original); } + +#[test] +fn test_from_tuple_struct_to_patch() { + let original = TupleOuter(Inner { value: 42 }, 7); + let patch: as Patchable>::Patch = original.clone().into(); + let mut target = TupleOuter(Inner { value: 0 }, 0); + + target.patch(patch); + assert_eq!(target, original); +} + +#[test] +fn test_from_unit_struct_to_patch() { + let patch: ::Patch = UnitOuter.into(); + let mut target = UnitOuter; + + target.patch(patch); + assert_eq!(target, UnitOuter); +} + +#[test] +fn test_from_patch_respects_skipped_fields() { + let original = SkipOuter { + value: 10, + untouched: 7, + }; + let patch: ::Patch = original.into(); + let mut target = SkipOuter { + value: 0, + untouched: 99, + }; + + target.patch(patch); + assert_eq!(target.value, 10); + assert_eq!(target.untouched, 99); +} diff --git a/patchable/tests/no_serde.rs b/patchable/tests/no_serde.rs new file mode 100644 index 0000000..50b3bd6 --- /dev/null +++ b/patchable/tests/no_serde.rs @@ -0,0 +1,77 @@ +use patchable::patchable_model; + +fn plus_one(x: i32) -> i32 { + x + 1 +} + +#[patchable_model] +#[derive(Clone, Debug, PartialEq)] +struct PlainInner { + value: i32, +} + +#[patchable_model] +#[derive(Clone, Debug, PartialEq)] +struct PlainOuter { + #[patchable] + inner: T, + version: u32, +} + +#[derive(Clone, Debug, PartialEq, patchable::Patchable, patchable::Patch)] +struct DeriveOnlyStruct { + value: i32, + #[patchable(skip)] + sticky: u32, +} + +#[patchable_model] +#[derive(Clone, Debug)] +struct AllSkipped { + #[patchable(skip)] + marker: fn(i32) -> i32, +} + +#[test] +fn test_patchable_model_and_derive_generate_patch_types_without_serde() { + fn assert_patchable() {} + fn assert_patch_debug() + where + T::Patch: core::fmt::Debug, + { + } + + assert_patchable::(); + assert_patchable::>(); + assert_patchable::(); + assert_patchable::(); + + assert_patch_debug::(); + assert_patch_debug::>(); + assert_patch_debug::(); + assert_patch_debug::(); +} + +#[test] +fn test_patch_methods_are_generated_without_serde() { + let _: fn( + &mut PlainOuter, + as patchable::Patchable>::Patch, + ) = as patchable::Patch>::patch; + + let _: fn(&mut DeriveOnlyStruct, ::Patch) = + ::patch; + + let _: fn(&mut AllSkipped, ::Patch) = + ::patch; + + let outer_patch_name = + std::any::type_name::< as patchable::Patchable>::Patch>(); + let derive_patch_name = + std::any::type_name::<::Patch>(); + assert!(outer_patch_name.contains("PlainOuter")); + assert!(derive_patch_name.contains("DeriveOnlyStruct")); + + let value = AllSkipped { marker: plus_one }; + assert_eq!((value.marker)(1), 2); +} diff --git a/patchable/tests/serde.rs b/patchable/tests/serde.rs index 22236b2..30de2d1 100644 --- a/patchable/tests/serde.rs +++ b/patchable/tests/serde.rs @@ -138,9 +138,129 @@ fn test_skip_serializing_field_is_excluded() { skipped: 5, value: 10, }; + let json = serde_json::to_value(&s).unwrap(); + assert_eq!(json, serde_json::json!({ "value": 10 })); + let patch: ::Patch = serde_json::from_str(r#"{"value": 42}"#).unwrap(); s.patch(patch); assert_eq!(s.skipped, 5); assert_eq!(s.value, 42); } + +#[derive(Clone, Debug, Serialize, patchable::Patchable, patchable::Patch)] +struct DeriveOnlySkipBehavior { + #[patchable(skip)] + hidden: i32, + shown: i32, +} + +#[test] +fn test_direct_derive_does_not_add_serde_skip() { + let value = DeriveOnlySkipBehavior { + hidden: 7, + shown: 11, + }; + let json = serde_json::to_value(&value).unwrap(); + assert_eq!(json, serde_json::json!({ "hidden": 7, "shown": 11 })); + + let patch: ::Patch = + serde_json::from_str(r#"{"shown": 5}"#).unwrap(); + let mut target = DeriveOnlySkipBehavior { + hidden: 99, + shown: 0, + }; + target.patch(patch); + + assert_eq!(target.hidden, 99); + assert_eq!(target.shown, 5); +} + +#[patchable_model] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +struct Counter { + value: i32, +} + +#[patchable_model] +#[derive(Clone, Debug, PartialEq, Eq)] +struct MixedGenericUsage { + history: Vec, + #[patchable] + current: T, +} + +#[test] +fn test_mixed_generic_usage_patches_and_replaces() { + let mut value = MixedGenericUsage { + history: vec![Counter { value: 1 }], + current: Counter { value: 2 }, + }; + let patch: as Patchable>::Patch = + serde_json::from_str(r#"{"history":[{"value":10},{"value":20}],"current":{"value":99}}"#) + .unwrap(); + + value.patch(patch); + assert_eq!( + value.history, + vec![Counter { value: 10 }, Counter { value: 20 }] + ); + assert_eq!(value.current, Counter { value: 99 }); +} + +#[patchable_model] +#[derive(Clone, Debug, PartialEq, Eq)] +struct ExistingWhereTrailing +where + U: Default, +{ + #[patchable] + inner: T, + marker: U, +} + +#[test] +fn test_existing_where_clause_with_trailing_comma() { + let mut value = ExistingWhereTrailing { + inner: Counter { value: 1 }, + marker: (), + }; + let patch: as Patchable>::Patch = + serde_json::from_str(r#"{"inner":{"value":5},"marker":null}"#).unwrap(); + + value.patch(patch); + assert_eq!( + value, + ExistingWhereTrailing { + inner: Counter { value: 5 }, + marker: (), + } + ); +} + +#[patchable_model] +#[derive(Clone, Debug, PartialEq, Eq)] +struct ExistingWhereNoTrailing +where + T: Clone, +{ + #[patchable] + inner: T, +} + +#[test] +fn test_existing_where_clause_without_trailing_comma() { + let mut value = ExistingWhereNoTrailing { + inner: Counter { value: 3 }, + }; + let patch: as Patchable>::Patch = + serde_json::from_str(r#"{"inner":{"value":8}}"#).unwrap(); + + value.patch(patch); + assert_eq!( + value, + ExistingWhereNoTrailing { + inner: Counter { value: 8 }, + } + ); +} From 6a98ce922fc2916af6e505f7a4721802832665b0 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 15 Feb 2026 04:43:18 +0800 Subject: [PATCH 2/3] test: add code coverage reporting and regression checks to CI --- .github/workflows/ci.yaml | 156 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 51197ec..59b54b7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,6 +2,10 @@ name: CI on: push: + branches: [main] + pull_request: + branches: [main] + types: [opened, synchronize, reopened] env: CARGO_TERM_COLOR: always @@ -34,3 +38,155 @@ jobs: - name: cargo test with feature impl_from run: cargo test --verbose --package patchable --features impl_from + + coverage-report: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: llvm-tools-preview + cache-key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Run merged coverage test matrix + run: | + cargo llvm-cov clean --workspace + cargo llvm-cov --workspace --no-default-features --tests --no-report + cargo llvm-cov --workspace --tests --no-report + cargo llvm-cov --workspace --features impl_from --tests --no-report + + - name: Generate HTML and JSON reports + run: | + cargo llvm-cov report --html --output-dir target/llvm-cov + cargo llvm-cov report --json --summary-only --output-path target/llvm-cov/summary.json + + - name: Capture current line coverage + run: | + CURRENT_LINE_COVERAGE=$(jq -r '.data[0].totals.lines.percent' target/llvm-cov/summary.json) + printf "%s\n" "${CURRENT_LINE_COVERAGE}" > target/llvm-cov/current_line_coverage.txt + { + echo "## Coverage Report" + echo "" + echo "Current total line coverage: ${CURRENT_LINE_COVERAGE}%" + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Upload HTML report artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: target/llvm-cov/html + if-no-files-found: error + retention-days: 14 + + - name: Upload coverage summary artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-summary + path: | + target/llvm-cov/summary.json + target/llvm-cov/current_line_coverage.txt + if-no-files-found: error + retention-days: 14 + + coverage-compare: + runs-on: ubuntu-latest + needs: coverage-report + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Download current coverage summary artifact + uses: actions/download-artifact@v4 + with: + name: coverage-summary + path: target/coverage-current + + - name: Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: llvm-tools-preview + cache-key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Compare current coverage against base + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA_PR: ${{ github.event.pull_request.base.sha }} + BASE_SHA_PUSH: ${{ github.event.before }} + run: | + set -euo pipefail + + if [ "${EVENT_NAME}" = "pull_request" ]; then + BASE_SHA="${BASE_SHA_PR}" + else + BASE_SHA="${BASE_SHA_PUSH}" + fi + + if [ -z "${BASE_SHA}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then + echo "No valid base SHA available. Skipping coverage comparison." + exit 0 + fi + + if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + echo "Base SHA ${BASE_SHA} not found locally. Fetching remote branch refs..." + git fetch --no-tags origin "+refs/heads/*:refs/remotes/origin/*" || true + fi + + if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + echo "Base SHA ${BASE_SHA} is unavailable (likely rewritten history). Skipping coverage comparison." + exit 0 + fi + + CURRENT_COVERAGE_FILE=$(find target/coverage-current -name current_line_coverage.txt -print -quit) + if [ -z "${CURRENT_COVERAGE_FILE}" ]; then + echo "Could not find current_line_coverage.txt in downloaded artifact." + exit 1 + fi + CURRENT_LINE_COVERAGE=$(cat "${CURRENT_COVERAGE_FILE}") + + if ! git checkout --force "${BASE_SHA}"; then + echo "Checkout of ${BASE_SHA} failed. Trying to fetch commit object directly..." + if ! git fetch --no-tags --depth=1 origin "${BASE_SHA}"; then + echo "Could not fetch base SHA ${BASE_SHA}. Skipping coverage comparison." + exit 0 + fi + git checkout --force "${BASE_SHA}" || { + echo "Checkout still failed after fetch. Skipping coverage comparison." + exit 0 + } + fi + + cargo llvm-cov clean --workspace + cargo llvm-cov --workspace --no-default-features --tests --no-report + cargo llvm-cov --workspace --tests --no-report + cargo llvm-cov --workspace --features impl_from --tests --no-report + mkdir -p target/llvm-cov + cargo llvm-cov report --json --summary-only --output-path target/llvm-cov/base-summary.json + + BASE_LINE_COVERAGE=$(jq -r '.data[0].totals.lines.percent' target/llvm-cov/base-summary.json) + DELTA=$(awk -v current="${CURRENT_LINE_COVERAGE}" -v base="${BASE_LINE_COVERAGE}" 'BEGIN { printf "%.4f", current - base }') + + { + echo "## Coverage Comparison" + echo "" + echo "Current line coverage: ${CURRENT_LINE_COVERAGE}%" + echo "Base line coverage: ${BASE_LINE_COVERAGE}%" + echo "Delta: ${DELTA}%" + } >> "${GITHUB_STEP_SUMMARY}" + + if awk -v current="${CURRENT_LINE_COVERAGE}" -v base="${BASE_LINE_COVERAGE}" 'BEGIN { exit (current + 1e-9 < base) ? 0 : 1 }'; then + echo "Coverage regression detected: current (${CURRENT_LINE_COVERAGE}%) < base (${BASE_LINE_COVERAGE}%)." + exit 1 + fi + + echo "Coverage did not regress." From 90ec5bb01bf3dbb30eda27890f211f89b18cef3f Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 15 Feb 2026 05:10:50 +0800 Subject: [PATCH 3/3] ci: enhance CI coverage check Use detailed summary.json for multiple metrics and implement a tiered regression policy with relaxed thresholds. --- .github/workflows/ci.yaml | 59 ++++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 59b54b7..7c7d430 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -147,12 +147,15 @@ jobs: exit 0 fi - CURRENT_COVERAGE_FILE=$(find target/coverage-current -name current_line_coverage.txt -print -quit) - if [ -z "${CURRENT_COVERAGE_FILE}" ]; then - echo "Could not find current_line_coverage.txt in downloaded artifact." + CURRENT_SUMMARY_FILE=$(find target/coverage-current -name summary.json -print -quit) + if [ -z "${CURRENT_SUMMARY_FILE}" ]; then + echo "Could not find summary.json in downloaded artifact." exit 1 fi - CURRENT_LINE_COVERAGE=$(cat "${CURRENT_COVERAGE_FILE}") + + CURRENT_LINE_COVERAGE=$(jq -r '.data[0].totals.lines.percent' "${CURRENT_SUMMARY_FILE}") + CURRENT_REGION_COVERAGE=$(jq -r '.data[0].totals.regions.percent' "${CURRENT_SUMMARY_FILE}") + CURRENT_FUNCTION_COVERAGE=$(jq -r '.data[0].totals.functions.percent' "${CURRENT_SUMMARY_FILE}") if ! git checkout --force "${BASE_SHA}"; then echo "Checkout of ${BASE_SHA} failed. Trying to fetch commit object directly..." @@ -174,19 +177,55 @@ jobs: cargo llvm-cov report --json --summary-only --output-path target/llvm-cov/base-summary.json BASE_LINE_COVERAGE=$(jq -r '.data[0].totals.lines.percent' target/llvm-cov/base-summary.json) - DELTA=$(awk -v current="${CURRENT_LINE_COVERAGE}" -v base="${BASE_LINE_COVERAGE}" 'BEGIN { printf "%.4f", current - base }') + BASE_REGION_COVERAGE=$(jq -r '.data[0].totals.regions.percent' target/llvm-cov/base-summary.json) + BASE_FUNCTION_COVERAGE=$(jq -r '.data[0].totals.functions.percent' target/llvm-cov/base-summary.json) + + LINE_DELTA=$(awk -v current="${CURRENT_LINE_COVERAGE}" -v base="${BASE_LINE_COVERAGE}" 'BEGIN { printf "%.4f", current - base }') + REGION_DELTA=$(awk -v current="${CURRENT_REGION_COVERAGE}" -v base="${BASE_REGION_COVERAGE}" 'BEGIN { printf "%.4f", current - base }') + FUNCTION_DELTA=$(awk -v current="${CURRENT_FUNCTION_COVERAGE}" -v base="${BASE_FUNCTION_COVERAGE}" 'BEGIN { printf "%.4f", current - base }') { echo "## Coverage Comparison" echo "" + echo "Current function coverage: ${CURRENT_FUNCTION_COVERAGE}%" + echo "Base function coverage: ${BASE_FUNCTION_COVERAGE}%" + echo "Function delta: ${FUNCTION_DELTA}%" + echo "" echo "Current line coverage: ${CURRENT_LINE_COVERAGE}%" echo "Base line coverage: ${BASE_LINE_COVERAGE}%" - echo "Delta: ${DELTA}%" + echo "Line delta: ${LINE_DELTA}%" + echo "" + echo "Current region coverage: ${CURRENT_REGION_COVERAGE}%" + echo "Base region coverage: ${BASE_REGION_COVERAGE}%" + echo "Region delta: ${REGION_DELTA}%" } >> "${GITHUB_STEP_SUMMARY}" - if awk -v current="${CURRENT_LINE_COVERAGE}" -v base="${BASE_LINE_COVERAGE}" 'BEGIN { exit (current + 1e-9 < base) ? 0 : 1 }'; then - echo "Coverage regression detected: current (${CURRENT_LINE_COVERAGE}%) < base (${BASE_LINE_COVERAGE}%)." - exit 1 + if awk -v current="${CURRENT_LINE_COVERAGE}" -v base="${BASE_LINE_COVERAGE}" 'BEGIN { exit (current + 1e-9 >= base) ? 0 : 1 }'; then + echo "Coverage status: very good (no line coverage regression)." + { + echo "" + echo "Result: very good" + echo "Reason: line coverage did not regress." + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 fi - echo "Coverage did not regress." + echo "Coverage regression detected. Evaluating relaxed thresholds..." + if awk -v func="${CURRENT_FUNCTION_COVERAGE}" -v line="${CURRENT_LINE_COVERAGE}" -v region="${CURRENT_REGION_COVERAGE}" 'BEGIN { exit (func + 1e-9 >= 100.0 && line + 1e-9 >= 90.0 && region + 1e-9 >= 90.0) ? 0 : 1 }'; then + echo "Coverage status: acceptable regression (functions >= 100%, lines >= 90%, regions >= 90%)." + { + echo "" + echo "Result: acceptable" + echo "Reason: regression exists, but quality thresholds are satisfied." + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + echo "Coverage status: failed (regression with thresholds not satisfied)." + { + echo "" + echo "Result: failed" + echo "Reason: regression detected and quality thresholds not met." + echo "Required for acceptable regression: functions >= 100%, lines >= 90%, regions >= 90%." + } >> "${GITHUB_STEP_SUMMARY}" + exit 1