| """ |
| Tests for src/workflows/microarray.py — expression-level functions. |
| |
| All tests use small in-memory toy DataFrames or temporary CSV files. |
| No AnnData, scanpy, or heavy bioinformatics dependencies required. |
| |
| Toy dataset |
| ----------- |
| 10 samples (S001–S010), 5 genes (GENE_A – GENE_E). |
| Group A: S001–S005 (GENE_A intentionally up-regulated by +2 units). |
| Group B: S006–S010. |
| Values are in a log2-like range (8–12) with small Gaussian noise. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import sys |
| import tempfile |
| from pathlib import Path |
|
|
| import numpy as np |
| import pandas as pd |
| import pytest |
|
|
| sys.path.insert(0, str(Path(__file__).parent.parent)) |
|
|
| from src.workflows.microarray import ( |
| collapse_duplicate_genes, |
| detect_log_scale, |
| harmonize_expression_and_metadata, |
| load_expression_matrix, |
| prepare_gene_level_statistics, |
| ) |
|
|
| |
| |
| |
|
|
| _RNG = np.random.default_rng(42) |
| _SAMPLE_IDS = [f"S{i:03d}" for i in range(1, 11)] |
| _GENES = ["GENE_A", "GENE_B", "GENE_C", "GENE_D", "GENE_E"] |
|
|
| |
| _BASE = _RNG.normal(loc=10.0, scale=0.4, size=(10, 5)) |
| |
| _BASE[:5, 0] += 2.0 |
|
|
| EXPR = pd.DataFrame(_BASE, index=_SAMPLE_IDS, columns=_GENES) |
| META = pd.DataFrame( |
| {"group": ["A"] * 5 + ["B"] * 5, "tissue": ["tumor"] * 10}, |
| index=_SAMPLE_IDS, |
| ) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _write_csv(df: pd.DataFrame, suffix: str = ".csv") -> str: |
| """Write a DataFrame to a temp file and return the path.""" |
| tmp = tempfile.NamedTemporaryFile(suffix=suffix, mode="w", delete=False, encoding="utf-8") |
| sep = "\t" if suffix in (".tsv", ".txt") else "," |
| df.to_csv(tmp.name, sep=sep) |
| tmp.close() |
| return tmp.name |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestLoadExpressionMatrix: |
| def test_loads_csv(self): |
| path = _write_csv(EXPR, ".csv") |
| result = load_expression_matrix(path) |
| assert result["n_samples"] == 10 |
| assert result["n_features"] == 5 |
|
|
| def test_loads_tsv(self): |
| path = _write_csv(EXPR, ".tsv") |
| result = load_expression_matrix(path) |
| assert result["n_samples"] == 10 |
| assert result["n_features"] == 5 |
|
|
| def test_returns_dataframe(self): |
| path = _write_csv(EXPR) |
| result = load_expression_matrix(path) |
| assert isinstance(result["dataframe"], pd.DataFrame) |
|
|
| def test_sample_ids_preserved(self): |
| path = _write_csv(EXPR) |
| result = load_expression_matrix(path) |
| assert list(result["dataframe"].index) == _SAMPLE_IDS |
|
|
| def test_gene_names_preserved(self): |
| path = _write_csv(EXPR) |
| result = load_expression_matrix(path) |
| assert list(result["dataframe"].columns) == _GENES |
|
|
| def test_sample_id_sample_field(self): |
| path = _write_csv(EXPR) |
| result = load_expression_matrix(path) |
| assert len(result["sample_id_sample"]) <= 5 |
| assert all(isinstance(s, str) for s in result["sample_id_sample"]) |
|
|
| def test_non_numeric_columns_dropped_with_warning(self): |
| df_mixed = EXPR.copy() |
| df_mixed["notes"] = "text" |
| path = _write_csv(df_mixed) |
| result = load_expression_matrix(path) |
| assert "notes" not in result["dataframe"].columns |
| assert any("non-numeric" in w.lower() for w in result["warnings"]) |
|
|
| def test_file_not_found_raises(self): |
| with pytest.raises(FileNotFoundError): |
| load_expression_matrix("/nonexistent/path/file.csv") |
|
|
| def test_transposed_orientation_warns(self): |
| |
| |
| big_df = pd.DataFrame( |
| _RNG.normal(10, 0.5, size=(200, 5)), |
| index=[f"GENE_{i}" for i in range(200)], |
| columns=[f"S{i:03d}" for i in range(5)], |
| ) |
| path = _write_csv(big_df) |
| result = load_expression_matrix(path) |
| assert any("transpos" in w.lower() for w in result["warnings"]) |
|
|
| def test_no_warning_for_normal_orientation(self): |
| path = _write_csv(EXPR) |
| result = load_expression_matrix(path) |
| |
| assert not any("transpos" in w.lower() for w in result["warnings"]) |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestHarmonizeExpressionAndMetadata: |
| def test_fully_aligned_returns_same_size(self): |
| result = harmonize_expression_and_metadata(EXPR, META) |
| assert result["n_aligned_samples"] == 10 |
| assert result["n_expression_only"] == 0 |
| assert result["n_metadata_only"] == 0 |
|
|
| def test_valid_is_true_when_aligned(self): |
| result = harmonize_expression_and_metadata(EXPR, META) |
| assert result["valid"] is True |
|
|
| def test_expression_only_samples_reported(self): |
| |
| expr_extra = pd.concat([EXPR, pd.DataFrame([[9.0] * 5], index=["S999"], columns=_GENES)]) |
| result = harmonize_expression_and_metadata(expr_extra, META) |
| assert result["n_expression_only"] == 1 |
| assert "S999" in result["expression_only_samples"] |
| assert any("no metadata" in w for w in result["warnings"]) |
|
|
| def test_metadata_only_samples_reported(self): |
| |
| meta_extra = pd.concat( |
| [META, pd.DataFrame([["A", "tumor"]], index=["S999"], columns=META.columns)] |
| ) |
| result = harmonize_expression_and_metadata(EXPR, meta_extra) |
| assert result["n_metadata_only"] == 1 |
| assert "S999" in result["metadata_only_samples"] |
|
|
| def test_no_common_samples_invalid(self): |
| meta_disjoint = META.copy() |
| meta_disjoint.index = [f"X{i:03d}" for i in range(10)] |
| result = harmonize_expression_and_metadata(EXPR, meta_disjoint) |
| assert result["n_aligned_samples"] == 0 |
| assert result["valid"] is False |
| assert any("no common" in w.lower() for w in result["warnings"]) |
|
|
| def test_sample_id_column_parameter(self): |
| |
| meta_col = META.reset_index().rename(columns={"index": "sample_id"}) |
| result = harmonize_expression_and_metadata(EXPR, meta_col, sample_id_column="sample_id") |
| assert result["n_aligned_samples"] == 10 |
|
|
| def test_invalid_sample_id_column_raises(self): |
| with pytest.raises(ValueError, match="not found"): |
| harmonize_expression_and_metadata(EXPR, META, sample_id_column="bad_col") |
|
|
| def test_aligned_dataframes_have_same_index(self): |
| result = harmonize_expression_and_metadata(EXPR, META) |
| assert list(result["expression_df"].index) == list(result["metadata_df"].index) |
|
|
| def test_partial_overlap_aligns_correctly(self): |
| expr_half = EXPR.iloc[:6] |
| meta_half = META.iloc[4:] |
| result = harmonize_expression_and_metadata(expr_half, meta_half) |
| assert result["n_aligned_samples"] == 2 |
| assert result["valid"] is True |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestDetectLogScale: |
| def test_log2_like_data_detected(self): |
| |
| result = detect_log_scale(EXPR) |
| assert result["likely_log_scale"] is True |
| assert result["likely_log2"] is True |
|
|
| def test_raw_counts_not_log(self): |
| counts = pd.DataFrame( |
| _RNG.integers(0, 50000, size=(10, 5)).astype(float), |
| index=_SAMPLE_IDS, |
| columns=_GENES, |
| ) |
| result = detect_log_scale(counts) |
| assert result["likely_log_scale"] is False |
|
|
| def test_negative_values_flagged(self): |
| log_ratio = EXPR - 10.0 |
| result = detect_log_scale(log_ratio) |
| assert result["has_negative_values"] is True |
| assert result["likely_log_scale"] is True |
|
|
| def test_stats_reported(self): |
| result = detect_log_scale(EXPR) |
| for key in ["value_min", "value_max", "value_median", "value_mean", "fraction_integer"]: |
| assert key in result |
| assert result[key] is not None |
|
|
| def test_diagnostic_notes_present(self): |
| result = detect_log_scale(EXPR) |
| assert len(result["diagnostic_notes"]) > 0 |
|
|
| def test_warnings_always_present(self): |
| result = detect_log_scale(EXPR) |
| assert isinstance(result["warnings"], list) |
|
|
| def test_empty_dataframe(self): |
| empty = pd.DataFrame() |
| result = detect_log_scale(empty) |
| assert result["likely_log_scale"] is False |
| assert result["value_min"] is None |
|
|
| def test_fraction_integer_near_zero_for_log(self): |
| result = detect_log_scale(EXPR) |
| assert result["fraction_integer"] < 0.1 |
|
|
| def test_fraction_integer_near_one_for_counts(self): |
| counts = pd.DataFrame( |
| _RNG.integers(0, 10000, size=(10, 5)).astype(float), columns=_GENES, index=_SAMPLE_IDS |
| ) |
| result = detect_log_scale(counts) |
| assert result["fraction_integer"] > 0.9 |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestCollapseDuplicateGenes: |
| def test_no_duplicates_returns_unchanged(self): |
| result = collapse_duplicate_genes(EXPR) |
| assert result["n_features_before"] == result["n_features_after"] |
| assert result["n_duplicated_genes"] == 0 |
| assert result["dataframe"].equals(EXPR) |
|
|
| def test_no_duplicate_warning_message(self): |
| result = collapse_duplicate_genes(EXPR) |
| assert any("unchanged" in w.lower() for w in result["warnings"]) |
|
|
| def test_mean_collapse(self): |
| |
| df_dup = pd.concat([EXPR, EXPR[["GENE_A"]].rename(columns={"GENE_A": "GENE_A"})], axis=1) |
| result = collapse_duplicate_genes(df_dup, method="mean") |
| assert result["n_features_after"] == 5 |
| assert result["n_duplicated_genes"] == 1 |
| assert "GENE_A" in result["duplicated_gene_sample"] |
|
|
| def test_max_collapse(self): |
| df_dup = pd.concat([EXPR, EXPR[["GENE_B"]].rename(columns={"GENE_B": "GENE_B"})], axis=1) |
| result = collapse_duplicate_genes(df_dup, method="max") |
| assert result["n_features_after"] == 5 |
|
|
| def test_most_variable_collapse(self): |
| df_dup = pd.concat([EXPR, EXPR[["GENE_C"]]], axis=1) |
| result = collapse_duplicate_genes(df_dup, method="most_variable") |
| assert result["n_features_after"] == 5 |
|
|
| def test_multiple_duplicated_genes(self): |
| df_dup = pd.concat([EXPR, EXPR[["GENE_A", "GENE_B"]]], axis=1) |
| result = collapse_duplicate_genes(df_dup, method="mean") |
| assert result["n_duplicated_genes"] == 2 |
| assert result["n_features_after"] == 5 |
|
|
| def test_invalid_method_raises(self): |
| with pytest.raises(ValueError, match="method"): |
| collapse_duplicate_genes(EXPR, method="invalid") |
|
|
| def test_output_has_no_duplicates(self): |
| df_dup = pd.concat([EXPR, EXPR[["GENE_A", "GENE_B"]]], axis=1) |
| result = collapse_duplicate_genes(df_dup, method="mean") |
| out_counts = result["dataframe"].columns.value_counts() |
| assert (out_counts > 1).sum() == 0 |
|
|
| def test_sample_count_preserved(self): |
| df_dup = pd.concat([EXPR, EXPR[["GENE_A"]]], axis=1) |
| result = collapse_duplicate_genes(df_dup, method="mean") |
| assert result["dataframe"].shape[0] == EXPR.shape[0] |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestPrepareGeneLevelStatistics: |
| def test_returns_result_dict(self): |
| result = prepare_gene_level_statistics(EXPR, META, "group", "A", "B") |
| assert isinstance(result, dict) |
| assert "dataframe" in result |
|
|
| def test_output_columns(self): |
| result = prepare_gene_level_statistics(EXPR, META, "group", "A", "B") |
| df = result["dataframe"] |
| for col in ["statistic", "pvalue", "padj", "mean_test", "mean_control", "log2fc_like"]: |
| assert col in df.columns, f"Missing column: {col}" |
|
|
| def test_index_named_gene(self): |
| result = prepare_gene_level_statistics(EXPR, META, "group", "A", "B") |
| assert result["dataframe"].index.name == "gene" |
|
|
| def test_all_genes_in_output(self): |
| result = prepare_gene_level_statistics(EXPR, META, "group", "A", "B") |
| assert result["n_genes"] == 5 |
| assert len(result["dataframe"]) == 5 |
|
|
| def test_sample_counts(self): |
| result = prepare_gene_level_statistics(EXPR, META, "group", "A", "B") |
| assert result["n_test_samples"] == 5 |
| assert result["n_control_samples"] == 5 |
|
|
| def test_gene_a_has_largest_effect(self): |
| |
| result = prepare_gene_level_statistics(EXPR, META, "group", "A", "B") |
| df = result["dataframe"] |
| gene_a_lfc = abs(df.loc["GENE_A", "log2fc_like"]) |
| others = df.drop("GENE_A")["log2fc_like"].abs() |
| assert gene_a_lfc > others.max() |
|
|
| def test_gene_a_is_significant(self): |
| result = prepare_gene_level_statistics(EXPR, META, "group", "A", "B") |
| df = result["dataframe"] |
| assert df.loc["GENE_A", "padj"] < 0.05 |
|
|
| def test_sorted_by_padj(self): |
| result = prepare_gene_level_statistics(EXPR, META, "group", "A", "B") |
| df = result["dataframe"] |
| assert list(df["padj"]) == sorted(df["padj"].tolist()) |
|
|
| def test_significant_gene_counts(self): |
| result = prepare_gene_level_statistics(EXPR, META, "group", "A", "B") |
| assert isinstance(result["significant_genes_05"], int) |
| assert result["significant_genes_05"] >= 1 |
|
|
| def test_warnings_include_log2fc_caveat(self): |
| result = prepare_gene_level_statistics(EXPR, META, "group", "A", "B") |
| combined = " ".join(result["warnings"]) |
| assert "log2fc_like" in combined or "log2" in combined.lower() |
|
|
| def test_pvalues_in_valid_range(self): |
| result = prepare_gene_level_statistics(EXPR, META, "group", "A", "B") |
| df = result["dataframe"] |
| assert (df["pvalue"] >= 0).all() and (df["pvalue"] <= 1).all() |
| assert (df["padj"] >= 0).all() and (df["padj"] <= 1).all() |
|
|
| def test_missing_group_column_raises(self): |
| with pytest.raises(ValueError, match="group_column"): |
| prepare_gene_level_statistics(EXPR, META, "bad_col", "A", "B") |
|
|
| def test_unknown_test_group_raises(self): |
| with pytest.raises(ValueError, match="test_group"): |
| prepare_gene_level_statistics(EXPR, META, "group", "X", "B") |
|
|
| def test_unknown_control_group_raises(self): |
| with pytest.raises(ValueError, match="control_group"): |
| prepare_gene_level_statistics(EXPR, META, "group", "A", "X") |
|
|
| def test_too_few_samples_raises(self): |
| tiny_expr = EXPR.iloc[:3] |
| tiny_meta = META.iloc[:3] |
| with pytest.raises(ValueError): |
| prepare_gene_level_statistics(tiny_expr, tiny_meta, "group", "A", "B") |
|
|
| def test_subset_query_restricts_samples(self): |
| meta_tissue = META.copy() |
| meta_tissue["tissue"] = ["tumor"] * 8 + ["normal"] * 2 |
| result = prepare_gene_level_statistics( |
| EXPR, meta_tissue, "group", "A", "B", subset_query="tissue == 'tumor'" |
| ) |
| |
| assert result["n_test_samples"] == 5 |
| assert result["n_control_samples"] == 3 |
|
|
| def test_invalid_subset_query_raises(self): |
| with pytest.raises(ValueError, match="subset_query"): |
| prepare_gene_level_statistics( |
| EXPR, META, "group", "A", "B", subset_query="not_a_column === broken" |
| ) |
|
|
| def test_invalid_method_raises(self): |
| with pytest.raises(ValueError, match="method"): |
| prepare_gene_level_statistics(EXPR, META, "group", "A", "B", method="deseq2") |
|
|
| def test_metadata_fields_in_result(self): |
| result = prepare_gene_level_statistics(EXPR, META, "group", "A", "B") |
| assert result["group_column"] == "group" |
| assert result["test_group"] == "A" |
| assert result["control_group"] == "B" |
| assert result["subset_query"] is None |
|
|