291 lines
9.7 KiB
Python
291 lines
9.7 KiB
Python
"""Unit tests for g_sheet/plugins.py — mapping against the real 32-column sheet.
|
|
|
|
No DB, no network. Pins header aliases, parsers, and the Bilal sample row.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from g_sheet import plugins
|
|
from g_sheet.enums import FORM_DATA_FIELDS, HEADER_ALIASES, FormDataField
|
|
from g_sheet.models import FormData
|
|
|
|
|
|
# Real header row from "PK - Recruitment Tracking Sheet" (32 columns).
|
|
HEADERS = [
|
|
"um",
|
|
"Year",
|
|
"Month",
|
|
"Date",
|
|
"Time of Entry",
|
|
"Screened By",
|
|
"Candidate Name",
|
|
"HR comments",
|
|
"Candidate Number",
|
|
"Candidate Email",
|
|
"Profile Link",
|
|
"Area of Expertise",
|
|
"Requisition Number",
|
|
"Position Suitable For",
|
|
"Source of Application",
|
|
"Age",
|
|
"Marital Status",
|
|
"Education",
|
|
"University of Graduation ",
|
|
"Experience",
|
|
"Experience Details",
|
|
"Area of Residence",
|
|
"Communication Skills\n(01 to 10)",
|
|
"Preferred Timings?",
|
|
"Availability to work in the H.O",
|
|
"Current Company",
|
|
"Reason for Leaving",
|
|
"How soon can you join",
|
|
"Current Salary",
|
|
"Expected Salary",
|
|
"Pros",
|
|
"Cons",
|
|
]
|
|
|
|
EXPECTED_FIELDS = (
|
|
FormDataField.SERIAL_NO,
|
|
FormDataField.ENTRY_YEAR,
|
|
FormDataField.ENTRY_MONTH,
|
|
FormDataField.ENTRY_DATE,
|
|
FormDataField.ENTRY_TIME,
|
|
FormDataField.SCREENED_BY,
|
|
FormDataField.NAME,
|
|
FormDataField.HR_COMMENTS,
|
|
FormDataField.CANDIDATE_NUMBER,
|
|
FormDataField.CANDIDATE_EMAIL,
|
|
FormDataField.PROFILE_LINK,
|
|
FormDataField.AREA_OF_EXPERTISE,
|
|
FormDataField.REQUISITION_NUMBER,
|
|
FormDataField.POSITION_SUITABLE_FOR,
|
|
FormDataField.SOURCE_OF_APPLICATION,
|
|
FormDataField.AGE,
|
|
FormDataField.MARITAL_STATUS,
|
|
FormDataField.DEGREE,
|
|
FormDataField.UNIVERSITY,
|
|
FormDataField.EXPERIENCE,
|
|
FormDataField.EXPERIENCE_DETAILS,
|
|
FormDataField.AREA_OF_RESIDENCE,
|
|
FormDataField.COMMUNICATION_SKILLS,
|
|
FormDataField.PREFERRED_TIMINGS,
|
|
FormDataField.HO_AVAILABILITY,
|
|
FormDataField.CURRENT_COMPANY,
|
|
FormDataField.REASON_FOR_LEAVING,
|
|
FormDataField.NOTICE_PERIOD,
|
|
FormDataField.CURRENT_SALARY,
|
|
FormDataField.EXPECTED_SALARY,
|
|
FormDataField.PROS,
|
|
FormDataField.CONS,
|
|
)
|
|
|
|
SAMPLE_RECORD = {
|
|
"um": "1",
|
|
"Year": "2021",
|
|
"Month": "June",
|
|
"Date": "23-Jun-2021",
|
|
"Time of Entry": "10:30 AM",
|
|
"Screened By": "Sara",
|
|
"Candidate Name": "Muhammad Bilal Khan",
|
|
"HR comments": "Good profile",
|
|
"Candidate Number": "0303-2892503",
|
|
"Candidate Email": "bilal_kf@yahoo.com",
|
|
"Profile Link": "https://example.com/bilal",
|
|
"Area of Expertise": "Software Development",
|
|
"Requisition Number": "REQ-1",
|
|
"Position Suitable For": "Backend Engineer",
|
|
"Source of Application": "Referral",
|
|
"Age": "33",
|
|
"Marital Status": "Married",
|
|
"Education": "BS CS",
|
|
"University of Graduation ": "NUST",
|
|
"Experience": "11 Years",
|
|
"Experience Details": "Software development related experience",
|
|
"Area of Residence": "Islamabad",
|
|
"Communication Skills\n(01 to 10)": "7",
|
|
"Preferred Timings?": "Morning",
|
|
"Availability to work in the H.O": "Yes",
|
|
"Current Company": "Acme",
|
|
"Reason for Leaving": "Growth",
|
|
"How soon can you join": "1 month",
|
|
"Current Salary": "110k",
|
|
"Expected Salary": "150k",
|
|
"Pros": "Strong backend",
|
|
"Cons": "Limited cloud",
|
|
}
|
|
|
|
|
|
def test_every_real_header_maps_and_none_are_unmapped():
|
|
assert len(HEADERS) == 32
|
|
for header, field in zip(HEADERS, EXPECTED_FIELDS):
|
|
assert plugins.match_field(header) == field, header
|
|
assert plugins.collect_unmapped_headers(HEADERS) == []
|
|
|
|
|
|
def test_experience_details_does_not_overwrite_experience():
|
|
mapped = plugins.map_record_to_form_data("tab", SAMPLE_RECORD, HEADERS, 2)
|
|
assert mapped["experience"] == "11 Years"
|
|
assert mapped["experience_details"] == "Software development related experience"
|
|
|
|
|
|
def test_candidate_number_and_email_land_in_own_columns():
|
|
mapped = plugins.map_record_to_form_data("tab", SAMPLE_RECORD, HEADERS, 2)
|
|
assert mapped["candidate_number"] == "0303-2892503"
|
|
assert mapped["candidate_email"] == "bilal_kf@yahoo.com"
|
|
assert mapped["name"] == "Muhammad Bilal Khan"
|
|
|
|
|
|
def test_date_maps_to_entry_date_not_interview_round():
|
|
mapped = plugins.map_record_to_form_data("tab", SAMPLE_RECORD, HEADERS, 2)
|
|
assert mapped["entry_date"] == datetime(2021, 6, 23, tzinfo=timezone.utc)
|
|
assert "interview_date" not in mapped
|
|
|
|
|
|
def test_marital_status_only_lands_in_marital_status():
|
|
mapped = plugins.map_record_to_form_data("tab", SAMPLE_RECORD, HEADERS, 2)
|
|
assert mapped["marital_status"] == "Married"
|
|
assert "family_details" not in mapped
|
|
assert "interview_status" not in mapped
|
|
|
|
|
|
def test_canonical_header_strips_parens_question_and_trailing_space():
|
|
assert plugins.canonical_header("Communication Skills\n(01 to 10)") == "communication skills"
|
|
assert plugins.canonical_header("Preferred Timings?") == "preferred timings"
|
|
assert plugins.canonical_header("University of Graduation ") == "university of graduation"
|
|
|
|
|
|
def test_parse_salary_and_score():
|
|
assert plugins.parse_salary("110k") == 110000
|
|
assert plugins.parse_salary("60k-70k") == 60000
|
|
assert plugins.parse_salary("Negotiable") is None
|
|
assert plugins.parse_score("7") == 7
|
|
assert plugins.parse_score("15") is None
|
|
|
|
|
|
def test_rows_to_indexed_records_keeps_true_sheet_row_across_blank():
|
|
rows = [
|
|
["Name", "Age"],
|
|
["Ada", "30"],
|
|
["", ""],
|
|
["Bob", "40"],
|
|
]
|
|
indexed = plugins.rows_to_indexed_records(rows)
|
|
assert indexed == [
|
|
(2, {"Name": "Ada", "Age": "30"}),
|
|
(4, {"Name": "Bob", "Age": "40"}),
|
|
]
|
|
# read API contract still drops blanks without exposing indices
|
|
assert plugins.rows_to_records(rows) == [
|
|
{"Name": "Ada", "Age": "30"},
|
|
{"Name": "Bob", "Age": "40"},
|
|
]
|
|
|
|
|
|
def test_no_alias_string_appears_under_two_fields():
|
|
seen: dict[str, FormDataField] = {}
|
|
for field, aliases in HEADER_ALIASES.items():
|
|
for alias in aliases:
|
|
assert alias not in seen, f"{alias!r} under {seen[alias]} and {field}"
|
|
seen[alias] = field
|
|
|
|
|
|
def test_form_data_fields_match_model():
|
|
assert set(FORM_DATA_FIELDS) == set(FormData.model_fields)
|
|
|
|
|
|
def test_sample_row_end_to_end():
|
|
mapped = plugins.map_record_to_form_data(
|
|
"PK - Recruitment Tracking Sheet", SAMPLE_RECORD, HEADERS, 2,
|
|
)
|
|
assert mapped["experience"] == "11 Years"
|
|
assert mapped["experience_details"] == "Software development related experience"
|
|
assert mapped["candidate_email"] == "bilal_kf@yahoo.com"
|
|
assert mapped["age"] == 33
|
|
assert mapped["current_salary_value"] == 110000
|
|
assert mapped["communication_skills"] == 7
|
|
assert mapped["pros"] == "Strong backend"
|
|
assert mapped["cons"] == "Limited cloud"
|
|
assert mapped["row_number"] == 2
|
|
assert mapped["sheet"] == "PK - Recruitment Tracking Sheet"
|
|
assert mapped.get("job_post_id") is None
|
|
|
|
|
|
# Google Form Responses tab — headers differ from the PK screening sheet.
|
|
FORM_RESPONSE_HEADERS = [
|
|
"Timestamp",
|
|
"Email",
|
|
"Full Name",
|
|
"Gender",
|
|
"Marital Status",
|
|
"University",
|
|
"Educational Degree",
|
|
"Year of Graduation",
|
|
"Position Applied For",
|
|
"LinkedIn Profile Link",
|
|
"Drop your updated resume",
|
|
"How soon can you join us?",
|
|
"Phone number (03XX-XXXXXXX)",
|
|
"Are you willing to relocate?",
|
|
"Residing City",
|
|
"Residing Country",
|
|
"Area of Interest",
|
|
"Recruiter",
|
|
"Where did you hear about the position you're applying for?",
|
|
]
|
|
|
|
FORM_RESPONSE_RECORD = {
|
|
"Timestamp": "7/2/2026 17:50:19",
|
|
"Email": "nusratazra@gmail.com",
|
|
"Full Name": "Nusrat Azra",
|
|
"Gender": "Female",
|
|
"Marital Status": "Single",
|
|
"University": "Karachi University",
|
|
"Educational Degree": "Masters",
|
|
"Year of Graduation": "12/2/2007",
|
|
"Position Applied For": "Executive Secretary",
|
|
"LinkedIn Profile Link": "https://www.linkedin.com/in/nusrat-azra-executive-manager-to-c-suite-3bb86719/",
|
|
"Drop your updated resume": "https://drive.google.com/open?id=1l5HOY5R6KiL6270A_sV56FkdX6EIGfI4",
|
|
"How soon can you join us?": "1 - 2 weeks",
|
|
"Phone number (03XX-XXXXXXX)": "03312464228",
|
|
"Are you willing to relocate?": "Yes",
|
|
"Residing City": "Karachi",
|
|
"Residing Country": "Pakistan",
|
|
"Area of Interest": "",
|
|
"Recruiter": "",
|
|
"Where did you hear about the position you're applying for?": "Indeed",
|
|
}
|
|
|
|
|
|
def test_form_response_headers_map_to_typed_columns():
|
|
mapped = plugins.map_record_to_form_data(
|
|
"Form Responses - Candidate Database Sheet 2026",
|
|
FORM_RESPONSE_RECORD,
|
|
FORM_RESPONSE_HEADERS,
|
|
9,
|
|
)
|
|
assert mapped["name"] == "Nusrat Azra"
|
|
assert mapped["candidate_email"] == "nusratazra@gmail.com"
|
|
assert mapped["candidate_number"] == "03312464228"
|
|
assert mapped["degree"] == "Masters"
|
|
assert mapped["university"] == "Karachi University"
|
|
assert mapped["position_suitable_for"] == "Executive Secretary"
|
|
assert mapped["notice_period"] == "1 - 2 weeks"
|
|
assert mapped["source_of_application"] == "Indeed"
|
|
assert mapped["ho_availability"] == "Yes"
|
|
assert mapped["marital_status"] == "Single"
|
|
assert mapped["area_of_residence"] == "Karachi" # first matching residence header wins
|
|
assert mapped["profile_link"] == (
|
|
"https://www.linkedin.com/in/nusrat-azra-executive-manager-to-c-suite-3bb86719/"
|
|
)
|
|
assert mapped["entry_year"] == "12/2/2007"
|
|
assert mapped["entry_date"] is not None
|
|
assert mapped["entry_time"] == "17:50:19"
|
|
# Timestamp must never be used as the candidate name.
|
|
assert mapped["name"] != "7/2/2026 17:50:19"
|
|
assert mapped["job_post_id"] is None
|
|
assert mapped["raw_record"]["Full Name"] == "Nusrat Azra"
|