Last active
May 4, 2024 06:35
-
-
Save non7247/2a42f9a456044b3422f375a51b5ce5bf to your computer and use it in GitHub Desktop.
関数型プログラムの設計(テストコードを追加)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
use reqwest::blocking::Client; | |
use reqwest::header::{HeaderMap, USER_AGENT}; | |
use serde::Deserialize; | |
use std::ops::Deref; | |
use std::error; | |
type Result<T> = std::result::Result<T, Box<dyn error::Error>>; | |
#[derive(Debug, Clone)] | |
struct LocationId(String); | |
impl LocationId { | |
fn from(s: &str) -> Self { | |
LocationId(String::from(s)) | |
} | |
} | |
impl Deref for LocationId { | |
type Target = String; | |
fn deref(&self) -> &Self::Target { | |
&self.0 | |
} | |
} | |
#[allow(dead_code)] | |
#[derive(Debug, Clone)] | |
struct Location { | |
id: LocationId, | |
name: String, | |
population: u64 | |
} | |
#[allow(dead_code)] | |
#[derive(Debug, Clone)] | |
struct Attraction { | |
name: String, | |
description: Option<String>, | |
location: Location | |
} | |
#[allow(dead_code)] | |
#[derive(Debug, Clone)] | |
enum PopCultureSubject { | |
Artist {name: String, followers: u64}, | |
Movie {name: String, box_office: u64} | |
} | |
#[derive(Debug, Clone)] | |
struct TravelGuide { | |
attraction: Attraction, | |
subjects: Vec<PopCultureSubject> | |
} | |
#[allow(dead_code)] | |
#[derive(Debug, Clone)] | |
enum AttractionOrdering { | |
ByName, | |
ByLocationPopulation | |
} | |
#[derive(Debug, Clone)] | |
struct SearchReport { | |
bad_guides: Vec<TravelGuide>, | |
problems: Vec<String> | |
} | |
impl SearchReport { | |
fn new() -> Self { | |
Self { bad_guides: vec![], problems: vec![] } | |
} | |
} | |
impl std::error::Error for SearchReport {} | |
impl std::fmt::Display for SearchReport { | |
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
write!(f, "{:?}, {:?}", self.bad_guides, self.problems) | |
} | |
} | |
#[derive(Debug, Deserialize)] | |
struct WDInstance { | |
value: String, | |
} | |
impl WDInstance { | |
fn get_local_name(&self) -> String { | |
match self.value.rfind("/") { | |
Some(i) => self.value[i + 1..].to_string(), | |
None => String::new() | |
} | |
} | |
} | |
#[derive(Debug, Deserialize)] | |
struct WDLabel { | |
value: String, | |
} | |
#[derive(Debug, Deserialize)] | |
struct WDNumber { | |
value: String, | |
} | |
impl WDNumber { | |
fn get_number(&self) -> u64 { | |
match self.value.parse::<u64>() { | |
Ok(n) => n, | |
Err(_) => 0, | |
} | |
} | |
} | |
#[allow(dead_code)] | |
#[derive(Debug, Deserialize)] | |
struct BindingAttraction { | |
attraction: WDInstance, | |
#[serde(rename = "attractionLabel")] | |
attraction_label: WDLabel, | |
description: Option<String>, | |
location: WDInstance, | |
#[serde(rename = "locationLabel")] | |
location_label: WDLabel, | |
population: WDNumber, | |
} | |
#[derive(Debug, Deserialize)] | |
struct ResultsAttraction { | |
bindings: Vec<BindingAttraction>, | |
} | |
#[derive(Debug, Deserialize)] | |
struct SolutionAttraction { | |
results: ResultsAttraction, | |
} | |
#[allow(dead_code)] | |
#[derive(Debug, Deserialize)] | |
struct BindingArtist { | |
artist: WDInstance, | |
#[serde(rename = "artistLabel")] | |
artist_label: WDLabel, | |
followers: WDNumber, | |
} | |
#[derive(Debug, Deserialize)] | |
struct ResultsArtist { | |
bindings: Vec<BindingArtist>, | |
} | |
#[derive(Debug, Deserialize)] | |
struct SolutionArtist { | |
results: ResultsArtist, | |
} | |
#[allow(dead_code)] | |
#[derive(Debug, Deserialize)] | |
struct BindingSubject { | |
subject: WDInstance, | |
#[serde(rename = "subjectLabel")] | |
subject_label: WDLabel, | |
#[serde(rename = "boxOffice")] | |
box_office: WDNumber, | |
} | |
#[derive(Debug, Deserialize)] | |
struct ResultSubject { | |
bindings: Vec<BindingSubject>, | |
} | |
#[derive(Debug, Deserialize)] | |
struct SolutionSubject { | |
results: ResultSubject, | |
} | |
fn parse_artist(artist: BindingArtist) -> PopCultureSubject { | |
PopCultureSubject::Artist { | |
name: artist.artist_label.value, | |
followers: artist.followers.get_number() | |
} | |
} | |
fn find_artists_from_location( | |
client: &Client, | |
url: &str, | |
headers: HeaderMap, | |
location_id: &LocationId, | |
limit: u32 | |
) -> Result<Vec<PopCultureSubject>> { | |
let response = client | |
.get(url) | |
.headers(headers) | |
.query(&[ | |
("format", "json"), | |
("query", format!(" | |
PREFIX wd: <http://www.wikidata.org/entity/> | |
PREFIX wdt: <http://www.wikidata.org/prop/direct/> | |
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> | |
PREFIX schema: <http://schema.org> | |
SELECT DISTINCT ?artist ?artistLabel ?followers WHERE {{ | |
?artist wdt:P136 ?genre; | |
wdt:P8687 ?followers; | |
rdfs:label ?artistLabel. | |
FILTER(LANG(?artistLabel) = \"en\"). | |
?artist wdt:P740 wd:{} | |
}} ORDER BY DESC(?followers) LIMIT {} | |
", **location_id, limit).trim()) | |
]) | |
.send()?; | |
let body = response.text()?; | |
let artists: SolutionArtist = serde_json::from_str(&body)?; | |
let mut result = vec![]; | |
for artist in artists.results.bindings.into_iter() { | |
result.push(parse_artist(artist)); | |
} | |
Ok(result) | |
} | |
fn parse_movie(binding: BindingSubject) -> PopCultureSubject { | |
PopCultureSubject::Movie { | |
name: binding.subject_label.value, | |
box_office: binding.box_office.get_number() | |
} | |
} | |
fn find_movies_about_location( | |
client: &Client, | |
url: &str, | |
headers: HeaderMap, | |
location_id: &LocationId, | |
limit: u32 | |
) -> Result<Vec<PopCultureSubject>> { | |
let response = client | |
.get(url) | |
.headers(headers) | |
.query(&[ | |
("format", "json"), | |
("query", format!(" | |
PREFIX wd: <http://www.wikidata.org/entity/> | |
PREFIX wdt: <http://www.wikidata.org/prop/direct/> | |
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> | |
PREFIX schema: <http://schema.org> | |
SELECT DISTINCT ?subject ?subjectLabel ?boxOffice WHERE {{ | |
?subject wdt:P31 wd:Q11424; | |
wdt:P2142 ?boxOffice; | |
rdfs:label ?subjectLabel. | |
?subject wdt:P840 wd:{} | |
FILTER(LANG(?subjectLabel) = \"en\"). | |
}} ORDER BY DESC(?boxOffice) LIMIT {} | |
", **location_id, limit).trim()) | |
]) | |
.send()?; | |
let body = response.text()?; | |
let subjects: SolutionSubject = serde_json::from_str(&body)?; | |
let mut result = vec![]; | |
for subject in subjects.results.bindings.into_iter() { | |
result.push(parse_movie(subject)); | |
} | |
Ok(result) | |
} | |
fn parse_attraction(attraction: BindingAttraction) -> Attraction { | |
let id = LocationId::from(&attraction.location.get_local_name()); | |
Attraction { | |
name: attraction.attraction_label.value, | |
description: attraction.description, | |
location: Location { | |
id, | |
name: attraction.location_label.value, | |
population: attraction.population.get_number() | |
} | |
} | |
} | |
fn find_attractions( | |
client: &Client, | |
url: &str, | |
headers: HeaderMap, | |
name: &str, | |
ordering: AttractionOrdering, | |
limit: u32 | |
) -> Result<Vec<Attraction>> { | |
let order_by = match ordering { | |
AttractionOrdering::ByName => "?attractionLabel", | |
AttractionOrdering::ByLocationPopulation => "DESC(?population)", | |
}; | |
let query = format!(" | |
PREFIX wd: <http://www.wikidata.org/entity/> | |
PREFIX wdt: <http://www.wikidata.org/prop/direct/> | |
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> | |
PREFIX schema: <http://schema.org> | |
SELECT DISTINCT ?attraction ?attractionLabel ?description | |
?location ?locationLabel ?population WHERE {{ | |
?attraction wdt:P31 wd:Q570116; | |
rdfs:label ?attractionLabel; | |
wdt:P131 ?location. | |
FILTER(LANG(?attractionLabel) = \"en\"). | |
OPTIONAL{{ | |
?attraction schema:description ?description. | |
FILTER(LANG(?description) = \"en\"). | |
}} | |
?location wdt:P1082 ?population; | |
rdfs:label ?locationLabel; | |
FILTER(LANG(?locationLabel) = \"en\"). | |
FILTER(CONTAINS(?attractionLabel, \"{}\")). | |
}} ORDER BY {} LIMIT {} | |
", name, order_by, limit); | |
let query = query.trim(); | |
let response = client | |
.get(url) | |
.headers(headers) | |
.query(&[("format", "json"), ("query", query)]) | |
.send()?; | |
let body = response.text()?; | |
let attractions: SolutionAttraction = serde_json::from_str(&body)?; | |
let mut result = vec![]; | |
for attraction in attractions.results.bindings.into_iter() { | |
result.push(parse_attraction(attraction)); | |
} | |
Ok(result) | |
} | |
fn guide_score(guide: &TravelGuide) -> u64 { | |
let description_score = if guide.attraction.description.is_some() { 30 } else { 0 }; | |
let quantity_score = std::cmp::min(40, guide.subjects.len() * 10) as u64; | |
let total_followers: u64 = guide.subjects.iter() | |
.map(|s| { match s { | |
PopCultureSubject::Artist { name: _, followers } => *followers, | |
_ => 0 | |
}}) | |
.sum(); | |
let total_box_office: u64 = guide.subjects.iter() | |
.map(|s| { match s { | |
PopCultureSubject::Movie { name: _, box_office } => *box_office, | |
_ => 0 | |
}}) | |
.sum(); | |
let followers_score = std::cmp::min(15, total_followers / 100_000); | |
let box_office_score = std::cmp::min(15, total_box_office / 10_000_000); | |
description_score + quantity_score + followers_score + box_office_score | |
} | |
fn travel_guide(attraction_name: &str) -> Result<TravelGuide> { | |
let client = Client::new(); | |
let url = "https://query.wikidata.org/sparql"; | |
let user_agent = "User-Agent: reqwest"; | |
let mut headers = HeaderMap::new(); | |
headers.insert(USER_AGENT, user_agent.parse()?); | |
let attractions = find_attractions( | |
&client, url, headers.clone(), attraction_name, AttractionOrdering::ByLocationPopulation, 3 | |
)?; | |
let mut guides = vec![]; | |
for attraction in attractions { | |
let mut subjects = vec![]; | |
match find_artists_from_location( | |
&client, url, headers.clone(), &attraction.location.id, 2 | |
) { | |
Ok(artists) => subjects.extend(artists), | |
Err(e) => println!("{:?}", e) | |
}; | |
match find_movies_about_location( | |
&client, url, headers.clone(), &attraction.location.id, 2 | |
) { | |
Ok(movies) => subjects.extend(movies), | |
Err(e) => println!("{:?}", e) | |
}; | |
guides.push(TravelGuide {attraction, subjects}); | |
} | |
guides.sort_by(|l, r| {guide_score(r).cmp(&guide_score(l))}); | |
guides.first() | |
.ok_or(SearchReport::new().into()) | |
.cloned() | |
} | |
fn main() { | |
match travel_guide("Yosemite") { | |
Ok(guide) => println!("{:?}", guide), | |
Err(e) => println!("Not found: {}", e) | |
} | |
match travel_guide("Yellowstone") { | |
Ok(guide) => println!("{:?}", guide), | |
Err(e) => println!("Not found: {}", e) | |
} | |
} | |
#[cfg(test)] | |
mod tests { | |
use super::*; | |
use proptest::prelude::*; | |
#[test] | |
fn test_guide_score() { | |
// score of a guide with a description, 0 artists, and 2 movies should be 65. | |
let guide = TravelGuide { | |
attraction: Attraction { | |
name: "Yellowstone National Park".to_string(), | |
description: Some("first national park in the world".to_string()), | |
location: Location { | |
id: LocationId::from("Q1214"), | |
name: "Wyoming".to_string(), | |
population: 586107 | |
} | |
}, | |
subjects: vec![ | |
PopCultureSubject::Movie{ | |
name: "The Hateful Eight".to_string(), | |
box_office: 155760117 | |
}, | |
PopCultureSubject::Movie{ | |
name: "Heaven's Gate".to_string(), | |
box_office: 3484331 | |
} | |
] | |
}; | |
// 30 (description) + 0 (0 artists) + 20 (2 movies) + 15 (159 million box office) | |
assert_eq!(guide_score(&guide), 65); | |
} | |
#[test] | |
fn test_guide_score2() { | |
// score of a guide with no description, 0 artists, and 0 movies should be 0. | |
let guide = TravelGuide { | |
attraction: Attraction { | |
name: "Yellowstone National Park".to_string(), | |
description: None, | |
location: Location { | |
id: LocationId::from("Q1214"), | |
name: "Wyoming".to_string(), | |
population: 586107 | |
} | |
}, | |
subjects: vec![] | |
}; | |
// 0 (description) + 0 (0 artists) + 0 (0 movies) | |
assert_eq!(guide_score(&guide), 0); | |
} | |
#[test] | |
fn test_guide_score3() { | |
// score of a guide with no description, 0 artists, and 2 movies with | |
// no box office earnings should be 20. | |
let guide = TravelGuide { | |
attraction: Attraction { | |
name: "Yellowstone National Park".to_string(), | |
description: None, | |
location: Location { | |
id: LocationId::from("Q1214"), | |
name: "Wyoming".to_string(), | |
population: 586107 | |
} | |
}, | |
subjects: vec![ | |
PopCultureSubject::Movie{ | |
name: "The Hateful Eight".to_string(), | |
box_office: 0 | |
}, | |
PopCultureSubject::Movie{ | |
name: "Heaven's Gate".to_string(), | |
box_office: 0 | |
} | |
] | |
}; | |
// 0 (description) + 0 (0 artists) + 20 (2 movies) + 0 (0 million box office) | |
assert_eq!(guide_score(&guide), 20); | |
} | |
proptest! { | |
#[test] | |
fn proptest_guide_score(name: String, description: String) { | |
// guide score should not depend on its attraction's name and description strings. | |
let guide = TravelGuide { | |
attraction: Attraction { | |
name, | |
description: Some(description), | |
location: Location { | |
id: LocationId::from("Q1214"), | |
name: "Wyoming".to_string(), | |
population: 586107 | |
} | |
}, | |
subjects: vec![ | |
PopCultureSubject::Movie{ | |
name: "The Hateful Eight".to_string(), | |
box_office: 155760117 | |
}, | |
PopCultureSubject::Movie{ | |
name: "Heaven's Gate".to_string(), | |
box_office: 3484331 | |
} | |
] | |
}; | |
// 30 (description) + 0 (0 artists) + 20 (2 movies) + 15 (159 million box office) | |
assert_eq!(guide_score(&guide), 65); | |
} | |
#[test] | |
fn proptest_guide_score2(amount_of_movies: u8) { | |
// guide score should always be between 30 and 70 if it has a description and | |
// some bad movies. | |
let guide = TravelGuide { | |
attraction: Attraction { | |
name: "Yellowstone National Park".to_string(), | |
description: Some("first national park in the world".to_string()), | |
location: Location { | |
id: LocationId::from("Q1214"), | |
name: "Wyoming".to_string(), | |
population: 586107 | |
} | |
}, | |
subjects: [ | |
PopCultureSubject::Movie{name: "Random Movie".to_string(), box_office: 0} | |
].into_iter() | |
.cycle() | |
.take(amount_of_movies as usize) | |
.collect::<Vec<_>>() | |
}; | |
let score = guide_score(&guide); | |
// greater equal 30 (description) and less equal 70 (0 artists, 0 million box office) | |
assert!(score >= 30 && score <= 70); | |
} | |
#[test] | |
fn proptest_guide_score3(followers: u64, box_office: u64) { | |
// guide score should always be between 20 and 50 if there is an artist and | |
// a movie but no description. | |
let guide = TravelGuide { | |
attraction: Attraction { | |
name: "Yellowstone National Park".to_string(), | |
description: None, | |
location: Location { | |
id: LocationId::from("Q1214"), | |
name: "Wyoming".to_string(), | |
population: 586107 | |
} | |
}, | |
subjects: vec![ | |
PopCultureSubject::Artist { name: "Chris LeDoux".to_string(), followers }, | |
PopCultureSubject::Movie { name: "The Hateful Eight".to_string(), box_office } | |
] | |
}; | |
let score = guide_score(&guide); | |
// greater equal 20 = 0 (description) + 10 (1 artist) + 10 (1 movie) | |
// and less equal 50 (many followers or many box office) | |
assert!(score >= 20 && score <= 50); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment