diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 871fd89..2559f2e 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -2152,6 +2152,39 @@ class AtsResults(SQLModel, table=True): grouped.setdefault(row.form_data_id, []).append(row) return grouped + @classmethod + async def latest_per_candidate_for_job(cls, session: AsyncSession, job_post_id): + """Suggested candidates for one job: the newest score per person, best first. + + A person is whichever identity the row carries — form_data_id, + candidate_id or user_id (one is set at a time) — so DISTINCT ON their + COALESCE, newest created_at winning. Rows carrying none are skipped. + Returns (row, user_name, user_email, form_name, form_email, candidate). + """ + from g_sheet.models import FormData + from job.candidate.models import Candidates + + jid = cls._as_uuid(job_post_id) + if jid is None: + return [] + identity = func.coalesce(cls.form_data_id, cls.candidate_id, cls.user_id) + latest = ( + select(cls.id) + .where(cls.job_post_id == jid, identity.is_not(None)) + .distinct(identity) + .order_by(identity.desc(), cls.created_at.desc()) + .subquery() + ) + result = await session.execute( + select(cls, Users.name, Users.email, FormData.name, FormData.candidate_email, Candidates) + .join(latest, cls.id == latest.c.id) + .outerjoin(Users, cls.user_id == Users.id) + .outerjoin(FormData, cls.form_data_id == FormData.id) + .outerjoin(Candidates, cls.candidate_id == Candidates.id) + .order_by(cls.overall_score.desc(), cls.created_at.desc()) + ) + return list(result.all()) + @classmethod async def job_ids_by_emails(cls, session: AsyncSession, emails) -> dict: """{email: {job_post_id, ...}} for any prior ATS score of these people.""" diff --git a/backend/job/app.py b/backend/job/app.py index 0f4eec9..d4313ae 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -983,6 +983,24 @@ async def fetch_jobs( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/jobs/profile/fetch") +async def fetch_job_profile( + job_post_id: str = Query(...), + current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), + session: AsyncSession = Depends(get_session), +): + """Job profile page: the job row, suggested candidates (newest ats_results row + per person, best score first) and the Suggested / Top Match header stats.""" + try: + service=JobPost(session=session) + data=await service.fetch_job_profile(job_post_id,current_user=current_user) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.get("/jobs/export") async def export_jobs( search: str | None = Query(None), diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index e1d56de..48ad300 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -1143,6 +1143,28 @@ class Candidates(SQLModel, table=True): ) return result.scalars().first() + @classmethod + async def latest_completed_for_job_by_emails(cls, session: AsyncSession, job_id, emails) -> dict: + """{lower(email): newest completed row} against one job — the batch form of + get_completed_by_email_job, for scores whose identity is a user or form row.""" + lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()}) + jid = cls._as_uuid(job_id) + if not lowers or jid is None: + return {} + result = await session.execute( + select(cls) + .where( + func.lower(cls.candidate_email).in_(lowers), + cls.job_id == jid, + cls.status == "completed", + ) + .order_by(cls.updated_at.desc()) + ) + out = {} + for row in result.scalars(): + out.setdefault((row.candidate_email or "").strip().lower(), row) + return out + @classmethod async def list_by_emails(cls, session: AsyncSession, emails): """Scored `candidates` rows for these addresses (ATS, not pipeline stage).""" diff --git a/backend/job/job_post/plugins.py b/backend/job/job_post/plugins.py index 8604722..e28119e 100644 --- a/backend/job/job_post/plugins.py +++ b/backend/job/job_post/plugins.py @@ -281,3 +281,57 @@ async def list_buffer_channels() -> list[dict]: "organization_name": org.get("name"), }) return channels + + +# ats_results.band labels, best first — the same cut-offs CandidateView._recommendation writes. +MATCH_BANDS = ("Strong Match", "Potential Match", "Weak Match") +TOP_MATCH_BAND = MATCH_BANDS[0] + + +def suggested_source(inbox_id, form_data_id, candidate_source=None) -> str: + """Where a suggested candidate's score came from: inbox | form | upload | bank.""" + if inbox_id is not None: + return "inbox" + if form_data_id is not None: + return "form" + return (candidate_source or "").strip() or "upload" + + +def _skill_key(value) -> str: + return " ".join(re.sub(r"[^a-z0-9+#]+", " ", str(value or "").lower()).split()) + + +def optional_skill_hits(optional_skills, matched_keywords) -> list[str]: + """Job optional skills the candidate's matched keywords cover, in the job's order. + + A hit is an exact normalized match, or one side containing the other as whole + words ("Salesforce" covers "CRM (Salesforce)"). Keywords are verified against the + resume upstream, so this only has to line up two spellings of the same skill. + """ + keys = [k for k in (_skill_key(m) for m in matched_keywords or []) if k] + hits = [] + for skill in optional_skills or []: + target = _skill_key(skill) + if not target or skill in hits: + continue + padded = f" {target} " + if any(k == target or f" {k} " in padded or padded in f" {k} " for k in keys): + hits.append(skill) + return hits + + +def suggested_summary(candidates) -> dict: + """Header stats for a job's suggested candidates: count, top-band count, best score.""" + bands = {band: 0 for band in MATCH_BANDS} + scores = [] + for c in candidates or []: + if c.get("band") in bands: + bands[c["band"]] += 1 + if c.get("match_score") is not None: + scores.append(c["match_score"]) + return { + "suggested": len(candidates or []), + "top_match": bands[TOP_MATCH_BAND], + "top_score": max(scores) if scores else None, + "bands": bands, + } diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 1adb195..3ff97eb 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -155,3 +155,30 @@ def serialize_status_history(row, *, changed_by_name=None) -> dict: "actor_kind": row.actor_kind, "created_at": row.created_at.isoformat() if row.created_at else None, } + + +def serialize_suggested_candidate(row, *, name=None, email=None, candidate=None, source=None, optional_matched=None) -> dict: + """One suggested candidate on the job profile: the newest ats_results row for a + person, with profile fields from its `candidates` row when one exists.""" + score = row.overall_score + return { + "id": str(row.id), + "user_id": str(row.user_id) if row.user_id else None, + "candidate_id": str(row.candidate_id) if row.candidate_id else None, + "form_data_id": str(row.form_data_id) if row.form_data_id else None, + "inbox_id": row.inbox_id, + "name": name or (candidate.candidate_name if candidate else None) or email or None, + "email": email or (candidate.candidate_email if candidate else None) or None, + "current_title": candidate.job_title if candidate else None, + "current_company": candidate.current_company if candidate else None, + "years_experience": candidate.years_experience if candidate else None, + "match_score": round(score) if score is not None else None, + "band": row.band or None, + "matched_keywords": list(candidate.matched_keywords or []) if candidate else [], + "missing_keywords": list(candidate.missing_keywords or []) if candidate else [], + "optional_matched": list(optional_matched or []), + "summary": (candidate.summary_critique if candidate else None) or row.professional_summary or None, + "source": source, + "scored_candidate_id": str(candidate.id) if candidate else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + } diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 3f4f408..a0c4208 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -11,8 +11,9 @@ from fastapi import HTTPException from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, model_validator -from inbox.models import Inbox_Messages +from inbox.models import AtsResults,Inbox_Messages from job.assignment.views import Assignment +from job.candidate.models import Candidates from job.job_post.enums import RequisitionStatus from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform from role.models import EnumRoles @@ -23,11 +24,14 @@ from job.job_post.plugins import ( list_buffer_channels, local_status, normalize_platform, + optional_skill_hits, parse_buffer_datetime, render_job_post, resolve_channel, + suggested_source, + suggested_summary, ) -from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history +from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history, serialize_suggested_candidate load_dotenv() logger=logging.getLogger("job.job_post") @@ -413,6 +417,52 @@ class JobPost: for r in rows ],total + async def fetch_job_profile(self,job_post_id,current_user=None): + """Job profile page: the requisition row, its suggested candidates and the + Suggested / Top Match header stats — one round trip. + + Suggested = newest ats_results row per person for this job. Profile fields + and keywords come from that score's candidates row; a user- or form-identity + score has none, so it borrows the newest completed row for the same email.""" + uid=JobPosts._as_uuid(job_post_id) + if uid is None: + raise HTTPException(status_code=422,detail="job_post_id must be a UUID") + restrict=await self._restrict_ids_for_requisition_scope(current_user) + if restrict is not None and str(uid) not in {str(i) for i in restrict}: + raise HTTPException(status_code=404,detail="Job post not found") + job=await JobPosts.get_job_post_by_id(self.session,uid) + if not job or job.is_deleted: + raise HTTPException(status_code=404,detail="Job post not found") + + names=await Users.names_by_ids( + self.session, + JobPosts.recruiter_ids_of(job)+[job.hiring_manager_id], + ) + counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[job.id]) + job_payload=serialize_job_row( + job, + names=names, + hiring_manager_name=names.get(str(job.hiring_manager_id)), + applicant_count=counts.get(str(job.id),0), + ) + + rows=await AtsResults.latest_per_candidate_for_job(self.session,job.id) + emails=[user_email or form_email for row,_,user_email,_,form_email,candidate in rows if candidate is None] + by_email=await Candidates.latest_completed_for_job_by_emails(self.session,job.id,emails) + candidates=[] + for row,user_name,user_email,form_name,form_email,candidate in rows: + email=user_email or form_email + scored=candidate or by_email.get((email or "").strip().lower()) + candidates.append(serialize_suggested_candidate( + row, + name=user_name or form_name, + email=email, + candidate=scored, + source=suggested_source(row.inbox_id,row.form_data_id,scored.source if scored else None), + optional_matched=optional_skill_hits(job.optional_skills,scored.matched_keywords if scored else []), + )) + return {"job":job_payload,**suggested_summary(candidates),"candidates":candidates} + async def _job_row(self,row): names=await Users.names_by_ids( self.session, diff --git a/backend/tests/test_job_profile_plugins.py b/backend/tests/test_job_profile_plugins.py new file mode 100644 index 0000000..4ead684 --- /dev/null +++ b/backend/tests/test_job_profile_plugins.py @@ -0,0 +1,75 @@ +"""Unit tests for the job-profile helpers in job/job_post/plugins.py — pure functions only.""" + +from __future__ import annotations + +from job.job_post import plugins + + +# ---------------------------------------------------------------- suggested_source + +def test_inbox_score_is_email_sourced_even_with_a_candidates_row(): + assert plugins.suggested_source(42, None, "upload") == "inbox" + + +def test_form_score_is_form_sourced(): + assert plugins.suggested_source(None, "f-1", None) == "form" + + +def test_upload_score_uses_the_candidates_row_source(): + assert plugins.suggested_source(None, None, "bank") == "bank" + assert plugins.suggested_source(None, None, None) == "upload" + assert plugins.suggested_source(None, None, " ") == "upload" + + +# ---------------------------------------------------------------- optional_skill_hits + +def test_exact_match_ignores_case_and_punctuation(): + assert plugins.optional_skill_hits(["FMCG Background", "Arabic"], ["fmcg-background"]) == ["FMCG Background"] + + +def test_keyword_inside_skill_counts_on_word_boundaries(): + assert plugins.optional_skill_hits(["CRM (Salesforce)"], ["Salesforce"]) == ["CRM (Salesforce)"] + + +def test_skill_inside_keyword_counts(): + assert plugins.optional_skill_hits(["Arabic"], ["Arabic language"]) == ["Arabic"] + + +def test_partial_words_do_not_count(): + assert plugins.optional_skill_hits(["Java"], ["JavaScript"]) == [] + + +def test_hits_keep_job_order_and_skip_duplicates(): + hits = plugins.optional_skill_hits(["Travel", "Arabic", "Arabic"], ["arabic", "travel"]) + assert hits == ["Travel", "Arabic"] + + +def test_no_optional_skills_or_keywords_is_empty(): + assert plugins.optional_skill_hits([], ["Python"]) == [] + assert plugins.optional_skill_hits(["Python"], None) == [] + + +# ---------------------------------------------------------------- suggested_summary + +def test_summary_counts_bands_and_top_match(): + candidates = [ + {"band": "Strong Match", "match_score": 91}, + {"band": "Strong Match", "match_score": 85}, + {"band": "Potential Match", "match_score": 70}, + {"band": "Weak Match", "match_score": 40}, + {"band": None, "match_score": None}, + ] + summary = plugins.suggested_summary(candidates) + assert summary["suggested"] == 5 + assert summary["top_match"] == 2 + assert summary["top_score"] == 91 + assert summary["bands"] == {"Strong Match": 2, "Potential Match": 1, "Weak Match": 1} + + +def test_empty_summary(): + assert plugins.suggested_summary([]) == { + "suggested": 0, + "top_match": 0, + "top_score": None, + "bands": {"Strong Match": 0, "Potential Match": 0, "Weak Match": 0}, + } diff --git a/frontend/job-profile.test.mjs b/frontend/job-profile.test.mjs new file mode 100644 index 0000000..9602c37 --- /dev/null +++ b/frontend/job-profile.test.mjs @@ -0,0 +1,270 @@ +/** + * Job profile page test — /job/:jobId rendered into jsdom against a mocked + * GET /jobs/profile/fetch. + * + * node job-profile.test.mjs + * + * Pins the design contract: employment type in the hero line, Suggested and + * Top Match stats from the one profile request, Optional Skills highlighted + * below Required Skills, and suggested-candidate cards ranked best → worst with + * matched optional skills highlighted on each card. + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +import esbuild from 'esbuild' +import { JSDOM } from 'jsdom' + +// ---------------------------------------------------------------- environment +const dom = new JSDOM('
', { + url: 'http://localhost:5173/', + pretendToBeVisual: true, +}) + +globalThis.window = dom.window +globalThis.document = dom.window.document +Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true }) +globalThis.HTMLElement = dom.window.HTMLElement +globalThis.Element = dom.window.Element +globalThis.Node = dom.window.Node +globalThis.getComputedStyle = dom.window.getComputedStyle +globalThis.localStorage = dom.window.localStorage +globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0) +globalThis.cancelAnimationFrame = clearTimeout +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +class RO { observe() {} unobserve() {} disconnect() {} } +class MO { observe() {} disconnect() {} takeRecords() { return [] } } +globalThis.ResizeObserver = RO +globalThis.MutationObserver = MO +dom.window.ResizeObserver = RO +dom.window.MutationObserver = MO +dom.window.matchMedia = () => ({ + matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, +}) +dom.window.HTMLCanvasElement.prototype.getContext = () => + new Proxy({}, { get: (_, k) => (k === 'canvas' ? {} : () => {}) }) + +const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments', + 'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent', + 'requisitions'] +const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure'] +const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`)) + +dom.window.localStorage.setItem('tf-auth', JSON.stringify({ + access_token: 'test', refresh_token: 'test', expires_in: 1800, + expires_at: Date.now() + 1800_000, + data: { id: 1, name: 'Test User', email: 't@example.com', role_name: 'system_administrator', permissions }, +})) + +// ---------------------------------------------------------------- fixtures +const JOB_ID = '11111111-2222-3333-4444-555555555555' + +function jobRow(overrides = {}) { + return { + id: JOB_ID, + title: 'Regional Sales Executive', + department: 'GEO', + location: 'Multi-region', + employment_type: 'Permanent', + vacancies: 3, + platform: 'linkedin', + requisition_status: 'open', + status: 'draft', + experience_min: 3, + experience_max: 5, + requirements: ['B2B Sales', 'Distributor Management'], + optional_skills: ['Arabic', 'FMCG Background', 'Regional Travel'], + description: 'Own the sales pipeline across the GEO region.', + current_recruiter_ids: [], + recruiter_names: ['Nida Khan'], + hiring_manager_name: 'Amara Osei', + applicant_count: 42, + created_by_name: 'Nida Khan', + created_at: '2026-08-12T09:00:00Z', + ...overrides, + } +} + +function candidate(overrides) { + return { + id: overrides.id, + user_id: null, candidate_id: null, form_data_id: null, inbox_id: null, + email: null, current_title: null, current_company: null, years_experience: null, + matched_keywords: [], missing_keywords: [], optional_matched: [], + summary: null, source: 'upload', scored_candidate_id: null, + created_at: '2026-09-01T10:00:00Z', + ...overrides, + } +} + +const CANDIDATES = [ + candidate({ + id: 'a1', name: 'Sana Iqbal', match_score: 91, band: 'Strong Match', source: 'inbox', + current_title: 'Regional Sales Manager', current_company: 'Unilever', years_experience: 6, + matched_keywords: ['B2B Sales', 'Distributor Management'], optional_matched: ['Arabic'], + summary: 'Six years leading distributor relationships across GCC.', created_at: '2026-09-01T10:00:00Z', + }), + candidate({ + id: 'a2', name: 'Ayesha Noor', match_score: 78, band: 'Potential Match', + matched_keywords: ['B2B Sales'], missing_keywords: ['Distributor Management'], + created_at: '2026-09-05T10:00:00Z', + }), + candidate({ + id: 'a3', name: 'Bilal Ahmed', match_score: 40, band: 'Weak Match', source: 'form', + created_at: '2026-09-03T10:00:00Z', + }), +] + +let profilePayload = null +const REQUESTS = [] + +globalThis.fetch = async (input) => { + const url = String(input?.url ?? input) + REQUESTS.push(url) + const body = url.includes('/jobs/profile/fetch') + ? { data: profilePayload, total: 1, status_code: 200 } + : { data: [], status_code: 200 } + return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify(body) } +} + +// ---------------------------------------------------------------- bundle +const outDir = mkdtempSync(join(tmpdir(), 'tf-jobprofile-')) +const outFile = join(outDir, 'entry.mjs') + +await esbuild.build({ + entryPoints: ['src/__smoke__/entry.jsx'], + outfile: outFile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + jsx: 'automatic', + loader: { '.js': 'jsx', '.jsx': 'jsx' }, + logLevel: 'error', + define: { + 'process.env.NODE_ENV': '"development"', + 'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }), + }, +}) + +// ---------------------------------------------------------------- run +const errors = [] +const origError = console.error +console.error = (...args) => { + const msg = args.map((a) => (a instanceof Error ? a.stack : String(a))).join(' ') + if (msg.includes('React Router Future Flag')) return + errors.push(msg) +} + +let failed = 0 +function check(name, ok, detail = '') { + if (ok) console.log(`ok ${name}`) + else { + failed++ + console.log(`FAIL ${name}${detail ? `\n ${detail}` : ''}`) + } +} + +try { + const mod = await import(pathToFileURL(outFile).href) + mod.boot() + + // ------------------------------------------------ full profile + profilePayload = { + job: jobRow(), + suggested: 3, + top_match: 1, + top_score: 91, + bands: { 'Strong Match': 1, 'Potential Match': 1, 'Weak Match': 1 }, + candidates: CANDIDATES, + } + let container = dom.window.document.createElement('div') + dom.window.document.body.appendChild(container) + let m = await mod.mountRoute(`/job/${JOB_ID}`, container) + await m.settle(60) + + const profileCalls = REQUESTS.filter((u) => u.includes('/jobs/profile/fetch')) + check('one profile request feeds the page', profileCalls.length === 1, `calls=${profileCalls.length}`) + check('profile request carries the job id', profileCalls[0]?.includes(`job_post_id=${JOB_ID}`), profileCalls[0]) + + const role = m.find('.job-hero .ph-role')?.textContent || '' + check('hero line shows department, location and employment type', role === 'GEO · Multi-region · Permanent', `role="${role}"`) + + const stats = [...container.querySelectorAll('.hero-stat')].map((el) => ({ + v: el.querySelector('.v')?.textContent, l: el.querySelector('.l')?.textContent, + })) + check('Suggested stat reads 3', stats.some((s) => s.l === 'Suggested' && s.v === '3'), JSON.stringify(stats)) + check('Top Match stat counts the Strong Match band', stats.some((s) => s.l === 'Top Match' && s.v === '1'), JSON.stringify(stats)) + check('hero tags show vacancies and applicants', m.text().includes('3 Vacancies') && m.text().includes('42 Applicants')) + + const html = m.html() + const reqAt = html.indexOf('Required Skills') + const optAt = html.indexOf('Optional Skills') + check('Optional Skills section sits below Required Skills', reqAt > -1 && optAt > reqAt, `req=${reqAt} opt=${optAt}`) + const optionalTags = [...container.querySelectorAll('.tag.tag-optional')].map((el) => el.textContent.trim()) + check('every optional skill is a highlighted tag', optionalTags.join('|') === 'Arabic|FMCG Background|Regional Travel', optionalTags.join('|')) + check('created line joins date and author', m.text().includes('by Nida Khan')) + + // ------------------------------------------------ suggested tab + await m.click(m.findByText('[role="tab"]', 'Suggested Candidates')) + let cards = [...container.querySelectorAll('.cand-card')] + check('one card per suggested candidate', cards.length === 3, `cards=${cards.length}`) + const names = () => [...container.querySelectorAll('.cand-card .cand-name')].map((el) => el.textContent.trim()) + check('default sort is best → worst', names().join('|') === 'Sana Iqbal|Ayesha Noor|Bilal Ahmed', names().join('|')) + const ranks = [...container.querySelectorAll('.cand-rank')].map((el) => el.textContent) + check('cards are ranked 1..n', ranks.join(',') === '1,2,3', ranks.join(',')) + check('sub line counts and marks the sort', m.text().includes('3 candidates suggested') && m.text().includes('Sorted: Best → Worst')) + + const first = container.querySelector('.cand-card') + const optChips = [...first.querySelectorAll('.cand-chip.opt')].map((el) => el.textContent.trim()) + check('matched optional skill is highlighted on its card', optChips.join('|') === 'Arabic', optChips.join('|')) + check('matched and missing chips render', first.querySelectorAll('.cand-chip.ok').length === 2 + && container.querySelectorAll('.cand-card')[1].querySelectorAll('.cand-chip.miss').length === 1) + check('source label maps inbox → Email', first.textContent.includes('Email')) + check('foot shows years and company', first.textContent.includes('6 yrs · Unilever')) + + const sortSelect = container.querySelector('#suggested-sort') + await m.selectOption(sortSelect, 'name') + check('A → Z sort orders by name and drops ranks', + names().join('|') === 'Ayesha Noor|Bilal Ahmed|Sana Iqbal' && !container.querySelector('.cand-rank'), names().join('|')) + await m.selectOption(sortSelect, 'recent') + check('Most Recent sort orders by score time', names().join('|') === 'Ayesha Noor|Bilal Ahmed|Sana Iqbal', names().join('|')) + + await m.selectOption(container.querySelector('select[aria-label="Match band"]'), 'Weak Match') + check('band filter narrows to that band', names().join('|') === 'Bilal Ahmed', names().join('|')) + await m.unmount() + container.remove() + + // ------------------------------------------------ sparse job + // A different id: the query client is shared across mounts, so the first + // job's profile is still cached under its own key. + const SPARSE_ID = '99999999-2222-3333-4444-555555555555' + profilePayload = { + job: jobRow({ id: SPARSE_ID, employment_type: null, optional_skills: [] }), + suggested: 0, top_match: 0, top_score: null, + bands: { 'Strong Match': 0, 'Potential Match': 0, 'Weak Match': 0 }, + candidates: [], + } + container = dom.window.document.createElement('div') + dom.window.document.body.appendChild(container) + m = await mod.mountRoute(`/job/${SPARSE_ID}?tab=suggested`, container) + await m.settle(60) + const sparseRole = m.find('.job-hero .ph-role')?.textContent || '' + check('no employment type → hero line omits it', sparseRole === 'GEO · Multi-region', `role="${sparseRole}"`) + check('?tab=suggested opens that tab, with an empty state', m.text().includes('No suggested candidates yet')) + await m.click(m.findByText('[role="tab"]', 'Details')) + check('no optional skills → no Optional Skills section', !m.text().includes('Optional Skills')) + await m.unmount() + container.remove() + + check('no console errors', errors.length === 0, errors[0]?.split('\n').slice(0, 3).join(' | ')) +} finally { + console.error = origError + rmSync(outDir, { recursive: true, force: true }) +} + +console.log(failed ? `\n${failed} job profile check(s) FAILED` : '\nAll job profile checks passed') +process.exit(failed ? 1 : 0) diff --git a/frontend/package.json b/frontend/package.json index f06b864..a927402 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,8 @@ "test:candidates": "node candidates-table.test.mjs", "test:cvbank": "node cvbank.test.mjs", "test:mobile": "node mobile.test.mjs", - "verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs" + "test:jobprofile": "node job-profile.test.mjs", + "verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs && node job-profile.test.mjs" }, "dependencies": { "@tanstack/react-query": "^5.101.4", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9c52297..cd38957 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -46,6 +46,7 @@ const SCREENS = { // Detail pages live outside the ROUTES table (no sidebar entry, parameterized path). const CandidatePage = lazy(() => import('./screens/CandidatePage')) +const JobProfile = lazy(() => import('./screens/JobProfile')) export default function App() { return ( @@ -95,6 +96,14 @@ export default function App() { } /> +{j.description}
+{c.summary || No summary}
+ +{j.description}
-