use self::model::{ClassGrade, CourseType, GradeSummary, GradesByClassification, SemesterGrade};
use crate::application::utils::input_field::InputFieldExt as _;
use crate::application::utils::oz::{
extract_oz_url_from_script_calls, fetch_data_module, parse_oz_url_params,
};
use crate::application::utils::popup::close_popups;
use crate::application::utils::sap_table::try_table_into_with_scroll;
use crate::application::utils::semester::get_selected_semester;
use crate::client::{USaintApplication, USaintClient};
use crate::{ApplicationError, RusaintError, model::SemesterType};
use std::collections::HashMap;
use wdpe::body::Body;
use wdpe::command::WebDynproCommandExecutor;
use wdpe::command::element::action::ButtonPressEventCommand;
use wdpe::element::action::Button;
use wdpe::element::complex::sap_table::cell::SapTableCellWrapper;
use wdpe::element::parser::ElementParser;
use wdpe::scraper::Selector;
use wdpe::state::EventProcessResult;
use wdpe::{
command::element::{
complex::SapTableBodyCommand,
selection::{ComboBoxLSDataCommand, ComboBoxSelectEventCommand},
},
define_elements,
element::{
Element, ElementDefWrapper, ElementWrapper,
complex::sap_table::{SapTable, cell::SapTableCell},
definition::ElementDefinition,
selection::ComboBox,
text::InputField,
},
error::{BodyError, ElementError, WebDynproError},
event::Event,
};
#[derive(Debug)]
pub struct CourseGradesApplication {
client: USaintClient,
}
impl USaintApplication for CourseGradesApplication {
const APP_NAME: &'static str = "ZCMB3W0017";
fn from_client(client: USaintClient) -> Result<Self, RusaintError> {
if client.name() != Self::APP_NAME {
Err(RusaintError::InvalidClientError)
} else {
Ok(Self { client })
}
}
}
#[allow(unused)]
impl<'a> CourseGradesApplication {
define_elements! {
BTN_SEARCH: Button<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.BTN_SEARCH";
BTN_PRINT_CP: Button<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.BTN_PRINT_CP";
}
define_elements!(
GRADES_SUMMARY_TABLE: SapTable<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.TABLE";
PROGRESS_TYPE: ComboBox<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.PROGC_VAR";
ATTM_CRD1: InputField<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.ATTM_CRD1";
EARN_CRD1: InputField<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.EARN_CRD1";
GT_GPA1: InputField<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.GT_GPA1";
CGPA1: InputField<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.CGPA1";
AVG1: InputField<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.AVG1";
PF_EARN_CRD: InputField<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.PF_EARN_CRD";
ATTM_CRD2: InputField<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.ATTM_CRD2";
EARN_CRD2: InputField<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.EARN_CRD2";
GT_GPA2: InputField<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.GT_GPA2";
CGPA2: InputField<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.CGPA2";
AVG2: InputField<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.AVG2";
PF_EARN_CRD1: InputField<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.T_PF_ERN_CRD1";
);
define_elements!(
PERIOD_YEAR: ComboBox<'a> = "ZCMW_PERIOD_RE.ID_0DC742680F42DA9747594D1AE51A0C69:VIW_MAIN.PERYR";
PERIOD_SEMESTER: ComboBox<'a> = "ZCMW_PERIOD_RE.ID_0DC742680F42DA9747594D1AE51A0C69:VIW_MAIN.PERID";
GRADE_BY_CLASSES_TABLE: SapTable<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.TABLE_1";
);
async fn close_popups(&mut self) -> Result<(), WebDynproError> {
close_popups(&mut self.client).await
}
fn semester_to_key(semester: SemesterType) -> &'static str {
match semester {
SemesterType::One => "090",
SemesterType::Summer => "091",
SemesterType::Two => "092",
SemesterType::Winter => "093",
}
}
fn course_type_to_key(course_type: CourseType) -> &'static str {
match course_type {
CourseType::Phd => "DR",
CourseType::Master => "MA",
CourseType::PhdIntergrated => "MP",
CourseType::Research => "RE",
CourseType::Bachelor => "UG",
}
}
async fn select_course(
&mut self,
parser: &ElementParser,
course: CourseType,
) -> Result<(), WebDynproError> {
let course = Self::course_type_to_key(course);
let combobox_lsdata = parser.read(ComboBoxLSDataCommand::new(Self::PROGRESS_TYPE))?;
if combobox_lsdata.key().map(String::as_str) != Some(course) {
let select_event = parser.read(ComboBoxSelectEventCommand::new(
Self::PROGRESS_TYPE,
course,
false,
))?;
self.client.process_event(false, select_event).await?;
}
Ok(())
}
async fn select_semester(
&mut self,
parser: &ElementParser,
year: &str,
semester: SemesterType,
) -> Result<(), WebDynproError> {
let semester = Self::semester_to_key(semester);
let year_combobox_lsdata = parser.read(ComboBoxLSDataCommand::new(Self::PERIOD_YEAR))?;
let semester_combobox_lsdata =
parser.read(ComboBoxLSDataCommand::new(Self::PERIOD_SEMESTER))?;
if year_combobox_lsdata.key().map(String::as_str) != Some(year) {
let year_select_event = parser.read(ComboBoxSelectEventCommand::new(
Self::PERIOD_YEAR,
year,
false,
))?;
self.client.process_event(false, year_select_event).await?;
}
if semester_combobox_lsdata.key().map(String::as_str) != Some(semester) {
let semester_select_event = parser.read(ComboBoxSelectEventCommand::new(
Self::PERIOD_SEMESTER,
semester,
false,
))?;
self.client
.process_event(false, semester_select_event)
.await?;
}
Ok(())
}
pub fn get_selected_semester(&self) -> Result<(u32, SemesterType), RusaintError> {
Ok(get_selected_semester(
&self.client,
&Self::PERIOD_YEAR,
&Self::PERIOD_SEMESTER,
)?)
}
pub async fn lookup(&mut self) -> Result<(), RusaintError> {
let parser = ElementParser::new(self.client.body());
let button_press_event = parser.read(ButtonPressEventCommand::new(Self::BTN_SEARCH))?;
self.client.process_event(false, button_press_event).await?;
Ok(())
}
pub async fn reload(&mut self) -> Result<(), RusaintError> {
self.client.reload().await?;
Ok(())
}
pub async fn recorded_summary(
&mut self,
course_type: CourseType,
) -> Result<GradeSummary, RusaintError> {
self.close_popups().await?;
let parser = ElementParser::new(self.client.body());
self.select_course(&parser, course_type).await?;
self.read_recorded_summary()
}
fn read_recorded_summary(&self) -> Result<GradeSummary, RusaintError> {
let parser = ElementParser::new(self.client.body());
let attempted_credits = parser
.element_from_def(&Self::ATTM_CRD1)?
.value_into_f32()?;
let earned_credits = parser
.element_from_def(&Self::EARN_CRD1)?
.value_into_f32()?;
let gpa = parser.element_from_def(&Self::GT_GPA1)?.value_into_f32()?;
let cgpa = parser.element_from_def(&Self::CGPA1)?.value_into_f32()?;
let avg = parser.element_from_def(&Self::AVG1)?.value_into_f32()?;
let pf_earned_credits = parser
.element_from_def(&Self::PF_EARN_CRD)?
.value_into_f32()?;
Ok(GradeSummary::new(
attempted_credits,
earned_credits,
gpa,
cgpa,
avg,
pf_earned_credits,
))
}
pub async fn certificated_summary(
&mut self,
course_type: CourseType,
) -> Result<GradeSummary, RusaintError> {
self.close_popups().await?;
let parser = ElementParser::new(self.client.body());
self.select_course(&parser, course_type).await?;
self.read_certificated_summary()
}
fn read_certificated_summary(&self) -> Result<GradeSummary, RusaintError> {
let parser = ElementParser::new(self.client.body());
let attempted_credits = parser
.element_from_def(&Self::ATTM_CRD2)?
.value_into_f32()?;
let earned_credits = parser
.element_from_def(&Self::EARN_CRD2)?
.value_into_f32()?;
let gpa = parser.element_from_def(&Self::GT_GPA2)?.value_into_f32()?;
let cgpa = parser.element_from_def(&Self::CGPA2)?.value_into_f32()?;
let avg = parser.element_from_def(&Self::AVG2)?.value_into_f32()?;
let pf_earned_credits = parser
.element_from_def(&Self::PF_EARN_CRD1)?
.value_into_f32()?;
Ok(GradeSummary::new(
attempted_credits,
earned_credits,
gpa,
cgpa,
avg,
pf_earned_credits,
))
}
pub async fn grades_by_classification(
&mut self,
course_type: CourseType,
) -> Result<GradesByClassification, RusaintError> {
self.close_popups().await?;
let parser = ElementParser::new(self.client.body());
let button_press_event = parser.read(ButtonPressEventCommand::new(Self::BTN_PRINT_CP))?;
let result = self.client.process_event(true, button_press_event).await?;
let script_calls = match result {
EventProcessResult::Sent(body_update_result) => {
body_update_result.script_calls.unwrap_or_default()
}
EventProcessResult::Enqueued => {
return Err(ApplicationError::OzDataFetchError(
"BTN_PRINT_CP event was enqueued but not sent".to_string(),
)
.into());
}
};
let oz_url = extract_oz_url_from_script_calls(&script_calls)?;
let mut oz_params = parse_oz_url_params(&oz_url)?;
if let Some(uname) = oz_params
.params
.iter()
.find(|(k, _)| k == "UNAME")
.map(|(_, v)| v.clone())
{
if !oz_params.params.iter().any(|(k, _)| k == "arg4") {
oz_params.params.push(("arg4".to_string(), uname.clone()));
}
if !oz_params.params.iter().any(|(k, _)| k == "ADMIN") {
oz_params.params.push(("ADMIN".to_string(), uname));
}
}
let response = fetch_data_module(&oz_params).await?;
let result = GradesByClassification::from_datasets(&response.datasets)?;
Ok(result)
}
pub async fn semesters(
&mut self,
course_type: CourseType,
) -> Result<Vec<SemesterGrade>, RusaintError> {
self.close_popups().await?;
let parser = ElementParser::new(self.client.body());
self.select_course(&parser, course_type).await?;
self.read_semesters().await
}
async fn read_semesters(&mut self) -> Result<Vec<SemesterGrade>, RusaintError> {
let parser = ElementParser::new(self.client.body());
let ret = try_table_into_with_scroll::<SemesterGrade>(
&mut self.client,
parser,
Self::GRADES_SUMMARY_TABLE,
)
.await?;
Ok(ret)
}
async fn class_detail_in_popup(
&mut self,
press_event: Event,
) -> Result<HashMap<String, f32>, RusaintError> {
self.client.process_event(false, press_event).await?;
let parse_table_in_popup = |body: &Body| -> Result<HashMap<String, f32>, WebDynproError> {
let table_inside_popup_selector = Selector::parse(r#"[ct="PW"] [ct="ST"]"#).unwrap();
let parser = ElementParser::new(body);
let mut table_inside_popup = parser.document().select(&table_inside_popup_selector);
let table_ref = table_inside_popup
.next()
.ok_or(BodyError::NoSuchElement("Table in popup".to_string()))?;
let table_elem: SapTable<'_> = ElementWrapper::from_ref(table_ref)?.try_into()?;
let table_body = table_elem.table()?;
let zip = table_body
.iter()
.next()
.ok_or(ElementError::InvalidContent {
element: table_elem.id().to_string(),
content: "header and first row".to_string(),
})?
.try_row_into::<Vec<(String, String)>>(table_body.header(), &parser)?
.into_iter();
zip.skip(4)
.map(|(key, val)| {
let str = val.trim();
if str.is_empty() {
return Ok((key, -1.0));
}
let float = str.parse::<f32>().or(Err(ElementError::InvalidContent {
element: format!("TABLE: {}, key: {}", table_elem.id(), key),
content: "(not an correct f32)".to_string(),
}))?;
Ok((key, float))
})
.collect::<Result<HashMap<String, f32>, WebDynproError>>()
};
let table = parse_table_in_popup(self.client.body())?;
self.close_popups().await?;
Ok(HashMap::from_iter(table))
}
pub async fn classes(
&mut self,
course_type: CourseType,
year: u32,
semester: SemesterType,
include_details: bool,
) -> Result<Vec<ClassGrade>, RusaintError> {
{
self.close_popups().await?;
let parser = ElementParser::new(self.client.body());
self.select_course(&parser, course_type).await?;
self.select_semester(&parser, &year.to_string(), semester)
.await?;
}
let parser = ElementParser::new(self.client.body());
let class_grades: Vec<(Option<Event>, HashMap<String, String>)> = {
let grade_table_body =
parser.read(SapTableBodyCommand::new(Self::GRADE_BY_CLASSES_TABLE))?;
let iter = grade_table_body.iter();
iter.map(|row| {
let btn_event = SapTableCellWrapper::from_def(&row[4], &parser)
.ok()
.and_then(|cell| {
if let Some(ElementDefWrapper::Button(btn)) = cell.content() {
parser.element_from_def(&btn).ok()?.press().ok()
} else {
None
}
});
(btn_event, row)
})
.filter_map(|(btn_event, row)| {
row.try_row_into::<HashMap<String, String>>(grade_table_body.header(), &parser)
.ok()
.map(|row| (btn_event, row))
})
.collect()
};
let mut ret: Vec<ClassGrade> = vec![];
for (btn_event, values) in class_grades {
let detail: Option<HashMap<String, f32>> = if let Some(btn_event) = btn_event {
if include_details {
Some(self.class_detail_in_popup(btn_event).await?)
} else {
None
}
} else {
None
};
let parsed: Option<ClassGrade> = (|| {
Some(ClassGrade::new(
year,
semester,
values["과목코드"].trim().to_owned(),
values["과목명"].trim().to_owned(),
values["과목학점"].parse().ok()?,
values["성적"].parse().ok()?,
values["등급"].trim().to_owned(),
values["교수명"].trim().to_owned(),
detail,
))
})();
if let Some(parsed) = parsed {
ret.push(parsed);
}
}
Ok(ret)
}
pub async fn class_detail(
&mut self,
course_type: CourseType,
year: u32,
semester: SemesterType,
code: &str,
) -> Result<HashMap<String, f32>, RusaintError> {
let year = year.to_string();
{
self.close_popups().await?;
let parser = ElementParser::new(self.client.body());
self.select_course(&parser, course_type).await?;
self.select_semester(&parser, &year, semester).await?;
}
let parser = ElementParser::new(self.client.body());
let table = parser.read(SapTableBodyCommand::new(Self::GRADE_BY_CLASSES_TABLE))?;
let Some(btn) = ({
table
.iter()
.find(
|row| match SapTableCellWrapper::from_def(&row[8], &parser) {
Ok(cell) => {
if let Some(ElementDefWrapper::TextView(code_elem)) = cell.content() {
parser
.element_from_def(&code_elem)
.is_ok_and(|elem| elem.text() == code)
} else {
false
}
}
Err(_) => false,
},
)
.and_then(
|row| match SapTableCellWrapper::from_def(&row[4], &parser) {
Ok(cell) => {
if let Some(ElementDefWrapper::Button(btn)) = cell.content() {
parser.element_from_def(&btn).ok()?.press().ok()
} else {
None
}
}
Err(_) => None,
},
)
}) else {
return Err(WebDynproError::from(ElementError::NoSuchData {
element: Self::GRADE_BY_CLASSES_TABLE.id().to_string(),
field: format!("details of class {code}"),
}))?;
};
self.class_detail_in_popup(btn).await
}
fn body(&self) -> &Body {
self.client.body()
}
}
pub mod model;
#[cfg(test)]
mod test {
use crate::{
application::course_grades::CourseGradesApplication, client::USaintClientBuilder,
global_test_utils::get_session,
};
use wdpe::element::{Element, layout::PopupWindow, parser::ElementParser};
#[tokio::test]
async fn close_popups() {
let session = get_session().await.unwrap();
let mut app = USaintClientBuilder::new()
.session(session)
.build_into::<CourseGradesApplication>()
.await
.unwrap();
app.close_popups().await.unwrap();
let popup_selector = wdpe::scraper::Selector::parse(
format!(r#"[ct="{}"]"#, PopupWindow::CONTROL_ID).as_str(),
)
.unwrap();
let parser = ElementParser::new(app.client.body());
let result = parser.document().select(&popup_selector).next().is_none();
assert!(result);
}
}