diff --git a/.envrc.example b/.envrc.example new file mode 100644 index 00000000..b879a2ec --- /dev/null +++ b/.envrc.example @@ -0,0 +1,15 @@ +# Example environment variables for FAMS Tools +# Copy this file to .envrc and replace with actual values + +export ETDA_API_URL="http://localhost:3000" +export ETDA_API_TOKEN="generate_a_token_in_ETDA_console" +export FAMS_WEBSERVICES_USERNAME +export FAMS_WEBSERVICES_PASSWORD +export FAMS_MAIN_USERNAME +export FAMS_MAIN_PASSWORD +export FAMS_BACKUPS_SERVICE_USERNAME +export FAMS_BACKUPS_SERVICE_PASSWORD +export FAMS_METADATA_DB_KEY +export FAMS_S3_BUCKET_API_KEY +export FAMS_LP_SFTP_USERNAME +export FAMS_LP_SFTP_HOST diff --git a/.gitignore b/.gitignore index 3cc11584..8f89fd14 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ spec/examples.txt .envrc .irb_history .DS_Store +CLAUDE.md \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 69dc4a93..5f3ca30a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -102,8 +102,8 @@ GEM builder (3.3.0) bundle-audit (0.1.0) bundler-audit - bundler-audit (0.9.1) - bundler (>= 1.2.0, < 3) + bundler-audit (0.9.3) + bundler (>= 1.2.0) thor (~> 1.0) byebug (11.1.3) capistrano (3.18.0) @@ -526,4 +526,4 @@ DEPENDENCIES whenever BUNDLED WITH - 4.0.9 + 4.0.9 diff --git a/README.md b/README.md index 88e669ec..59b0dfb3 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ This app parses data from faculty CVs, and integrates data into Activity Insight ## Setup Add the following untracked files/folders: - - * `config/database.yml` This will be similar to `config/database.yml.sample` + + * `config/database.yml` This will be similar to `config/database.yml.sample` * `config/activity_insight.yml` * `config/integration_passcode.yml` * `public/log` diff --git a/app/importers/committee_data/committee_xml_builder.rb b/app/importers/committee_data/committee_xml_builder.rb index caec7c27..5d662238 100644 --- a/app/importers/committee_data/committee_xml_builder.rb +++ b/app/importers/committee_data/committee_xml_builder.rb @@ -25,11 +25,14 @@ def build_xml(batch) committees.each do |committee| xml.DSL do xml.ROLE committee.role + xml.TYPE committee.type_of_work + xml.COMPSTAGE committee.stage_of_completion + xml.DTY_START committee.start_year + xml.DTY_END committee.completion_year if committee.completion_year xml.DSL_STUDENT do xml.FNAME committee.student_fname xml.LNAME committee.student_lname - xml.DEG committee.degree_type xml.TITLE committee.thesis_title end end diff --git a/app/importers/committee_data/etda_importer.rb b/app/importers/committee_data/etda_importer.rb new file mode 100644 index 00000000..4c14be8d --- /dev/null +++ b/app/importers/committee_data/etda_importer.rb @@ -0,0 +1,81 @@ +require 'etda/committee_records_client' + +module CommitteeData + class EtdaImporter + class DegreeTypeError < RuntimeError; end + + def import_all + Faculty.find_each do |faculty| + import_for_faculty(faculty) + rescue Etda::CommitteeRecordsClient::CommitteeRecordsClientError => e + Rails.logger.error("Failed to import committees for #{faculty.access_id}: #{e.message}") + end + end + + private + + def import_for_faculty(faculty) + result = Etda::CommitteeRecordsClient.new.faculty_committees(faculty.access_id) + committees_data = result[:data]['committees'] + + committees_data.each do |committee| + next unless within_last_six_months?(committee['approval_started_at']) + + normalized_role = CommitteeRoleNormalizer.normalize(committee['role']) + + faculty.committees.create!( + student_fname: committee['student_fname'], + student_lname: committee['student_lname'], + role: normalized_role, + thesis_title: committee['title'], + type_of_work: map_type_of_work(committee['degree_type']), + stage_of_completion: determine_completion_stage( + committee['final_submission_approved_at'] + ), + start_year: extract_year(committee['approval_started_at']), + completion_year: extract_year(committee['final_submission_approved_at']) + ) + end + + Rails.logger.info("Imported #{committees_data.length} committees for #{faculty.access_id}") + end + + def map_type_of_work(degree_type) + return nil if degree_type.blank? + + case degree_type.strip + when 'Master Thesis' + "Master's Committee" + when 'Dissertation' + 'Dissertation Committee' + when 'Thesis' + 'Undergraduate Honors Thesis' + when 'Final Paper' + "Master's Paper Committee" + + else + raise DegreeTypeError, "Unexpected Degree Type: #{degree_type.strip}" + end + end + + def extract_year(date_string) + return nil if date_string.blank? + + Date.parse(date_string).year + rescue ArgumentError + nil + end + + def within_last_six_months?(date_string) + return false if date_string.blank? + + Date.parse(date_string) >= 6.months.ago + end + + def determine_completion_stage(final_submission_approved_at) + return 'Completed' if final_submission_approved_at.present? + + 'In Process' + end + end +end diff --git a/app/jobs/activity_insight_committee_job.rb b/app/jobs/activity_insight_committee_job.rb index da972bfc..bc99b6db 100644 --- a/app/jobs/activity_insight_committee_job.rb +++ b/app/jobs/activity_insight_committee_job.rb @@ -1,6 +1,6 @@ class ActivityInsightCommitteeJob < ApplicationJob - def integrate(params) - target = params[:target] + def integrate(target) + CommitteeData::EtdaImporter.new.import_all builder = CommitteeData::CommitteeXmlBuilder.new xml_enum = builder.xmls_enumerator @@ -12,6 +12,6 @@ def integrate(params) private def name - 'Committee Membership Integration' + 'Committee Integration' end end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index d536a4ef..84758189 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -86,5 +86,6 @@ def delete_all_data Yearly.delete_all ComEffort.delete_all ComQuality.delete_all + Committee.delete_all end end diff --git a/app/services/committee_role_normalizer.rb b/app/services/committee_role_normalizer.rb new file mode 100644 index 00000000..9df716ca --- /dev/null +++ b/app/services/committee_role_normalizer.rb @@ -0,0 +1,25 @@ +class CommitteeRoleNormalizer + PRIORITY_REGEX = [ + ['Co-Chairperson', %r{(co[-\s]?chair|co[-\s]?chairperson|committee chair/co-chair)}i], + ['Chairperson', /(chairperson|chair of committee|committee chair|chair.)/i], + ['Co-Advisor', /(co[-\s]?dissertation\s*advis(or|er)|co[-\s]?advisor)/i], + ['Advisor', /(dissertation\s*advis(?:o?r|er)|advis(?:o?r|er))/i], + ['Supervisor', /supervisor/i], + ['Mentor', /mentor/i], + ['Second Reader', /second\s+reader/i], + ['Reader', /reader/i], + ['Member', /(member|rep|represent|representative|substitute)/i] + + ].freeze + + def self.normalize(raw_name) + text = raw_name.to_s.strip + return 'Other' if text.empty? + + PRIORITY_REGEX.each do |label, regex| + return label if text.match?(regex) + end + + 'Other' + end +end diff --git a/db/migrate/20260324000000_add_activity_insight_fields_to_committees.rb b/db/migrate/20260324000000_add_activity_insight_fields_to_committees.rb new file mode 100644 index 00000000..7b451a05 --- /dev/null +++ b/db/migrate/20260324000000_add_activity_insight_fields_to_committees.rb @@ -0,0 +1,10 @@ +class AddActivityInsightFieldsToCommittees < ActiveRecord::Migration[7.2] + def change + change_table :committees, bulk: true do |t| + t.string :type_of_work + t.string :stage_of_completion + t.integer :start_year + t.integer :completion_year + end + end +end diff --git a/db/migrate/20260326000000_remove_degree_type_and_role_other_from_committees.rb b/db/migrate/20260326000000_remove_degree_type_and_role_other_from_committees.rb new file mode 100644 index 00000000..68bc8cba --- /dev/null +++ b/db/migrate/20260326000000_remove_degree_type_and_role_other_from_committees.rb @@ -0,0 +1,5 @@ +class RemoveDegreeTypeAndRoleOtherFromCommittees < ActiveRecord::Migration[7.0] + def change + remove_column :committees, :degree_type, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 1aef6b1e..acc3a256 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_12_05_172036) do +ActiveRecord::Schema[7.2].define(version: 2026_03_26_000000) do create_table "authors", charset: "utf8mb4", force: :cascade do |t| t.string "f_name" t.string "m_name" @@ -58,9 +58,12 @@ t.string "student_lname" t.string "role" t.string "thesis_title" - t.string "degree_type" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "type_of_work" + t.string "stage_of_completion" + t.integer "start_year" + t.integer "completion_year" t.index ["faculty_id"], name: "index_committees_on_faculty_id" end diff --git a/lib/etda/committee_records_client.rb b/lib/etda/committee_records_client.rb new file mode 100644 index 00000000..464199f8 --- /dev/null +++ b/lib/etda/committee_records_client.rb @@ -0,0 +1,53 @@ +require './lib/etda/committee_records_client' +require 'httparty' +module Etda + class CommitteeRecordsClient + class CommitteeRecordsClientError < StandardError; end + + def faculty_committees(access_id) + response = HTTParty.post( + "#{base_url}/api/v1/committee_records/faculty_committees", + headers: headers, + body: body(access_id) + ) + + handle_response(response) + + # Tells us the error with committe records client with the error itself + rescue StandardError => e + raise CommitteeRecordsClientError, e.message + end + + private + + def base_url + @base_url ||= ENV.fetch('ETDA_API_URL', 'http://localhost:3000') + end + + def api_token + @api_token ||= ENV.fetch('ETDA_API_TOKEN', 'abc123') + end + + def headers + { + 'X-API-KEY' => api_token, + 'Content-Type' => 'application/json' + } + end + + def body(access_id) + { + access_id: access_id + }.to_json + end + + def handle_response(response) + raise CommitteeRecordsClientError, response.parsed_response['error'] || 'Unknown error' unless response.success? + + { + success: true, + data: response.parsed_response + } + end + end +end diff --git a/spec/importers/committee_data/committee_xml_builder_spec.rb b/spec/importers/committee_data/committee_xml_builder_spec.rb index ae6b16a3..0e4cfa56 100644 --- a/spec/importers/committee_data/committee_xml_builder_spec.rb +++ b/spec/importers/committee_data/committee_xml_builder_spec.rb @@ -4,7 +4,7 @@ let(:xml_builder_obj) { described_class.new } describe '#xmls_enumerator' do - it 'returns an xml of DSL records' do + it 'returns an xml of DSL records with all Activity Insight fields' do faculty = FactoryBot.create(:faculty, access_id: 'test123') Committee.create!( @@ -13,7 +13,10 @@ student_lname: 'User', role: 'Mentor', thesis_title: 'Test Title', - degree_type: 'PhD' + type_of_work: 'Ph.D. Dissertation Committee', + stage_of_completion: 'Completed', + start_year: 2024, + completion_year: 2026 ) expect(xml_builder_obj.xmls_enumerator.first).to eq( @@ -22,10 +25,13 @@ Mentor + Ph.D. Dissertation Committee + Completed + 2024 + 2026 Test User - PhD Test Title @@ -35,6 +41,27 @@ ) end + it 'omits DTY_END when completion year is nil' do + faculty = FactoryBot.create(:faculty, access_id: 'test123') + + Committee.create!( + faculty: faculty, + student_fname: 'Test', + student_lname: 'User', + role: 'Mentor', + thesis_title: 'Test Title', + type_of_work: 'Ph.D. Dissertation Committee', + stage_of_completion: 'In Process', + start_year: 2024, + completion_year: nil + ) + + xml = xml_builder_obj.xmls_enumerator.first + expect(xml).to include('') + expect(xml).not_to include('') + expect(xml).to include('In Process') + end + it 'handles faculty with no committees' do FactoryBot.create(:faculty, access_id: 'test123') @@ -46,8 +73,8 @@ faculty1 = FactoryBot.create(:faculty, access_id: 'fac1') faculty2 = FactoryBot.create(:faculty, access_id: 'fac2') - Committee.create!(faculty: faculty1, student_fname: 'John', student_lname: 'Doe', role: 'Mentor', thesis_title: 'Title 1', degree_type: 'PhD') - Committee.create!(faculty: faculty2, student_fname: 'Jane', student_lname: 'Smith', role: 'Chair', thesis_title: 'Title 2', degree_type: 'MS') + Committee.create!(faculty: faculty1, student_fname: 'John', student_lname: 'Doe', role: 'Mentor', thesis_title: 'Title 1') + Committee.create!(faculty: faculty2, student_fname: 'Jane', student_lname: 'Smith', role: 'Chair', thesis_title: 'Title 2') result = xml_builder_obj.xmls_enumerator.to_a expect(result.length).to eq(1) @@ -56,8 +83,8 @@ it 'handles faculty member with multiple committees' do faculty = FactoryBot.create(:faculty, access_id: 'test123') - Committee.create!(faculty: faculty, student_fname: 'John', student_lname: 'Doe', role: 'Mentor', thesis_title: 'Title 1', degree_type: 'PhD') - Committee.create!(faculty: faculty, student_fname: 'Jane', student_lname: 'Smith', role: 'Member', thesis_title: 'Title 2', degree_type: 'MS') + Committee.create!(faculty: faculty, student_fname: 'John', student_lname: 'Doe', role: 'Mentor', thesis_title: 'Title 1') + Committee.create!(faculty: faculty, student_fname: 'Jane', student_lname: 'Smith', role: 'Member', thesis_title: 'Title 2') xml = xml_builder_obj.xmls_enumerator.first expect(xml.scan('').count).to eq(2) diff --git a/spec/importers/committee_data/etda_importer_spec.rb b/spec/importers/committee_data/etda_importer_spec.rb new file mode 100644 index 00000000..86819b00 --- /dev/null +++ b/spec/importers/committee_data/etda_importer_spec.rb @@ -0,0 +1,142 @@ +require 'importers/importers_helper' + +RSpec.describe CommitteeData::EtdaImporter do + subject(:importer) { described_class.new } + + let!(:faculty) { create(:faculty, access_id: 'abc123') } + let!(:faculty_one) { create(:faculty, access_id: 'mpk6156') } + let!(:faculty_two) { create(:faculty, access_id: 'aez1236') } + + let(:client) { instance_double(Etda::CommitteeRecordsClient) } + + before do + allow(Etda::CommitteeRecordsClient).to receive(:new).and_return(client) + end + + describe '#import_all' do + context 'when the import finds a committee' do + let(:api_response) do + { data: { 'committees' => [ + { 'student_fname' => 'Spider', 'student_lname' => 'Man', + 'role' => 'advisor', 'title' => 'My Thesis', 'degree_type' => 'Dissertation', + 'approval_started_at' => 1.month.ago.iso8601, + 'final_submission_approved_at' => '2026-01-15T10:30:00Z', + 'submission_status' => 'released for publication' } + ] } } + end + + before do + allow(client).to receive(:faculty_committees).and_return(api_response) + end + + it 'creates a committee with the correct attributes' do + expect { importer.import_all }.to change(Committee, :count).by(3) + expect(Committee.last.student_fname).to eq('Spider') + expect(Committee.last.student_lname).to eq('Man') + expect(Committee.last.role).to eq('Advisor') + expect(Committee.last.thesis_title).to eq('My Thesis') + expect(Committee.last.type_of_work).to eq('Dissertation Committee') + expect(Committee.last.stage_of_completion).to eq('Completed') + expect(Committee.last.start_year).to eq(1.month.ago.year) + expect(Committee.last.completion_year).to eq(2026) + end + end + end + + describe '#map_type_of_work' do + it 'maps Dissertation to Dissertation Committee' do + expect(importer.send(:map_type_of_work, 'Dissertation')).to eq('Dissertation Committee') + end + + it "maps Master Thesis to Master's Committee" do + expect(importer.send(:map_type_of_work, 'Master Thesis')).to eq("Master's Committee") + end + + it 'maps Thesis to Undergraduate Honors Thesis' do + expect(importer.send(:map_type_of_work, 'Thesis')).to eq('Undergraduate Honors Thesis') + end + + it "maps Final Paper to Master's Paper Committee" do + expect(importer.send(:map_type_of_work, 'Final Paper')).to eq("Master's Paper Committee") + end + + it 'raises DegreeTypeError for unknown degree types' do + expect { importer.send(:map_type_of_work, 'DMA') }.to raise_error(CommitteeData::EtdaImporter::DegreeTypeError) + end + + it 'returns nil for blank degree type' do + expect(importer.send(:map_type_of_work, nil)).to be_nil + expect(importer.send(:map_type_of_work, '')).to be_nil + end + end + + describe '#extract_year' do + subject(:extract) { importer.send(:extract_year, date_string) } + + context 'with a valid ISO8601 date string' do + let(:date_string) { '2026-01-15T10:30:00Z' } + + it { is_expected.to eq(2026) } + end + + context 'with nil' do + let(:date_string) { nil } + + it { is_expected.to be_nil } + end + + context 'with an empty string' do + let(:date_string) { '' } + + it { is_expected.to be_nil } + end + + context 'with an invalid date string' do + let(:date_string) { 'not-a-date' } + + it { is_expected.to be_nil } + end + end + + describe '#within_last_six_months?' do + it 'returns true for a date within the last 6 months' do + recent_date = 1.month.ago.iso8601 + expect(importer.send(:within_last_six_months?, recent_date)).to be true + end + + it 'returns false for a date older than 6 months' do + old_date = 1.year.ago.iso8601 + expect(importer.send(:within_last_six_months?, old_date)).to be false + end + + it 'returns false for a nil date' do + expect(importer.send(:within_last_six_months?, nil)).to be false + end + + it 'returns false for a blank date' do + expect(importer.send(:within_last_six_months?, '')).to be false + end + end + + describe '#determine_completion_stage' do + it 'returns Completed when final submission date is present' do + result = importer.send(:determine_completion_stage, '2026-01-15T10:30:00Z') + expect(result).to eq('Completed') + end + + it 'returns In Process when final submission date is nil' do + result = importer.send(:determine_completion_stage, nil) + expect(result).to eq('In Process') + end + end + + it 'rescues CommitteeRecordsClientError and logs it' do + allow(client).to receive(:faculty_committees) + .and_raise(Etda::CommitteeRecordsClient::CommitteeRecordsClientError, 'API down') + + expect(Rails.logger).to receive(:error).with(/mpk6156.*API down/) + expect(Rails.logger).to receive(:error).with(/abc123.*API down/) + expect(Rails.logger).to receive(:error).with(/aez1236.*API down/) + expect { importer.import_all }.not_to raise_error + end +end diff --git a/spec/jobs/activity_insight_committee_job_spec.rb b/spec/jobs/activity_insight_committee_job_spec.rb new file mode 100644 index 00000000..09217e04 --- /dev/null +++ b/spec/jobs/activity_insight_committee_job_spec.rb @@ -0,0 +1,58 @@ +require 'rails_helper' + +RSpec.describe ActivityInsightCommitteeJob, type: :job do + describe '#integrate' do + let(:target) { 'test_target' } + let(:xml_enumerator) { ['test'].to_enum } + + let(:builder_double) do + instance_double( + CommitteeData::CommitteeXmlBuilder, + xmls_enumerator: xml_enumerator + ) + end + + let(:integrator_double) do + instance_double( + ActivityInsight::IntegrateData, + integrate: [] + ) + end + + before do + allow(CommitteeData::CommitteeXmlBuilder) + .to receive(:new) + .and_return(builder_double) + + allow(ActivityInsight::IntegrateData) + .to receive(:new) + .with(xml_enumerator, target, :post) + .and_return(integrator_double) + end + + it 'calls the XML builder to get the xml enumerator' do + expect(CommitteeData::CommitteeXmlBuilder).to receive(:new) + described_class.new.integrate(target) + end + + it 'initializes ActivityInsight::IntegrateData with correct arguments' do + expect(ActivityInsight::IntegrateData) + .to receive(:new) + .with(xml_enumerator, target, :post) + + described_class.new.integrate(target) + end + + it 'calls integrate on the integrator and returns its errors' do + expect(integrator_double).to receive(:integrate) + result = described_class.new.integrate(target) + expect(result).to eq([]) + end + end + + describe '#name' do + it 'returns the job name' do + expect(described_class.new.send(:name)).to eq('Committee Integration') + end + end +end diff --git a/spec/lib/etda/committee_records_client_spec.rb b/spec/lib/etda/committee_records_client_spec.rb new file mode 100644 index 00000000..2905cc28 --- /dev/null +++ b/spec/lib/etda/committee_records_client_spec.rb @@ -0,0 +1,70 @@ +require 'rails_helper' +require_relative '../../../lib/etda/committee_records_client' +RSpec.describe Etda::CommitteeRecordsClient do + let(:client) { Etda::CommitteeRecordsClient.new } + + # This is to instansiate the client + describe '#faculty_committees' do + context 'when valid API KEY and user has committees' do + it 'returns committee member JSON' do + # Create a mock test but with a respone returned as a JSON + + fake_response = instance_double(HTTParty::Response, + success?: true, + parsed_response: [{ id: 1, name: 'Millennium Scholar' }]) + + allow(HTTParty).to receive(:post).and_return(fake_response) + + # Calling the method and checking if the test works + response = client.faculty_committees('abc123') + + expect(response).to be_present + expect(response[:success]).to be true + expect(response[:data]).to be_present + end + end + + context 'when user has no committees' do + it 'returns empty committee' do + # Create a mock test but with no response + fake_response = instance_double(HTTParty::Response, + success?: true, + parsed_response: []) + + allow(HTTParty).to receive(:post).and_return(fake_response) + + response = client.faculty_committees(' ') + + expect(response).to be_present + expect(response[:success]).to be true + expect(response[:data]).to eq([]) + end + end + + # Checking for when key is invalid + context 'when API key is invalid' do + it 'raises a committee records client error' do + fake_response = instance_double(HTTParty::Response, + success?: false, + parsed_response: { 'error' => 'Invalid API key' }) + + allow(HTTParty).to receive(:post).and_return(fake_response) + + expect do + client.faculty_committees('invalid_key') + end.to raise_error(Etda::CommitteeRecordsClient::CommitteeRecordsClientError) + end + end + + # Checking when a timeout occurs + context 'when a timeout occurs' do + it 'raises a committee records client error' do + allow(HTTParty).to receive(:post).and_raise(Timeout::Error) + + expect do + client.faculty_committees('timeout_test') + end.to raise_error(Etda::CommitteeRecordsClient::CommitteeRecordsClientError) + end + end + end +end diff --git a/spec/models/committee_spec.rb b/spec/models/committee_spec.rb index 05b29bad..8ef9eb2c 100644 --- a/spec/models/committee_spec.rb +++ b/spec/models/committee_spec.rb @@ -11,7 +11,10 @@ it { is_expected.to have_db_column(:student_lname).of_type(:string) } it { is_expected.to have_db_column(:role).of_type(:string) } it { is_expected.to have_db_column(:thesis_title).of_type(:string) } - it { is_expected.to have_db_column(:degree_type).of_type(:string) } + it { is_expected.to have_db_column(:type_of_work).of_type(:string) } + it { is_expected.to have_db_column(:stage_of_completion).of_type(:string) } + it { is_expected.to have_db_column(:start_year).of_type(:integer) } + it { is_expected.to have_db_column(:completion_year).of_type(:integer) } it { is_expected.to have_db_column(:faculty_id).of_type(:integer) } it 'has faculty_id as bigint in the database' do diff --git a/spec/services/committee_role_normalizer_spec.rb b/spec/services/committee_role_normalizer_spec.rb new file mode 100644 index 00000000..e02833c8 --- /dev/null +++ b/spec/services/committee_role_normalizer_spec.rb @@ -0,0 +1,47 @@ +require 'rails_helper' + +RSpec.describe CommitteeRoleNormalizer do + describe '.normalize' do + it 'maps co-chair roles correctly' do + expect( + described_class.normalize('Co-Chair & Dissertation Advisor') + ).to eq('Co-Chairperson') + end + + it 'maps chair roles correctly' do + expect( + described_class.normalize('Chair of Committee') + ).to eq('Chairperson') + end + + it 'prioritizes chair over advisor when both appear' do + expect( + described_class.normalize('Chair & Dissertation Advisor') + ).to eq('Chairperson') + end + + it 'maps advisor roles' do + expect( + described_class.normalize('Dissertation Advisr') + ).to eq('Advisor') + end + + it 'maps member and representative roles' do + expect( + described_class.normalize('Committee Member & Dean Grad Sch Rep') + ).to eq('Member') + end + + it 'returns Other for unknown roles' do + expect( + described_class.normalize('Some Weird ETDA Thing') + ).to eq('Other') + end + + it 'returns Other for blank values' do + expect( + described_class.normalize(nil) + ).to eq('Other') + end + end +end