686 lines
22 KiB
Python
Executable File
686 lines
22 KiB
Python
Executable File
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import multiprocessing
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def load_ppi_module():
|
|
module_name = "ppi_skill_task3"
|
|
sys.modules.pop(module_name, None)
|
|
spec = importlib.util.spec_from_file_location(
|
|
module_name, REPO_ROOT / "skills/ppi/run.py"
|
|
)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[module_name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class FakeStringClient:
|
|
def __init__(self, mapping=None, network=None, network_error=None):
|
|
self.mapping = dict(mapping or {})
|
|
self.network = [] if network is None else network
|
|
self.network_error = network_error
|
|
self.mapping_calls = []
|
|
self.network_calls = []
|
|
self.species_id = "3562"
|
|
|
|
def get_string_ids(self, identifiers):
|
|
self.mapping_calls.append(list(identifiers))
|
|
rows = []
|
|
for query_index, identifier in enumerate(identifiers):
|
|
match = self.mapping.get(identifier)
|
|
if match is None:
|
|
continue
|
|
string_id, preferred_name = (
|
|
match if isinstance(match, tuple) else (match, identifier)
|
|
)
|
|
rows.append(
|
|
{
|
|
"queryIndex": query_index,
|
|
"queryItem": identifier,
|
|
"stringId": string_id,
|
|
"preferredName": preferred_name,
|
|
"ncbiTaxonId": 3562,
|
|
}
|
|
)
|
|
return rows
|
|
|
|
def get_network(self, identifiers, required_score=200, network_type="functional"):
|
|
self.network_calls.append((list(identifiers), required_score, network_type))
|
|
if self.network_error is not None:
|
|
raise self.network_error
|
|
return self.network
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(self, status_code=200, payload=None):
|
|
self.status_code = status_code
|
|
self._payload = payload
|
|
|
|
def json(self):
|
|
if isinstance(self._payload, Exception):
|
|
raise self._payload
|
|
return self._payload
|
|
|
|
|
|
class FakeHttpClient:
|
|
def __init__(self, responses):
|
|
self.responses = list(responses)
|
|
self.calls = []
|
|
|
|
def _next(self, method, url, **kwargs):
|
|
self.calls.append((method, url, kwargs))
|
|
if not self.responses:
|
|
raise AssertionError("unexpected HTTP request")
|
|
response = self.responses.pop(0)
|
|
if not hasattr(response, "url"):
|
|
response.url = url
|
|
return response
|
|
|
|
def post(self, url, **kwargs):
|
|
return self._next("POST", url, **kwargs)
|
|
|
|
|
|
def write_id_map(tmp_path: Path, rows: list[tuple[str, str, str]]) -> Path:
|
|
path = tmp_path / "soe.pep_cds_idmap.tsv"
|
|
lines = [
|
|
"species_code\tprotein_id\tpep_id\tcds_id\tgene_id\tcds_header\tncbi_gene_id"
|
|
]
|
|
for accession, gene_id, symbol in rows:
|
|
lines.append(
|
|
"\t".join(
|
|
[
|
|
"soe",
|
|
accession,
|
|
accession,
|
|
accession,
|
|
symbol,
|
|
f"{accession} [gene={symbol}] [db_xref=GeneID:{gene_id}]",
|
|
gene_id,
|
|
]
|
|
)
|
|
)
|
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def origin(module, canonical, reference):
|
|
return module.PpiQueryOrigin(
|
|
canonical_target_id=canonical,
|
|
ortholog_reference_id=reference,
|
|
raw_query_id=reference,
|
|
relationship_source="orthologs.tsv",
|
|
)
|
|
|
|
|
|
def test_parse_ortholog_mapping_preserves_every_exact_relationship(tmp_path):
|
|
module = load_ppi_module()
|
|
mapping = tmp_path / "orthologs.tsv"
|
|
mapping.write_text(
|
|
"Ft1\tXP_000001.1\nFt1\tXP_000002.3\nFt2\tXP_000001.1\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
origins = module.parse_ortholog_mapping(mapping)
|
|
|
|
assert len(origins) == 3
|
|
assert [(item.canonical_target_id, item.ortholog_reference_id) for item in origins] == [
|
|
("Ft1", "XP_000001.1"),
|
|
("Ft1", "XP_000002.3"),
|
|
("Ft2", "XP_000001.1"),
|
|
]
|
|
assert all(item.raw_query_id == item.ortholog_reference_id for item in origins)
|
|
|
|
|
|
def make_skill(module, client):
|
|
return module.PpiSkill(string_api_factory=lambda _species: client)
|
|
|
|
|
|
def test_cli_forwards_id_map_to_ppi_skill(tmp_path, monkeypatch):
|
|
module = load_ppi_module()
|
|
captured = {}
|
|
|
|
class FakePpiSkill:
|
|
def __init__(self, config_path=None):
|
|
captured["config_path"] = config_path
|
|
|
|
def run(self, **kwargs):
|
|
captured.update(kwargs)
|
|
return {"status": "success", "manifest_path": "manifest.json", "metrics": {}}
|
|
|
|
monkeypatch.setattr(module, "PpiSkill", FakePpiSkill)
|
|
monkeypatch.setattr(
|
|
sys,
|
|
"argv",
|
|
[
|
|
"run.py",
|
|
"--gene-ids",
|
|
str(tmp_path / "genes.txt"),
|
|
"--species",
|
|
"soe",
|
|
"--prefix",
|
|
"Ft.SPL",
|
|
"--output",
|
|
str(tmp_path / "ppi"),
|
|
"--id-map",
|
|
"/tmp/soe.pep_cds_idmap.tsv",
|
|
],
|
|
)
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
module.main()
|
|
|
|
assert exc_info.value.code == 0
|
|
assert captured["id_map_file"] == Path("/tmp/soe.pep_cds_idmap.tsv")
|
|
|
|
|
|
def test_owner_taxon_query_uses_normalization_label_for_strict_alias_species(tmp_path):
|
|
module = load_ppi_module()
|
|
id_map = tmp_path / "string_taxon_4555.pep_cds_idmap.tsv"
|
|
id_map.write_text(
|
|
"species_code\tprotein_id\tpep_id\tcds_id\tgene_id\tcds_header\n"
|
|
"string_taxon_4555\tSeita.1\tSeita.1\tSeita.1\tSeita1\tSeita.1 [gene=Seita1]\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
class OwnerTaxonClient:
|
|
species_id = "4555"
|
|
|
|
def get_string_ids(self, identifiers):
|
|
index = identifiers.index("Seita.1")
|
|
return [
|
|
{
|
|
"queryIndex": index,
|
|
"queryItem": "Seita.1",
|
|
"stringId": "4555.Seita.1",
|
|
"preferredName": "Seita.1",
|
|
"ncbiTaxonId": 4555,
|
|
}
|
|
]
|
|
|
|
result = make_skill(module, OwnerTaxonClient()).resolve_query_ids(
|
|
[origin(module, "SvGene1", "Seita.1")],
|
|
"4555",
|
|
id_map,
|
|
normalization_species_label="string_taxon_4555",
|
|
)
|
|
|
|
assert result.status == "success"
|
|
assert result.selected_string_ids == ["4555.Seita.1"]
|
|
|
|
|
|
def test_raw_xp_failure_falls_back_to_ncbi_geneid_and_selects_string_id(tmp_path):
|
|
module = load_ppi_module()
|
|
id_map = write_id_map(tmp_path, [("XP_000001.1", "1001", "LOC1001")])
|
|
client = FakeStringClient({"1001": ("3562.PROT1", "PROT1")})
|
|
|
|
result = make_skill(module, client).resolve_query_ids(
|
|
[origin(module, "Ft1", "XP_000001.1")], "soe", id_map
|
|
)
|
|
|
|
assert result.selected_string_ids == ["3562.PROT1"]
|
|
assert result.rows[0].mapping_status == "resolved"
|
|
assert result.rows[0].selected_query_id == "1001"
|
|
assert result.rows[0].relationship_source == "orthologs.tsv"
|
|
assert result.rows[0].evidence_source == str(id_map)
|
|
assert result.metrics["geneid_fallback_resolved_query_count"] == 1
|
|
|
|
|
|
def test_raw_and_geneid_mapping_to_same_string_id_is_not_ambiguous(tmp_path):
|
|
module = load_ppi_module()
|
|
id_map = write_id_map(tmp_path, [("XP_000001.1", "1001", "LOC1001")])
|
|
client = FakeStringClient(
|
|
{"XP_000001.1": "3562.PROT1", "1001": "3562.PROT1"}
|
|
)
|
|
|
|
result = make_skill(module, client).resolve_query_ids(
|
|
[origin(module, "Ft1", "XP_000001.1")], "soe", id_map
|
|
)
|
|
|
|
assert result.rows[0].mapping_status == "resolved"
|
|
assert result.metrics["ambiguous_member_count"] == 0
|
|
|
|
|
|
def test_one_input_mapping_to_two_distinct_string_ids_is_ambiguous(tmp_path):
|
|
module = load_ppi_module()
|
|
id_map = write_id_map(tmp_path, [("XP_000001.1", "1001", "LOC1001")])
|
|
client = FakeStringClient(
|
|
{"XP_000001.1": "3562.PROT1", "1001": "3562.PROT2"}
|
|
)
|
|
|
|
result = make_skill(module, client).resolve_query_ids(
|
|
[origin(module, "Ft1", "XP_000001.1")], "soe", id_map
|
|
)
|
|
|
|
assert result.selected_string_ids == []
|
|
assert result.rows[0].mapping_status == "ambiguous"
|
|
assert result.rows[0].reason == "candidate_string_ids_conflict"
|
|
|
|
|
|
def test_partial_resolution_continues_with_explicit_unresolved_rows():
|
|
module = load_ppi_module()
|
|
client = FakeStringClient({"XP_1.1": "3562.PROT1"})
|
|
origins = [
|
|
origin(module, "Ft1", "XP_1.1"),
|
|
origin(module, "Ft2", "XP_2.1"),
|
|
]
|
|
|
|
result = make_skill(module, client).resolve_query_ids(origins, "soe", None)
|
|
|
|
assert [row.mapping_status for row in result.rows] == ["resolved", "unresolved"]
|
|
assert result.metrics["probe_status"] == "partial"
|
|
assert result.selected_string_ids == ["3562.PROT1"]
|
|
|
|
|
|
def test_zero_resolved_ids_returns_id_resolution_failure():
|
|
module = load_ppi_module()
|
|
|
|
result = make_skill(module, FakeStringClient()).resolve_query_ids(
|
|
[origin(module, "Ft1", "XP_1.1")], "soe", None
|
|
)
|
|
|
|
assert result.status == "id_resolution_failure"
|
|
assert result.issue_code == "ppi_string_id_resolution_failed"
|
|
assert result.metrics["probe_status"] == "failed"
|
|
|
|
|
|
def test_network_empty_list_is_success_empty_network():
|
|
module = load_ppi_module()
|
|
client = FakeStringClient(
|
|
{"XP_1.1": "3562.PROT1"}, network=[]
|
|
)
|
|
|
|
result = make_skill(module, client).analyze_network(
|
|
"soe",
|
|
origins=[origin(module, "Ft1", "XP_1.1")],
|
|
canonical_target_ids=["Ft1"],
|
|
)
|
|
|
|
assert result["interactions"] == []
|
|
assert result["stats"]["network_status"] == "empty_network"
|
|
|
|
|
|
def test_network_http_error_is_external_api_failure():
|
|
module = load_ppi_module()
|
|
error = module.StringApiError("network failed", kind="http", status_code=503)
|
|
client = FakeStringClient(
|
|
{"XP_1.1": "3562.PROT1"}, network_error=error
|
|
)
|
|
|
|
with pytest.raises(module.StringApiError) as exc_info:
|
|
make_skill(module, client).analyze_network(
|
|
"soe",
|
|
origins=[origin(module, "Ft1", "XP_1.1")],
|
|
canonical_target_ids=["Ft1"],
|
|
)
|
|
|
|
assert exc_info.value.kind == "http"
|
|
assert exc_info.value.status_code == 503
|
|
|
|
|
|
def test_get_network_classifies_http_response_failure():
|
|
module = load_ppi_module()
|
|
api = module.StringAPI(
|
|
"soe",
|
|
http_client=FakeHttpClient([FakeResponse(status_code=503)]),
|
|
stable_api_base="https://stable.string-db.org/api",
|
|
rate_gate=SimpleNamespace(wait=lambda: None),
|
|
)
|
|
|
|
with pytest.raises(module.StringApiError) as exc_info:
|
|
api.get_network(["3562.PROT1"])
|
|
|
|
assert exc_info.value.kind == "http"
|
|
assert exc_info.value.status_code == 503
|
|
|
|
|
|
def test_get_network_classifies_json_decode_failure():
|
|
module = load_ppi_module()
|
|
api = module.StringAPI(
|
|
"soe",
|
|
http_client=FakeHttpClient([FakeResponse(payload=ValueError("invalid JSON"))]),
|
|
stable_api_base="https://stable.string-db.org/api",
|
|
rate_gate=SimpleNamespace(wait=lambda: None),
|
|
)
|
|
|
|
with pytest.raises(module.StringApiError) as exc_info:
|
|
api.get_network(["3562.PROT1"])
|
|
|
|
assert exc_info.value.kind == "json"
|
|
|
|
|
|
def test_get_string_ids_uses_query_index_not_response_position(tmp_path):
|
|
module = load_ppi_module()
|
|
response = FakeResponse(
|
|
payload=[
|
|
{
|
|
"queryIndex": 1,
|
|
"queryItem": "B",
|
|
"stringId": "3562.B",
|
|
"preferredName": "B",
|
|
"ncbiTaxonId": 3562,
|
|
},
|
|
{
|
|
"queryIndex": 0,
|
|
"queryItem": "A",
|
|
"stringId": "3562.A",
|
|
"preferredName": "A",
|
|
"ncbiTaxonId": 3562,
|
|
},
|
|
]
|
|
)
|
|
http = FakeHttpClient([response])
|
|
api = module.StringAPI(
|
|
"soe",
|
|
http_client=http,
|
|
stable_api_base="https://stable.string-db.org/api",
|
|
rate_gate=SimpleNamespace(wait=lambda: None),
|
|
)
|
|
|
|
rows = api.get_string_ids(["A", "B"])
|
|
|
|
assert [row["queryItem"] for row in rows] == ["A", "B"]
|
|
method, url, kwargs = http.calls[0]
|
|
assert method == "POST"
|
|
assert url == "https://stable.string-db.org/api/json/get_string_ids"
|
|
assert kwargs["data"]["echo_query"] == 1
|
|
assert kwargs["data"]["caller_identity"] == "PlantAI"
|
|
assert kwargs["timeout"] == 30
|
|
|
|
|
|
def test_24_origins_collapse_to_12_queries_without_losing_provenance(tmp_path):
|
|
module = load_ppi_module()
|
|
aliases = []
|
|
origins = []
|
|
mapping = {}
|
|
for index in range(12):
|
|
accession = f"XP_{index:06d}.1"
|
|
gene_id = str(1000 + index)
|
|
aliases.append((accession, gene_id, f"LOC{gene_id}"))
|
|
string_id = f"3562.PROT{index}"
|
|
mapping[gene_id] = string_id
|
|
if index < 5:
|
|
mapping[accession] = string_id
|
|
origins.extend(
|
|
[
|
|
origin(module, f"Ft{index * 2 + 1}", accession),
|
|
origin(module, f"Ft{index * 2 + 2}", accession),
|
|
]
|
|
)
|
|
id_map = write_id_map(tmp_path, aliases)
|
|
client = FakeStringClient(mapping, network=[])
|
|
|
|
result = make_skill(module, client).resolve_query_ids(origins, "soe", id_map)
|
|
|
|
assert len(result.rows) == 24
|
|
assert [
|
|
(row.canonical_target_id, row.ortholog_reference_id) for row in result.rows
|
|
] == [
|
|
(item.canonical_target_id, item.ortholog_reference_id) for item in origins
|
|
]
|
|
assert len(client.mapping_calls) == 1
|
|
assert result.metrics == {
|
|
"input_member_count": 24,
|
|
"relationship_count": 24,
|
|
"unique_reference_query_count": 12,
|
|
"raw_resolved_query_count": 5,
|
|
"geneid_fallback_resolved_query_count": 7,
|
|
"resolved_reference_query_count": 12,
|
|
"selected_string_query_count": 12,
|
|
"resolved_member_count": 24,
|
|
"unresolved_member_count": 0,
|
|
"ambiguous_member_count": 0,
|
|
"member_mapping_rate": 1.0,
|
|
"query_mapping_rate": 1.0,
|
|
"probe_status": "complete",
|
|
}
|
|
|
|
|
|
def test_run_resolves_relationships_submits_selected_string_ids_and_writes_provenance(
|
|
tmp_path,
|
|
):
|
|
module = load_ppi_module()
|
|
gene_file = tmp_path / "members.id"
|
|
gene_file.write_text("Ft1\nFt2\n", encoding="utf-8")
|
|
ortholog_file = tmp_path / "orthologs.tsv"
|
|
ortholog_file.write_text(
|
|
"Ft1\tXP_000001.1\nFt2\tXP_000001.1\n", encoding="utf-8"
|
|
)
|
|
id_map = write_id_map(tmp_path, [("XP_000001.1", "1001", "LOC1001")])
|
|
client = FakeStringClient({"1001": ("3562.PROT1", "PROT1")}, network=[])
|
|
output_dir = tmp_path / "ppi"
|
|
|
|
result = module.PpiSkill(
|
|
string_api_factory=lambda _species: client
|
|
).run(
|
|
gene_ids_file=gene_file,
|
|
species="soe",
|
|
prefix="Ft.SPL",
|
|
output_dir=output_dir,
|
|
required_score=200,
|
|
mapping_file=ortholog_file,
|
|
id_map_file=id_map,
|
|
)
|
|
|
|
assert result["status"] == "success"
|
|
assert client.network_calls == [(["3562.PROT1"], 200, "functional")]
|
|
assert result["metrics"]["network_status"] == "empty_network"
|
|
assert result["metrics"]["id_resolution"]["relationship_count"] == 2
|
|
assert (output_dir / "Ft.SPL_network.tsv").read_text(encoding="utf-8") == (
|
|
"protein1\tprotein2\tscore\n"
|
|
)
|
|
report = output_dir / "Ft.SPL_id_resolution.tsv"
|
|
lines = report.read_text(encoding="utf-8").splitlines()
|
|
assert lines[0].split("\t") == [
|
|
"canonical_target_id",
|
|
"ortholog_reference_id",
|
|
"raw_query_id",
|
|
"candidate_gene_id",
|
|
"selected_query_id",
|
|
"string_id",
|
|
"string_preferred_name",
|
|
"mapping_status",
|
|
"method",
|
|
"evidence_source",
|
|
"relationship_source",
|
|
"species",
|
|
"taxon_id",
|
|
"reason",
|
|
]
|
|
assert [line.split("\t")[:3] for line in lines[1:]] == [
|
|
["Ft1", "XP_000001.1", "XP_000001.1"],
|
|
["Ft2", "XP_000001.1", "XP_000001.1"],
|
|
]
|
|
assert all(line.split("\t")[10] == str(ortholog_file) for line in lines[1:])
|
|
|
|
|
|
def test_run_direct_target_raw_candidate_uses_canonical_member_evidence(tmp_path):
|
|
module = load_ppi_module()
|
|
gene_file = tmp_path / "members.id"
|
|
gene_file.write_text("DIRECT1\n", encoding="utf-8")
|
|
client = FakeStringClient({"DIRECT1": "3562.DIRECT1"}, network=[])
|
|
output_dir = tmp_path / "direct"
|
|
|
|
result = module.PpiSkill(string_api_factory=lambda _species: client).run(
|
|
gene_ids_file=gene_file,
|
|
species="soe",
|
|
prefix="Soe.Direct",
|
|
output_dir=output_dir,
|
|
)
|
|
|
|
assert result["status"] == "success"
|
|
report_lines = (output_dir / "Soe.Direct_id_resolution.tsv").read_text(
|
|
encoding="utf-8"
|
|
).splitlines()
|
|
header = report_lines[0].split("\t")
|
|
row = dict(zip(header, report_lines[1].split("\t")))
|
|
assert row["ortholog_reference_id"] == ""
|
|
assert row["evidence_source"] == "canonical_member_input"
|
|
assert row["relationship_source"] == str(gene_file)
|
|
|
|
|
|
def test_run_counts_canonical_member_missing_ortholog_as_unresolved(tmp_path):
|
|
module = load_ppi_module()
|
|
gene_file = tmp_path / "members.id"
|
|
gene_file.write_text("Ft1\nFt2\n", encoding="utf-8")
|
|
ortholog_file = tmp_path / "orthologs.tsv"
|
|
ortholog_file.write_text("Ft1\tXP_000001.1\n", encoding="utf-8")
|
|
id_map = write_id_map(tmp_path, [("XP_000001.1", "1001", "LOC1001")])
|
|
client = FakeStringClient({"1001": "3562.PROT1"}, network=[])
|
|
|
|
result = module.PpiSkill(string_api_factory=lambda _species: client).run(
|
|
gene_ids_file=gene_file,
|
|
species="soe",
|
|
prefix="Ft.SPL",
|
|
output_dir=tmp_path / "partial",
|
|
mapping_file=ortholog_file,
|
|
id_map_file=id_map,
|
|
)
|
|
|
|
metrics = result["metrics"]["id_resolution"]
|
|
assert metrics["input_member_count"] == 2
|
|
assert metrics["relationship_count"] == 1
|
|
assert metrics["resolved_member_count"] == 1
|
|
assert metrics["unresolved_member_count"] == 1
|
|
assert metrics["member_mapping_rate"] == 0.5
|
|
|
|
|
|
def test_many_canonical_targets_sharing_one_reference_keep_separate_rows():
|
|
module = load_ppi_module()
|
|
client = FakeStringClient({"XP_1.1": "3562.PROT1"})
|
|
origins = [origin(module, f"Ft{i}", "XP_1.1") for i in range(4)]
|
|
|
|
result = make_skill(module, client).resolve_query_ids(origins, "soe", None)
|
|
|
|
assert len(result.rows) == 4
|
|
assert {row.canonical_target_id for row in result.rows} == {
|
|
"Ft0",
|
|
"Ft1",
|
|
"Ft2",
|
|
"Ft3",
|
|
}
|
|
assert client.mapping_calls == [["XP_1.1", "XP_1"]]
|
|
|
|
|
|
def test_one_canonical_source_with_conflicting_references_is_excluded():
|
|
module = load_ppi_module()
|
|
client = FakeStringClient(
|
|
{"XP_1.1": "3562.PROT1", "XP_2.1": "3562.PROT2"}
|
|
)
|
|
origins = [
|
|
origin(module, "Ft1", "XP_1.1"),
|
|
origin(module, "Ft1", "XP_2.1"),
|
|
]
|
|
|
|
result = make_skill(module, client).resolve_query_ids(origins, "soe", None)
|
|
|
|
assert [row.mapping_status for row in result.rows] == [
|
|
"ambiguous_ortholog",
|
|
"ambiguous_ortholog",
|
|
]
|
|
assert result.selected_string_ids == []
|
|
|
|
|
|
def test_mapping_404_and_200_empty_both_mean_zero_resolution():
|
|
module = load_ppi_module()
|
|
for response in [FakeResponse(status_code=404), FakeResponse(payload=[])]:
|
|
http = FakeHttpClient([response])
|
|
api = module.StringAPI(
|
|
"soe",
|
|
http_client=http,
|
|
stable_api_base="https://stable.string-db.org/api",
|
|
rate_gate=SimpleNamespace(wait=lambda: None),
|
|
)
|
|
assert api.get_string_ids(["missing"]) == []
|
|
|
|
|
|
def test_request_throttle_uses_injected_clock_and_sleeper(tmp_path):
|
|
from gfa_core.string_rate_gate import StringRateGate
|
|
|
|
class Clock:
|
|
now = 100.0
|
|
|
|
def __init__(self):
|
|
self.sleeps = []
|
|
|
|
def time(self):
|
|
return self.now
|
|
|
|
def sleep(self, seconds):
|
|
self.sleeps.append(seconds)
|
|
self.now += seconds
|
|
|
|
clock = Clock()
|
|
gate = StringRateGate(
|
|
path=tmp_path / "gate.timestamp",
|
|
min_interval=1.0,
|
|
clock=clock.time,
|
|
sleeper=clock.sleep,
|
|
)
|
|
|
|
gate.wait()
|
|
gate.wait()
|
|
|
|
assert clock.sleeps == [pytest.approx(1.0)]
|
|
|
|
|
|
def test_two_reference_queries_converging_on_one_string_id_are_both_resolved():
|
|
module = load_ppi_module()
|
|
client = FakeStringClient(
|
|
{"XP_1.1": "3562.PROT1", "XP_2.1": "3562.PROT1"}
|
|
)
|
|
origins = [
|
|
origin(module, "Ft1", "XP_1.1"),
|
|
origin(module, "Ft1", "XP_2.1"),
|
|
]
|
|
|
|
result = make_skill(module, client).resolve_query_ids(origins, "soe", None)
|
|
|
|
assert [row.mapping_status for row in result.rows] == ["resolved", "resolved"]
|
|
assert result.selected_string_ids == ["3562.PROT1"]
|
|
assert result.metrics["resolved_reference_query_count"] == 2
|
|
assert result.metrics["selected_string_query_count"] == 1
|
|
|
|
|
|
def _gate_worker(path, interval, ready, output):
|
|
from gfa_core.string_rate_gate import StringRateGate
|
|
|
|
gate = StringRateGate(path=Path(path), min_interval=interval)
|
|
ready.wait()
|
|
gate.wait()
|
|
output.put(time.time())
|
|
|
|
|
|
def test_interprocess_rate_gate_serializes_concurrent_clients(tmp_path):
|
|
context = multiprocessing.get_context("fork")
|
|
ready = context.Event()
|
|
output = context.Queue()
|
|
interval = 0.12
|
|
processes = [
|
|
context.Process(
|
|
target=_gate_worker,
|
|
args=(str(tmp_path / "shared.timestamp"), interval, ready, output),
|
|
)
|
|
for _ in range(2)
|
|
]
|
|
for process in processes:
|
|
process.start()
|
|
ready.set()
|
|
timestamps = sorted(output.get(timeout=5) for _ in processes)
|
|
for process in processes:
|
|
process.join(timeout=5)
|
|
assert process.exitcode == 0
|
|
|
|
assert timestamps[1] - timestamps[0] >= interval * 0.85
|