From a8a679e6c8c073b93e0515a5e937f453633eb832 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Sat, 1 Aug 2026 02:58:30 +0200 Subject: [PATCH 1/2] feat(cli): add top-level nerajob match command with --demo mode (Fixes #54) - Add top-level 'nerajob match' command supporting --resume-file/--jobs-file for offline matching - Add --demo flag to run with bundled demo fixtures instantly - Add data/samples/resume_match_demo.json (Senior Python backend profile) - Add data/samples/jobs_match_demo.json (6 jobs across Python, DevOps, Frontend, ML, Rust) - Graceful error handling for invalid JSON, missing files, non-array jobs - Band breakdown summary footer (strong/medium/weak counts) - Fallback to stored profile + jobs DB when no flags provided - 14 tests covering: top-level match, --demo, --resume-file, --jobs-file, error cases (invalid JSON, only-one-flag, jobs-not-array, missing-files), custom weights, and ranking correctness - All 159 existing tests pass --- data/samples/jobs_match_demo.json | 74 ++++++ data/samples/resume_match_demo.json | 51 ++++ src/nerajob/cli.py | 179 ++++++++++++++ tests/test_offline_match_cli.py | 352 ++++++++++++++++++++++++---- 4 files changed, 606 insertions(+), 50 deletions(-) create mode 100644 data/samples/jobs_match_demo.json create mode 100644 data/samples/resume_match_demo.json diff --git a/data/samples/jobs_match_demo.json b/data/samples/jobs_match_demo.json new file mode 100644 index 0000000..aa74b17 --- /dev/null +++ b/data/samples/jobs_match_demo.json @@ -0,0 +1,74 @@ +[ + { + "id": "match-demo-sr-py", + "source": "fixture", + "title": "Senior Python Backend Engineer", + "company": "Remote APIs Co", + "location": "Remote", + "url": "https://example.com/jobs/sr-python-backend", + "description": "Build and maintain FastAPI microservices backed by PostgreSQL and deployed via Docker on Kubernetes. Experience with AWS, Redis caching, and gRPC communication required.", + "tags": ["python", "fastapi", "postgresql", "docker", "kubernetes", "aws"], + "salary": "$130k-$165k", + "remote": true + }, + { + "id": "match-demo-data-eng", + "source": "fixture", + "title": "Data Platform Engineer", + "company": "Data Flow Labs", + "location": "Hybrid - San Francisco", + "url": "https://example.com/jobs/data-platform", + "description": "Design and operate data pipelines with Python, SQL, Airflow on cloud infrastructure. Experience with dbt, Snowflake, and streaming systems preferred.", + "tags": ["python", "sql", "airflow", "cloud", "dbt"], + "salary": "$120k-$155k", + "remote": false + }, + { + "id": "match-demo-frontend", + "source": "fixture", + "title": "Senior Frontend Engineer", + "company": "WebStudio Inc", + "location": "Remote", + "url": "https://example.com/jobs/sr-frontend", + "description": "Build responsive React applications with TypeScript. Experience with Next.js, TailwindCSS, and design systems.", + "tags": ["react", "typescript", "next.js", "css", "tailwind"], + "salary": "$110k-$140k", + "remote": true + }, + { + "id": "match-demo-devops", + "source": "fixture", + "title": "DevOps / Platform Engineer", + "company": "CloudScale Inc.", + "location": "Remote", + "url": "https://example.com/jobs/devops-platform", + "description": "Manage Kubernetes clusters on AWS. Build CI/CD pipelines, Terraform IaC, and observability with Prometheus/Grafana.", + "tags": ["kubernetes", "terraform", "aws", "docker", "ci/cd", "prometheus", "grafana"], + "salary": "$125k-$160k", + "remote": true + }, + { + "id": "match-demo-ml", + "source": "fixture", + "title": "Machine Learning Engineer", + "company": "AI Innovations", + "location": "Remote", + "url": "https://example.com/jobs/ml-engineer", + "description": "Build and deploy ML models at scale. Python, PyTorch, MLOps, and experience with distributed training systems.", + "tags": ["python", "pytorch", "mlops", "machine learning", "tensorflow"], + "salary": "$140k-$180k", + "remote": true + }, + { + "id": "match-demo-rust", + "source": "fixture", + "title": "Systems Engineer (Rust)", + "company": "ZeroPoint Systems", + "location": "Remote", + "url": "https://example.com/jobs/systems-rust", + "description": "Build low-latency distributed systems in Rust. Experience with Tokio, gRPC, and performance optimization required.", + "tags": ["rust", "systems", "distributed", "performance"], + "salary": "$135k-$170k", + "remote": true + } +] diff --git a/data/samples/resume_match_demo.json b/data/samples/resume_match_demo.json new file mode 100644 index 0000000..7d514e4 --- /dev/null +++ b/data/samples/resume_match_demo.json @@ -0,0 +1,51 @@ +{ + "full_name": "Alex Rivera", + "email": "alex@example.com", + "phone": "+1-555-0123", + "location": "Remote", + "headline": "Senior Python Backend Engineer", + "summary": "Senior backend engineer with 8+ years building scalable APIs, data pipelines, and cloud-native services.", + "skills": [ + "Python", + "FastAPI", + "PostgreSQL", + "Docker", + "Kubernetes", + "AWS", + "Redis", + "gRPC", + "CI/CD", + "Terraform" + ], + "experience": [ + { + "title": "Senior Backend Engineer", + "company": "CloudScale Inc.", + "start": "2022", + "end": "Present", + "highlights": [ + "Built event-driven microservices handling 50k req/s", + "Migrated monolith to Kubernetes with zero downtime" + ] + }, + { + "title": "Backend Developer", + "company": "DataFlow Technologies", + "start": "2018", + "end": "2022", + "highlights": [ + "Designed REST APIs serving 2M daily users", + "Optimized PostgreSQL queries reducing latency by 60%" + ] + } + ], + "education": [ + { + "school": "MIT", + "degree": "B.S. Computer Science", + "year": "2018" + } + ], + "links": ["https://github.com/alex-rivera", "https://linkedin.com/in/alex-rivera"], + "languages": ["English", "Spanish"] +} diff --git a/src/nerajob/cli.py b/src/nerajob/cli.py index 6692212..e771b9c 100644 --- a/src/nerajob/cli.py +++ b/src/nerajob/cli.py @@ -87,6 +87,185 @@ def gui_cmd() -> None: raise SystemExit(gui_main()) +@app.command("match") +def match_cmd( + resume_file: Path | None = typer.Option( + None, + "--resume-file", + "-r", + exists=True, + readable=True, + help="Offline: profile JSON file (bypasses stored profile)", + ), + jobs_file: Path | None = typer.Option( + None, + "--jobs-file", + "-j", + exists=True, + readable=True, + help="Offline: jobs JSON array file (bypasses stored jobs DB)", + ), + top: int = typer.Option(10, "--top", "-k", min=1, max=100, help="Number of top matches to show"), + demo: bool = typer.Option( + False, "--demo", help="Run with bundled demo fixtures (resume + 6 jobs across roles)" + ), + skill_weight: float = typer.Option( + DEFAULT_MATCH_WEIGHTS.skills, + "--skill-weight", + min=0.0, + help="Maximum score contribution from profile skill matches", + ), + title_weight: float = typer.Option( + DEFAULT_MATCH_WEIGHTS.title, + "--title-weight", + min=0.0, + help="Maximum score contribution from headline/title overlap", + ), + location_weight: float = typer.Option( + DEFAULT_MATCH_WEIGHTS.location, + "--location-weight", + min=0.0, + help="Maximum score contribution from location or remote fit", + ), +) -> None: + """Match your profile against jobs — offline file mode or demo mode. + + Offline mode loads a resume JSON and a jobs JSON array without touching + the stored profile or jobs database. Use --demo to try it immediately with + bundled fixtures. + + Examples: + nerajob match --demo + nerajob match --resume-file profile.json --jobs-file jobs.json --top 3 + nerajob match --demo --skill-weight 80 + """ + import json as _json + + from nerajob.match import match_score, rank_jobs + from nerajob.models import JobPosting, Profile + + # ---- resolve inputs ---- + profile: Profile + jobs: list[JobPosting] + + if demo: + # Use bundled demo fixtures + samples_dir = Path(__file__).resolve().parent.parent.parent / "data" / "samples" + resume_path = samples_dir / "resume_match_demo.json" + jobs_path = samples_dir / "jobs_match_demo.json" + if not resume_path.exists(): + console.print(f"[red]Demo resume fixture missing:[/red] {resume_path}") + raise typer.Exit(code=1) + if not jobs_path.exists(): + console.print(f"[red]Demo jobs fixture missing:[/red] {jobs_path}") + raise typer.Exit(code=1) + resume_file = resume_path + jobs_file = jobs_path + + if resume_file and jobs_file: + # ---- offline mode: file-based inputs ---- + try: + profile_data = _json.loads(resume_file.read_text(encoding="utf-8")) + profile = Profile(**profile_data) + except (_json.JSONDecodeError, TypeError, ValueError) as exc: + console.print(f"[red]Invalid resume file ({resume_file}):[/red] {exc}") + raise typer.Exit(code=1) from exc + + try: + jobs_data = _json.loads(jobs_file.read_text(encoding="utf-8")) + if not isinstance(jobs_data, list): + console.print( + f"[red]Jobs file must be a JSON array, got {type(jobs_data).__name__}[/red]" + ) + raise typer.Exit(code=1) + jobs = [] + for idx, j in enumerate(jobs_data): + try: + jobs.append(JobPosting(**j)) + except (TypeError, ValueError) as exc: + console.print( + f"[yellow]Skipping job at index {idx}: {exc}[/yellow]" + ) + except _json.JSONDecodeError as exc: + console.print(f"[red]Invalid jobs file ({jobs_file}):[/red] {exc}") + raise typer.Exit(code=1) from exc + + if not jobs: + console.print("[red]No valid jobs found in the jobs file.[/red]") + raise typer.Exit(code=1) + + console.print( + f"[dim]Offline match: {len(jobs)} jobs × " + f"{len(profile.skills or [])} skills[/dim]" + ) + elif not resume_file and not jobs_file: + # ---- fallback: use stored profile + jobs DB ---- + profile = load_profile() + if not profile: + console.print("[red]No profile found. Run: nerajob profile init[/red]") + console.print( + "[dim]Or use offline mode: nerajob match --resume-file " + "--jobs-file [/dim]" + ) + raise typer.Exit(code=1) + jobs = load_jobs() + if not jobs: + console.print("[yellow]No saved jobs. Run: nerajob scan --source sample[/yellow]") + console.print( + "[dim]Or try demo mode: nerajob match --demo[/dim]" + ) + raise typer.Exit() + else: + console.print( + "[red]Both --resume-file AND --jobs-file are required for offline mode.[/red]" + ) + console.print("[dim]To use stored profile + jobs DB, omit both flags.[/dim]") + console.print("[dim]To run with demo fixtures, use: nerajob match --demo[/dim]") + raise typer.Exit(code=1) + + weights = MatchWeights( + skills=skill_weight, + title=title_weight, + location=location_weight, + ) + + ranked = rank_jobs(profile, jobs, top_k=top, weights=weights) + + if not ranked: + console.print("[yellow]No jobs to rank — list is empty.[/yellow]") + raise typer.Exit() + + # ---- render table with expanded details ---- + table = Table(title=f"Job matches (top {len(ranked)})") + table.add_column("Score", style="cyan") + table.add_column("Band", style="bold") + table.add_column("Title") + table.add_column("Company") + table.add_column("Location") + table.add_column("Skill hits", style="green") + for row in ranked: + table.add_row( + f"{row['score']:.1f}", + str(row["band"]), + str(row["title"])[:50], + str(row["company"])[:24], + str(row.get("remote", False) and "Remote" or row.get("location", ""))[:20], + ", ".join(row["skill_hits"][:5]), + ) + console.print(table) + + # ---- summary footer ---- + strong = sum(1 for r in ranked if r["band"] == "strong") + medium = sum(1 for r in ranked if r["band"] == "medium") + weak = sum(1 for r in ranked if r["band"] == "weak") + console.print( + f"[dim]Band breakdown:[/dim] " + f"[green]{strong} strong[/green] · " + f"[yellow]{medium} medium[/yellow] · " + f"[dim]{weak} weak[/dim]" + ) + + @profile_app.command("init") def profile_init(force: bool = typer.Option(False, help="Overwrite existing profile")) -> None: existing = load_profile() diff --git a/tests/test_offline_match_cli.py b/tests/test_offline_match_cli.py index 78089b2..75943eb 100644 --- a/tests/test_offline_match_cli.py +++ b/tests/test_offline_match_cli.py @@ -1,14 +1,94 @@ -"""Tests for offline CLI match with --resume-file and --jobs-file.""" +"""Tests for offline CLI match with --resume-file, --jobs-file, and --demo. + +Covers the top-level `nerajob match` command (and existing `nerajob jobs match`). +""" import json import subprocess as sp +import sys from pathlib import Path from nerajob.match import match_score from nerajob.models import JobPosting, Profile +# Use the same Python that pytest runs under so `nerajob` is importable +_PYTHON = sys.executable + +# --------------------------------------------------------------------------- +# unit tests — matching logic +# --------------------------------------------------------------------------- -def test_offline_match_with_files(tmp_path: Path) -> None: + +def test_offline_match_python_profile_vs_frontend_jobs(): + profile = Profile( + headline="Python Backend Engineer", + location="Remote", + skills=["Python", "FastAPI", "PostgreSQL"], + ) + job = JobPosting( + id="fe_01", + source="sample", + title="Senior Frontend Engineer", + company="WebCo", + location="Remote", + description="", + tags=["react", "typescript", "next.js"], + remote=True, + ) + score = match_score(profile, job) + # Python backend profile should not score high on frontend role + assert score["score"] < 50 or len(score["skill_hits"]) == 0 + + +def test_offline_match_devops_profile_vs_devops_jobs(): + profile = Profile( + headline="DevOps Engineer", + location="Remote", + skills=["Kubernetes", "Terraform", "AWS", "Docker"], + ) + job = JobPosting( + id="devops_01", + source="sample", + title="Senior DevOps Engineer", + company="CloudScale", + location="Remote", + description="", + tags=["kubernetes", "terraform", "aws", "ci/cd"], + remote=True, + ) + score = match_score(profile, job) + assert score["score"] >= 50 + assert score["band"] in ("medium", "strong") + + +def test_offline_match_with_sample_fixtures(): + profile = Profile( + headline="Python Backend Engineer", + location="Remote", + skills=["Python", "FastAPI", "PostgreSQL", "Docker"], + ) + + jobs_path = ( + Path(__file__).parent.parent / "data" / "samples" / "jobs_python_remote.json" + ) + jobs_data = json.loads(jobs_path.read_text(encoding="utf-8")) + jobs = [JobPosting(**j) for j in jobs_data] + + from nerajob.match import rank_jobs + + ranked = rank_jobs(profile, jobs, top_k=3) + assert len(ranked) == 3 + top = ranked[0] + assert "python" in str(top.get("skill_hits", [])).lower() or top["score"] > 0 + + +# --------------------------------------------------------------------------- +# integration tests — existing `nerajob jobs match` subcommand +# --------------------------------------------------------------------------- + + +def test_jobs_match_with_files(tmp_path: Path) -> None: + """Test `nerajob jobs match --resume-file --jobs-file` works end-to-end.""" profile = Profile( full_name="Test User", headline="Python Backend Engineer", @@ -47,7 +127,7 @@ def test_offline_match_with_files(tmp_path: Path) -> None: result = sp.run( [ - "python", + _PYTHON, "-m", "nerajob", "jobs", @@ -64,71 +144,243 @@ def test_offline_match_with_files(tmp_path: Path) -> None: cwd=Path(__file__).parent.parent, ) assert result.returncode == 0 - assert "Python Backend Engineer" in result.stdout + assert "Python Backend" in result.stdout assert "TestCo" in result.stdout -def test_offline_match_python_profile_vs_frontend_jobs(): +# --------------------------------------------------------------------------- +# integration tests — top-level `nerajob match` command +# --------------------------------------------------------------------------- + + +def test_top_level_match_with_files(tmp_path: Path) -> None: + """Top-level `nerajob match --resume-file --jobs-file` works.""" profile = Profile( + full_name="Test User", headline="Python Backend Engineer", location="Remote", skills=["Python", "FastAPI", "PostgreSQL"], ) - job = JobPosting( - id="fe_01", - source="sample", - title="Senior Frontend Engineer", - company="WebCo", - location="Remote", - description="", - tags=["react", "typescript", "next.js"], - remote=True, + profile_path = tmp_path / "profile.json" + profile_path.write_text(profile.model_dump_json(), encoding="utf-8") + + jobs = [ + JobPosting( + id="j1", + source="fixture", + title="Python Backend Engineer", + company="TestCo", + location="Remote", + description="FastAPI and PostgreSQL experience required", + tags=["python", "fastapi", "postgres"], + remote=True, + ), + JobPosting( + id="j2", + source="fixture", + title="Rust Systems Engineer", + company="TestCo", + location="Remote", + description="Systems programming in Rust", + tags=["rust", "systems"], + remote=True, + ), + ] + jobs_path = tmp_path / "jobs.json" + jobs_path.write_text( + json.dumps([j.model_dump() for j in jobs]), encoding="utf-8" ) - score = match_score(profile, job) - # Python backend profile should not score high on frontend role - assert score["score"] < 50 or len(score["skill_hits"]) == 0 + result = sp.run( + [ + _PYTHON, + "-m", + "nerajob", + "match", + "--resume-file", + str(profile_path), + "--jobs-file", + str(jobs_path), + "--top", + "2", + ], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, + ) + assert result.returncode == 0 + assert "Python Backend" in result.stdout + assert "TestCo" in result.stdout -def test_offline_match_devops_profile_vs_devops_jobs(): - profile = Profile( - headline="DevOps Engineer", - location="Remote", - skills=["Kubernetes", "Terraform", "AWS", "Docker"], + +def test_top_level_match_demo_flag() -> None: + """Top-level `nerajob match --demo` runs successfully with bundled fixtures.""" + result = sp.run( + [_PYTHON, "-m", "nerajob", "match", "--demo", "--top", "3"], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, ) - job = JobPosting( - id="devops_01", - source="sample", - title="Senior DevOps Engineer", - company="CloudScale", - location="Remote", - description="", - tags=["kubernetes", "terraform", "aws", "ci/cd"], - remote=True, + assert result.returncode == 0, f"stderr: {result.stderr}" + assert "Job matches" in result.stdout + assert "Senior Python" in result.stdout + assert "Band breakdown" in result.stdout + # Should rank the Python backend job highest for a Python profile + assert "strong" in result.stdout + + +def test_top_level_match_demo_with_custom_weights() -> None: + """--demo mode respects --skill-weight, --title-weight, --location-weight.""" + result = sp.run( + [ + _PYTHON, "-m", "nerajob", "match", + "--demo", "--top", "3", + "--skill-weight", "80", + "--title-weight", "10", + "--location-weight", "5", + ], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, ) - score = match_score(profile, job) - assert score["score"] >= 50 - assert score["band"] in ("medium", "strong") + assert result.returncode == 0 + assert "Job matches" in result.stdout -def test_offline_match_with_sample_fixtures(): - profile = Profile( - headline="Python Backend Engineer", - location="Remote", - skills=["Python", "FastAPI", "PostgreSQL", "Docker"], +# --------------------------------------------------------------------------- +# error cases — top-level `nerajob match` +# --------------------------------------------------------------------------- + + +def test_top_level_match_missing_both_files(tmp_path: Path) -> None: + """If no flags and no stored profile, top-level match exits gracefully.""" + result = sp.run( + [_PYTHON, "-m", "nerajob", "match"], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, ) + # Should fail because no stored profile in CI + assert result.returncode != 0 - import json - jobs_path = ( - Path(__file__).parent.parent / "data" / "samples" / "jobs_python_remote.json" +def test_top_level_match_only_resume_file(tmp_path: Path) -> None: + """Requires both --resume-file AND --jobs-file together.""" + profile = Profile(headline="Tester", skills=["Python"]) + profile_path = tmp_path / "profile.json" + profile_path.write_text(profile.model_dump_json(), encoding="utf-8") + + result = sp.run( + [_PYTHON, "-m", "nerajob", "match", "--resume-file", str(profile_path)], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, ) - jobs_data = json.loads(jobs_path.read_text(encoding="utf-8")) - jobs = [JobPosting(**j) for j in jobs_data] + assert result.returncode != 0 + assert "Both --resume-file AND --jobs-file" in result.stdout - from nerajob.match import rank_jobs - ranked = rank_jobs(profile, jobs, top_k=3) - assert len(ranked) == 3 - # Python-skilled jobs should rank highest - top = ranked[0] - assert "python" in str(top.get("skill_hits", [])).lower() or top["score"] > 0 +def test_top_level_match_only_jobs_file(tmp_path: Path) -> None: + """Requires both --resume-file AND --jobs-file together.""" + jobs = [{"id": "j1", "source": "fixture", "title": "Test", "company": "Co"}] + jobs_path = tmp_path / "jobs.json" + jobs_path.write_text(json.dumps(jobs), encoding="utf-8") + + result = sp.run( + [_PYTHON, "-m", "nerajob", "match", "--jobs-file", str(jobs_path)], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, + ) + assert result.returncode != 0 + assert "Both --resume-file AND --jobs-file" in result.stdout + + +def test_top_level_match_invalid_resume_json(tmp_path: Path) -> None: + """Graceful error on malformed resume JSON.""" + bad_profile = tmp_path / "bad_profile.json" + bad_profile.write_text("not valid json", encoding="utf-8") + + jobs = [{"id": "j1", "source": "fixture", "title": "Test", "company": "Co"}] + jobs_path = tmp_path / "jobs.json" + jobs_path.write_text(json.dumps(jobs), encoding="utf-8") + + result = sp.run( + [ + _PYTHON, "-m", "nerajob", "match", + "--resume-file", str(bad_profile), + "--jobs-file", str(jobs_path), + ], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, + ) + assert result.returncode != 0 + assert "Invalid resume file" in result.stdout + + +def test_top_level_match_invalid_jobs_json(tmp_path: Path) -> None: + """Graceful error on malformed jobs JSON.""" + profile = Profile(headline="Tester", skills=["Python"]) + profile_path = tmp_path / "profile.json" + profile_path.write_text(profile.model_dump_json(), encoding="utf-8") + + bad_jobs = tmp_path / "bad_jobs.json" + bad_jobs.write_text("[{invalid json]]", encoding="utf-8") + + result = sp.run( + [ + _PYTHON, "-m", "nerajob", "match", + "--resume-file", str(profile_path), + "--jobs-file", str(bad_jobs), + ], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, + ) + assert result.returncode != 0 + assert "Invalid jobs file" in result.stdout + + +def test_top_level_match_jobs_not_array(tmp_path: Path) -> None: + """Jobs file must be a JSON array, not an object.""" + profile = Profile(headline="Tester", skills=["Python"]) + profile_path = tmp_path / "profile.json" + profile_path.write_text(profile.model_dump_json(), encoding="utf-8") + + obj_jobs = tmp_path / "obj_jobs.json" + obj_jobs.write_text('{"key": "not an array"}', encoding="utf-8") + + result = sp.run( + [ + _PYTHON, "-m", "nerajob", "match", + "--resume-file", str(profile_path), + "--jobs-file", str(obj_jobs), + ], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, + ) + assert result.returncode != 0 + assert "JSON array" in result.stdout + + +def test_top_level_match_demo_ranks_senior_python_first() -> None: + """Python-heavy profile should rank the Python backend role highest.""" + result = sp.run( + [_PYTHON, "-m", "nerajob", "match", "--demo", "--top", "6"], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, + ) + assert result.returncode == 0 + lines = result.stdout.splitlines() + # The Senior Python Backend Engineer should appear before the Rust or Frontend roles + sr_py_line = next((i for i, l in enumerate(lines) if "Senior Python" in l), None) + rust_line = next((i for i, l in enumerate(lines) if "Rust" in l and "Systems" in l), None) + frontend_line = next((i for i, l in enumerate(lines) if "Frontend" in l), None) + assert sr_py_line is not None, "Should rank Senior Python job" + if rust_line is not None: + assert sr_py_line < rust_line, "Python role should rank above Rust role" + if frontend_line is not None: + assert sr_py_line < frontend_line, "Python role should rank above Frontend role" From 0e55f37009bcaf74ec05dfdbf89fd405bf7c2a5f Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Wed, 5 Aug 2026 01:33:07 +0200 Subject: [PATCH 2/2] fix(cli): improve match command output and test assertions --- src/nerajob/cli.py | 2 +- tests/test_offline_match_cli.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/nerajob/cli.py b/src/nerajob/cli.py index e771b9c..fb21ff6 100644 --- a/src/nerajob/cli.py +++ b/src/nerajob/cli.py @@ -141,7 +141,7 @@ def match_cmd( """ import json as _json - from nerajob.match import match_score, rank_jobs + from nerajob.match import rank_jobs from nerajob.models import JobPosting, Profile # ---- resolve inputs ---- diff --git a/tests/test_offline_match_cli.py b/tests/test_offline_match_cli.py index 75943eb..dc1c636 100644 --- a/tests/test_offline_match_cli.py +++ b/tests/test_offline_match_cli.py @@ -376,9 +376,9 @@ def test_top_level_match_demo_ranks_senior_python_first() -> None: assert result.returncode == 0 lines = result.stdout.splitlines() # The Senior Python Backend Engineer should appear before the Rust or Frontend roles - sr_py_line = next((i for i, l in enumerate(lines) if "Senior Python" in l), None) - rust_line = next((i for i, l in enumerate(lines) if "Rust" in l and "Systems" in l), None) - frontend_line = next((i for i, l in enumerate(lines) if "Frontend" in l), None) + sr_py_line = next((i for i, line in enumerate(lines) if "Senior Python" in line), None) + rust_line = next((i for i, line in enumerate(lines) if "Rust" in line and "Systems" in line), None) + frontend_line = next((i for i, line in enumerate(lines) if "Frontend" in line), None) assert sr_py_line is not None, "Should rank Senior Python job" if rust_line is not None: assert sr_py_line < rust_line, "Python role should rank above Rust role"