diff --git a/challenge-accessibility-localization-guard/README.md b/challenge-accessibility-localization-guard/README.md new file mode 100644 index 00000000..5f1d68e7 --- /dev/null +++ b/challenge-accessibility-localization-guard/README.md @@ -0,0 +1,17 @@ +# Challenge Accessibility Localization Guard + +Self-contained SCIBASE Scientific Bounty System slice for issue #18. The guard checks whether global solver teams can understand and access challenge materials before submissions open. + +## Why this slice is distinct + +Existing #18 submissions cover intake, rubric readiness, arbitration, workspace privacy, clarification freeze, deadline fairness, communication parity, onboarding clock parity, debrief feedback, duplicate-solver checks, escrow, payout eligibility, sponsor compliance, and award transparency. This module focuses only on accessibility and localization parity: languages, translation freshness, captions/transcripts, screen-reader formats, timezone displays, and accessible prequalification forms. + +## Run + +```bash +npm test +npm run demo +npm run demo:video +``` + +Demo artifacts are written to `reports/`, including JSON, Markdown, SVG, GIF, and MP4 files. diff --git a/challenge-accessibility-localization-guard/demo.js b/challenge-accessibility-localization-guard/demo.js new file mode 100644 index 00000000..de73b759 --- /dev/null +++ b/challenge-accessibility-localization-guard/demo.js @@ -0,0 +1,59 @@ +const fs = require("fs"); +const path = require("path"); + +const { assessAccessibilityLocalization } = require("./index"); +const { accessibleChallenge, riskyChallenge } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +function markdownReport(name, report) { + const findings = report.findings.length + ? report.findings + .map((item) => `- ${item.severity.toUpperCase()} ${item.code}: ${item.message} Affected: ${item.affectedTeams.join(", ")}`) + .join("\n") + : "- No accessibility or localization findings."; + return `# ${report.title} + +Scenario: ${name} + +Decision: ${report.decision.toUpperCase()} + +Reviewed ${report.summary.teamsReviewed} solver teams, ${report.summary.materialsReviewed} materials, and ${report.summary.formsReviewed} forms. + +## Findings + +${findings} + +## Release Criteria + +${report.releaseCriteria.map((item) => `- ${item}`).join("\n")} +`; +} + +function svgReport(report) { + const color = report.decision === "hold" ? "#dc2626" : report.decision === "revise" ? "#d97706" : "#16a34a"; + return ` + + Challenge Accessibility Localization Guard + ${report.challengeId} + + ${report.decision.toUpperCase()} + Findings: ${report.summary.findings} + High: ${report.summary.high} + Medium: ${report.summary.medium} + Low: ${report.summary.low} + Synthetic challenge packet only. No external translation or private data. +`; +} + +for (const [name, challenge] of [ + ["accessible-challenge", accessibleChallenge], + ["risky-challenge", riskyChallenge], +]) { + const report = assessAccessibilityLocalization(challenge); + fs.writeFileSync(path.join(reportsDir, `${name}.json`), JSON.stringify(report, null, 2)); + fs.writeFileSync(path.join(reportsDir, `${name}.md`), markdownReport(name, report)); + fs.writeFileSync(path.join(reportsDir, `${name}.svg`), svgReport(report)); + console.log(`${name}: ${report.decision} (${report.summary.findings} findings)`); +} diff --git a/challenge-accessibility-localization-guard/demo_video.py b/challenge-accessibility-localization-guard/demo_video.py new file mode 100644 index 00000000..15e37d42 --- /dev/null +++ b/challenge-accessibility-localization-guard/demo_video.py @@ -0,0 +1,46 @@ +from pathlib import Path + +import imageio.v3 as iio +import numpy as np +from PIL import Image, ImageDraw, ImageFont + + +ROOT = Path(__file__).resolve().parent +REPORTS = ROOT / "reports" +REPORTS.mkdir(exist_ok=True) + + +def font(size): + for name in ("arial.ttf", "segoeui.ttf"): + try: + return ImageFont.truetype(name, size) + except OSError: + pass + return ImageFont.load_default() + + +slides = [ + ("Accessibility Localization Guard", "Scientific Bounty System #18"), + ("Checks", "required languages + translation freshness"), + ("Checks", "captions, transcripts, screen-reader formats"), + ("Decision", "hold challenge launch until global teams have parity"), +] + +frames = [] +for index, (title, subtitle) in enumerate(slides, start=1): + image = Image.new("RGB", (960, 544), "#111827") + draw = ImageDraw.Draw(image) + draw.rectangle((44, 52, 916, 492), outline="#38bdf8", width=3) + draw.text((80, 124), title, fill="#f8fafc", font=font(40)) + draw.text((80, 206), subtitle, fill="#e0f2fe", font=font(26)) + draw.rectangle((80, 326, 794, 382), fill="#075985") + draw.text((104, 342), "challenge materials must be understandable and accessible", fill="#f0f9ff", font=font(22)) + draw.text((80, 438), f"Slide {index}/4 - synthetic reviewer artifact", fill="#cbd5e1", font=font(20)) + frames.extend([image] * 14) + +gif_path = REPORTS / "demo.gif" +mp4_path = REPORTS / "demo.mp4" +frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=120, loop=0) +iio.imwrite(mp4_path, [np.asarray(frame) for frame in frames], fps=8, codec="libx264") +print(f"wrote {gif_path}") +print(f"wrote {mp4_path}") diff --git a/challenge-accessibility-localization-guard/index.js b/challenge-accessibility-localization-guard/index.js new file mode 100644 index 00000000..05bc2a7a --- /dev/null +++ b/challenge-accessibility-localization-guard/index.js @@ -0,0 +1,222 @@ +const HIGH = "high"; +const MEDIUM = "medium"; +const LOW = "low"; + +function requiredString(value, field) { + if (typeof value !== "string" || value.trim() === "") { + throw new TypeError(`${field} must be a non-empty string`); + } + return value.trim(); +} + +function array(value, field) { + if (!Array.isArray(value)) { + throw new TypeError(`${field} must be an array`); + } + return value; +} + +function unique(values) { + return [...new Set(values.map(String))]; +} + +function normalizeChallenge(raw) { + return { + challengeId: requiredString(raw.challengeId, "challengeId"), + title: requiredString(raw.title, "title"), + currentRuleDigest: requiredString(raw.currentRuleDigest, "currentRuleDigest"), + solverTeams: array(raw.solverTeams || [], "solverTeams").map((team) => ({ + id: requiredString(team.id, "team.id"), + requiredLanguages: unique(array(team.requiredLanguages || [], "team.requiredLanguages")), + accessibilityNeeds: unique(array(team.accessibilityNeeds || [], "team.accessibilityNeeds")), + timezone: requiredString(team.timezone || "UTC", "team.timezone"), + })), + materials: array(raw.materials || [], "materials").map((material) => ({ + id: requiredString(material.id, "material.id"), + required: Boolean(material.required), + type: requiredString(material.type, "material.type"), + languageVersions: material.languageVersions || {}, + accessibleFormats: unique(array(material.accessibleFormats || [], "material.accessibleFormats")), + })), + deadlines: array(raw.deadlines || [], "deadlines").map((deadline) => ({ + id: requiredString(deadline.id, "deadline.id"), + timestamp: requiredString(deadline.timestamp, "deadline.timestamp"), + displayTimezones: unique(array(deadline.displayTimezones || [], "deadline.displayTimezones")), + })), + forms: array(raw.forms || [], "forms").map((form) => ({ + id: requiredString(form.id, "form.id"), + required: Boolean(form.required), + languageVersions: form.languageVersions || {}, + accessibleFormats: unique(array(form.accessibleFormats || [], "form.accessibleFormats")), + })), + }; +} + +function finding(code, severity, sourceId, message, affectedTeams, remediation) { + return { code, severity, sourceId, message, affectedTeams, remediation }; +} + +function hasNeedCoverage(formats, need, materialType) { + if (need === "captions") { + if (materialType !== "video") { + return true; + } + return formats.includes("captions") || formats.includes("transcript"); + } + if (need === "screen-reader") { + if (materialType === "video") { + return formats.includes("transcript") || formats.includes("audio-description"); + } + return formats.includes("screen-reader") || formats.includes("html") || formats.includes("pdf-tagged"); + } + return formats.includes(need); +} + +function assessAccessibilityLocalization(rawChallenge) { + const challenge = normalizeChallenge(rawChallenge); + const findings = []; + const allLanguages = unique(challenge.solverTeams.flatMap((team) => team.requiredLanguages)); + + for (const material of challenge.materials.filter((item) => item.required)) { + for (const language of allLanguages) { + const digest = material.languageVersions[language]; + if (!digest) { + const teams = challenge.solverTeams + .filter((team) => team.requiredLanguages.includes(language)) + .map((team) => team.id); + findings.push( + finding( + "MISSING_REQUIRED_LANGUAGE", + HIGH, + material.id, + `${material.id} is missing required ${language} language coverage.`, + teams, + "Provide the missing translation before opening submissions or mark the language as not required." + ) + ); + } else if (digest !== challenge.currentRuleDigest) { + const teams = challenge.solverTeams + .filter((team) => team.requiredLanguages.includes(language)) + .map((team) => team.id); + findings.push( + finding( + "STALE_TRANSLATION_DIGEST", + HIGH, + material.id, + `${material.id} ${language} version references ${digest}, not current ${challenge.currentRuleDigest}.`, + teams, + "Regenerate translated materials from the current rule digest and restart any affected timing windows." + ) + ); + } + } + + for (const team of challenge.solverTeams) { + const missingNeeds = team.accessibilityNeeds.filter((need) => !hasNeedCoverage(material.accessibleFormats, need, material.type)); + if (missingNeeds.length > 0) { + findings.push( + finding( + "ACCESSIBILITY_FORMAT_GAP", + HIGH, + material.id, + `${material.id} lacks accessible formats for ${missingNeeds.join(", ")}.`, + [team.id], + "Add equivalent accessible formats, then notify affected teams before submissions open." + ) + ); + } + } + + if (material.type === "video" && !material.accessibleFormats.includes("captions") && !material.accessibleFormats.includes("transcript")) { + findings.push( + finding( + "VIDEO_CAPTION_TRANSCRIPT_GAP", + MEDIUM, + material.id, + `${material.id} is a required video without captions or transcript.`, + challenge.solverTeams.map((team) => team.id), + "Add captions or a transcript before treating the walkthrough as required." + ) + ); + } + } + + for (const deadline of challenge.deadlines) { + const missingTimezones = challenge.solverTeams + .filter((team) => !deadline.displayTimezones.includes(team.timezone)) + .map((team) => team.id); + if (missingTimezones.length > 0) { + findings.push( + finding( + "TIMEZONE_DISPLAY_GAP", + MEDIUM, + deadline.id, + `${deadline.id} deadline is not displayed in every solver team's timezone.`, + missingTimezones, + "Display UTC plus each eligible team's local timezone before the challenge clock starts." + ) + ); + } + } + + for (const form of challenge.forms.filter((item) => item.required)) { + for (const language of allLanguages) { + if (!form.languageVersions[language]) { + const teams = challenge.solverTeams + .filter((team) => team.requiredLanguages.includes(language)) + .map((team) => team.id); + findings.push( + finding( + "FORM_LANGUAGE_GAP", + MEDIUM, + form.id, + `${form.id} form is missing ${language} localization.`, + teams, + "Localize prequalification forms before requiring teams to complete them." + ) + ); + } + } + if (!form.accessibleFormats.includes("keyboard") || !form.accessibleFormats.includes("screen-reader")) { + findings.push( + finding( + "FORM_ACCESSIBILITY_GAP", + MEDIUM, + form.id, + `${form.id} form is not keyboard and screen-reader accessible.`, + challenge.solverTeams.map((team) => team.id), + "Add keyboard navigation and screen-reader labels before using the form for eligibility." + ) + ); + } + } + + const high = findings.filter((item) => item.severity === HIGH).length; + const medium = findings.filter((item) => item.severity === MEDIUM).length; + return { + challengeId: challenge.challengeId, + title: challenge.title, + decision: high > 0 ? "hold" : medium > 0 ? "revise" : "release", + summary: { + teamsReviewed: challenge.solverTeams.length, + materialsReviewed: challenge.materials.length, + formsReviewed: challenge.forms.length, + findings: findings.length, + high, + medium, + low: findings.filter((item) => item.severity === LOW).length, + }, + findings, + releaseCriteria: [ + "Required challenge materials cover every solver-team language.", + "Translations reference the current immutable rule digest.", + "Required documents, videos, and forms include equivalent accessible formats.", + "Deadlines are shown in UTC and each eligible team's local timezone.", + ], + }; +} + +module.exports = { + assessAccessibilityLocalization, + normalizeChallenge, +}; diff --git a/challenge-accessibility-localization-guard/package.json b/challenge-accessibility-localization-guard/package.json new file mode 100644 index 00000000..8964025f --- /dev/null +++ b/challenge-accessibility-localization-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "challenge-accessibility-localization-guard", + "version": "1.0.0", + "description": "Accessibility and localization parity guard for SCIBASE scientific bounty challenges", + "main": "index.js", + "type": "commonjs", + "scripts": { + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "python demo_video.py" + }, + "license": "MIT" +} diff --git a/challenge-accessibility-localization-guard/reports/accessible-challenge.json b/challenge-accessibility-localization-guard/reports/accessible-challenge.json new file mode 100644 index 00000000..06f5ac1a --- /dev/null +++ b/challenge-accessibility-localization-guard/reports/accessible-challenge.json @@ -0,0 +1,21 @@ +{ + "challengeId": "sb-access-001", + "title": "Open climate forecasting challenge", + "decision": "release", + "summary": { + "teamsReviewed": 2, + "materialsReviewed": 2, + "formsReviewed": 1, + "findings": 0, + "high": 0, + "medium": 0, + "low": 0 + }, + "findings": [], + "releaseCriteria": [ + "Required challenge materials cover every solver-team language.", + "Translations reference the current immutable rule digest.", + "Required documents, videos, and forms include equivalent accessible formats.", + "Deadlines are shown in UTC and each eligible team's local timezone." + ] +} \ No newline at end of file diff --git a/challenge-accessibility-localization-guard/reports/accessible-challenge.md b/challenge-accessibility-localization-guard/reports/accessible-challenge.md new file mode 100644 index 00000000..6d879327 --- /dev/null +++ b/challenge-accessibility-localization-guard/reports/accessible-challenge.md @@ -0,0 +1,18 @@ +# Open climate forecasting challenge + +Scenario: accessible-challenge + +Decision: RELEASE + +Reviewed 2 solver teams, 2 materials, and 1 forms. + +## Findings + +- No accessibility or localization findings. + +## Release Criteria + +- Required challenge materials cover every solver-team language. +- Translations reference the current immutable rule digest. +- Required documents, videos, and forms include equivalent accessible formats. +- Deadlines are shown in UTC and each eligible team's local timezone. diff --git a/challenge-accessibility-localization-guard/reports/accessible-challenge.svg b/challenge-accessibility-localization-guard/reports/accessible-challenge.svg new file mode 100644 index 00000000..49b4342a --- /dev/null +++ b/challenge-accessibility-localization-guard/reports/accessible-challenge.svg @@ -0,0 +1,12 @@ + + + Challenge Accessibility Localization Guard + sb-access-001 + + RELEASE + Findings: 0 + High: 0 + Medium: 0 + Low: 0 + Synthetic challenge packet only. No external translation or private data. + \ No newline at end of file diff --git a/challenge-accessibility-localization-guard/reports/demo.gif b/challenge-accessibility-localization-guard/reports/demo.gif new file mode 100644 index 00000000..0d97a866 Binary files /dev/null and b/challenge-accessibility-localization-guard/reports/demo.gif differ diff --git a/challenge-accessibility-localization-guard/reports/demo.mp4 b/challenge-accessibility-localization-guard/reports/demo.mp4 new file mode 100644 index 00000000..9e3a7e99 Binary files /dev/null and b/challenge-accessibility-localization-guard/reports/demo.mp4 differ diff --git a/challenge-accessibility-localization-guard/reports/risky-challenge.json b/challenge-accessibility-localization-guard/reports/risky-challenge.json new file mode 100644 index 00000000..0f3e9e62 --- /dev/null +++ b/challenge-accessibility-localization-guard/reports/risky-challenge.json @@ -0,0 +1,145 @@ +{ + "challengeId": "sb-access-002", + "title": "Open climate forecasting challenge", + "decision": "hold", + "summary": { + "teamsReviewed": 2, + "materialsReviewed": 2, + "formsReviewed": 1, + "findings": 12, + "high": 7, + "medium": 5, + "low": 0 + }, + "findings": [ + { + "code": "STALE_TRANSLATION_DIGEST", + "severity": "high", + "sourceId": "rules", + "message": "rules es version references rules-v4, not current rules-v5.", + "affectedTeams": [ + "team-mx" + ], + "remediation": "Regenerate translated materials from the current rule digest and restart any affected timing windows." + }, + { + "code": "MISSING_REQUIRED_LANGUAGE", + "severity": "high", + "sourceId": "rules", + "message": "rules is missing required ja language coverage.", + "affectedTeams": [ + "team-jp" + ], + "remediation": "Provide the missing translation before opening submissions or mark the language as not required." + }, + { + "code": "ACCESSIBILITY_FORMAT_GAP", + "severity": "high", + "sourceId": "rules", + "message": "rules lacks accessible formats for screen-reader.", + "affectedTeams": [ + "team-jp" + ], + "remediation": "Add equivalent accessible formats, then notify affected teams before submissions open." + }, + { + "code": "MISSING_REQUIRED_LANGUAGE", + "severity": "high", + "sourceId": "walkthrough", + "message": "walkthrough is missing required es language coverage.", + "affectedTeams": [ + "team-mx" + ], + "remediation": "Provide the missing translation before opening submissions or mark the language as not required." + }, + { + "code": "MISSING_REQUIRED_LANGUAGE", + "severity": "high", + "sourceId": "walkthrough", + "message": "walkthrough is missing required ja language coverage.", + "affectedTeams": [ + "team-jp" + ], + "remediation": "Provide the missing translation before opening submissions or mark the language as not required." + }, + { + "code": "ACCESSIBILITY_FORMAT_GAP", + "severity": "high", + "sourceId": "walkthrough", + "message": "walkthrough lacks accessible formats for captions.", + "affectedTeams": [ + "team-mx" + ], + "remediation": "Add equivalent accessible formats, then notify affected teams before submissions open." + }, + { + "code": "ACCESSIBILITY_FORMAT_GAP", + "severity": "high", + "sourceId": "walkthrough", + "message": "walkthrough lacks accessible formats for screen-reader.", + "affectedTeams": [ + "team-jp" + ], + "remediation": "Add equivalent accessible formats, then notify affected teams before submissions open." + }, + { + "code": "VIDEO_CAPTION_TRANSCRIPT_GAP", + "severity": "medium", + "sourceId": "walkthrough", + "message": "walkthrough is a required video without captions or transcript.", + "affectedTeams": [ + "team-mx", + "team-jp" + ], + "remediation": "Add captions or a transcript before treating the walkthrough as required." + }, + { + "code": "TIMEZONE_DISPLAY_GAP", + "severity": "medium", + "sourceId": "submission", + "message": "submission deadline is not displayed in every solver team's timezone.", + "affectedTeams": [ + "team-mx", + "team-jp" + ], + "remediation": "Display UTC plus each eligible team's local timezone before the challenge clock starts." + }, + { + "code": "FORM_LANGUAGE_GAP", + "severity": "medium", + "sourceId": "prequalification", + "message": "prequalification form is missing es localization.", + "affectedTeams": [ + "team-mx" + ], + "remediation": "Localize prequalification forms before requiring teams to complete them." + }, + { + "code": "FORM_LANGUAGE_GAP", + "severity": "medium", + "sourceId": "prequalification", + "message": "prequalification form is missing ja localization.", + "affectedTeams": [ + "team-jp" + ], + "remediation": "Localize prequalification forms before requiring teams to complete them." + }, + { + "code": "FORM_ACCESSIBILITY_GAP", + "severity": "medium", + "sourceId": "prequalification", + "message": "prequalification form is not keyboard and screen-reader accessible.", + "affectedTeams": [ + "team-mx", + "team-jp" + ], + "remediation": "Add keyboard navigation and screen-reader labels before using the form for eligibility." + } + ], + "releaseCriteria": [ + "Required challenge materials cover every solver-team language.", + "Translations reference the current immutable rule digest.", + "Required documents, videos, and forms include equivalent accessible formats.", + "Deadlines are shown in UTC and each eligible team's local timezone." + ] +} \ No newline at end of file diff --git a/challenge-accessibility-localization-guard/reports/risky-challenge.md b/challenge-accessibility-localization-guard/reports/risky-challenge.md new file mode 100644 index 00000000..5ba295ef --- /dev/null +++ b/challenge-accessibility-localization-guard/reports/risky-challenge.md @@ -0,0 +1,29 @@ +# Open climate forecasting challenge + +Scenario: risky-challenge + +Decision: HOLD + +Reviewed 2 solver teams, 2 materials, and 1 forms. + +## Findings + +- HIGH STALE_TRANSLATION_DIGEST: rules es version references rules-v4, not current rules-v5. Affected: team-mx +- HIGH MISSING_REQUIRED_LANGUAGE: rules is missing required ja language coverage. Affected: team-jp +- HIGH ACCESSIBILITY_FORMAT_GAP: rules lacks accessible formats for screen-reader. Affected: team-jp +- HIGH MISSING_REQUIRED_LANGUAGE: walkthrough is missing required es language coverage. Affected: team-mx +- HIGH MISSING_REQUIRED_LANGUAGE: walkthrough is missing required ja language coverage. Affected: team-jp +- HIGH ACCESSIBILITY_FORMAT_GAP: walkthrough lacks accessible formats for captions. Affected: team-mx +- HIGH ACCESSIBILITY_FORMAT_GAP: walkthrough lacks accessible formats for screen-reader. Affected: team-jp +- MEDIUM VIDEO_CAPTION_TRANSCRIPT_GAP: walkthrough is a required video without captions or transcript. Affected: team-mx, team-jp +- MEDIUM TIMEZONE_DISPLAY_GAP: submission deadline is not displayed in every solver team's timezone. Affected: team-mx, team-jp +- MEDIUM FORM_LANGUAGE_GAP: prequalification form is missing es localization. Affected: team-mx +- MEDIUM FORM_LANGUAGE_GAP: prequalification form is missing ja localization. Affected: team-jp +- MEDIUM FORM_ACCESSIBILITY_GAP: prequalification form is not keyboard and screen-reader accessible. Affected: team-mx, team-jp + +## Release Criteria + +- Required challenge materials cover every solver-team language. +- Translations reference the current immutable rule digest. +- Required documents, videos, and forms include equivalent accessible formats. +- Deadlines are shown in UTC and each eligible team's local timezone. diff --git a/challenge-accessibility-localization-guard/reports/risky-challenge.svg b/challenge-accessibility-localization-guard/reports/risky-challenge.svg new file mode 100644 index 00000000..ee5c4f9d --- /dev/null +++ b/challenge-accessibility-localization-guard/reports/risky-challenge.svg @@ -0,0 +1,12 @@ + + + Challenge Accessibility Localization Guard + sb-access-002 + + HOLD + Findings: 12 + High: 7 + Medium: 5 + Low: 0 + Synthetic challenge packet only. No external translation or private data. + \ No newline at end of file diff --git a/challenge-accessibility-localization-guard/requirements-map.md b/challenge-accessibility-localization-guard/requirements-map.md new file mode 100644 index 00000000..afcd7829 --- /dev/null +++ b/challenge-accessibility-localization-guard/requirements-map.md @@ -0,0 +1,13 @@ +# Requirements Map + +Issue #18 asks for a Scientific Bounty System that supports global research participation, challenge posting, secure submissions, arbitration, rewards, and trust between sponsors and solvers. + +| Issue capability | This implementation | +| --- | --- | +| Global participation | Validates required-language coverage for every eligible solver team. | +| Challenge posting portal | Holds publication when required rules, walkthroughs, or forms are inaccessible. | +| Timeline fairness | Checks UTC and local-time display for solver-team deadlines. | +| Prequalification support | Validates localized and keyboard/screen-reader accessible prequalification forms. | +| Trust and arbitration readiness | Emits deterministic `release`, `revise`, or `hold` decisions with remediation steps. | + +The module uses synthetic data only and does not contact credentials, private challenge records, payment systems, external APIs, or live translation services. diff --git a/challenge-accessibility-localization-guard/sample-data.js b/challenge-accessibility-localization-guard/sample-data.js new file mode 100644 index 00000000..59c7d9c5 --- /dev/null +++ b/challenge-accessibility-localization-guard/sample-data.js @@ -0,0 +1,63 @@ +const accessibleChallenge = { + challengeId: "sb-access-001", + title: "Open climate forecasting challenge", + currentRuleDigest: "rules-v5", + solverTeams: [ + { id: "team-mx", requiredLanguages: ["en", "es"], accessibilityNeeds: ["captions"], timezone: "America/Mexico_City" }, + { id: "team-jp", requiredLanguages: ["en", "ja"], accessibilityNeeds: ["screen-reader"], timezone: "Asia/Tokyo" }, + ], + materials: [ + { + id: "rules", + required: true, + type: "document", + languageVersions: { en: "rules-v5", es: "rules-v5", ja: "rules-v5" }, + accessibleFormats: ["html", "pdf-tagged", "screen-reader"], + }, + { + id: "walkthrough", + required: true, + type: "video", + languageVersions: { en: "rules-v5", es: "rules-v5", ja: "rules-v5" }, + accessibleFormats: ["captions", "transcript", "audio-description"], + }, + ], + deadlines: [ + { id: "submission", timestamp: "2026-07-10T17:00:00Z", displayTimezones: ["UTC", "America/Mexico_City", "Asia/Tokyo"] }, + ], + forms: [ + { id: "prequalification", required: true, languageVersions: { en: "rules-v5", es: "rules-v5", ja: "rules-v5" }, accessibleFormats: ["keyboard", "screen-reader"] }, + ], +}; + +const riskyChallenge = { + ...accessibleChallenge, + challengeId: "sb-access-002", + materials: [ + { + id: "rules", + required: true, + type: "document", + languageVersions: { en: "rules-v5", es: "rules-v4" }, + accessibleFormats: ["pdf"], + }, + { + id: "walkthrough", + required: true, + type: "video", + languageVersions: { en: "rules-v5" }, + accessibleFormats: ["video-only"], + }, + ], + deadlines: [ + { id: "submission", timestamp: "2026-07-10T17:00:00Z", displayTimezones: ["UTC"] }, + ], + forms: [ + { id: "prequalification", required: true, languageVersions: { en: "rules-v5" }, accessibleFormats: ["mouse"] }, + ], +}; + +module.exports = { + accessibleChallenge, + riskyChallenge, +}; diff --git a/challenge-accessibility-localization-guard/test.js b/challenge-accessibility-localization-guard/test.js new file mode 100644 index 00000000..3dc77508 --- /dev/null +++ b/challenge-accessibility-localization-guard/test.js @@ -0,0 +1,36 @@ +const assert = require("assert"); + +const { assessAccessibilityLocalization, normalizeChallenge } = require("./index"); +const { accessibleChallenge, riskyChallenge } = require("./sample-data"); + +const clean = assessAccessibilityLocalization(accessibleChallenge); +assert.strictEqual(clean.decision, "release"); +assert.strictEqual(clean.summary.findings, 0); + +const risky = assessAccessibilityLocalization(riskyChallenge); +assert.strictEqual(risky.decision, "hold"); +for (const code of [ + "MISSING_REQUIRED_LANGUAGE", + "STALE_TRANSLATION_DIGEST", + "ACCESSIBILITY_FORMAT_GAP", + "VIDEO_CAPTION_TRANSCRIPT_GAP", + "TIMEZONE_DISPLAY_GAP", + "FORM_LANGUAGE_GAP", + "FORM_ACCESSIBILITY_GAP", +]) { + assert(risky.findings.some((finding) => finding.code === code), `missing ${code}`); +} + +const partial = assessAccessibilityLocalization({ + ...accessibleChallenge, + deadlines: [{ id: "final", timestamp: "2026-07-10T17:00:00Z", displayTimezones: ["UTC"] }], +}); +assert.strictEqual(partial.decision, "revise"); +assert(partial.findings.some((finding) => finding.code === "TIMEZONE_DISPLAY_GAP")); + +assert.throws( + () => normalizeChallenge({ ...accessibleChallenge, challengeId: "" }), + /challengeId must be a non-empty string/ +); + +console.log("challenge accessibility localization guard tests passed");