use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, OnceLock, PoisonError};
use opentele2_browser::{
Browser, Error as BrowserError, HeaderGenerator, HeaderOptions, HeaderOverrides, Rng,
get_user_agent,
};
use serde::Deserialize;
use sha1::{Digest, Sha1};
use crate::api::Versions;
use crate::{Error, Result};
const ANDROID_JSON: &str = include_str!("../data/devices/android.json");
const DESKTOP_JSON: &str = include_str!("../data/devices/desktop.json");
const IOS_JSON: &str = include_str!("../data/devices/ios.json");
const MAC_JSON: &str = include_str!("../data/devices/mac.json");
const WEB_POOL_SEED: &str = "opentele2/devices/web-pool/v1";
const MAX_DATASET_LAG: u32 = 24;
const HASH_MODULUS: u64 = 1_000_000_000_000;
const ANDROID_SDKS: std::ops::RangeInclusive<i64> = 29..=37;
const SDK_12L: i64 = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Platform {
Windows,
Linux,
MacOS {
swift: bool,
},
Android,
Ios,
Web {
client: WebClient,
},
}
impl Platform {
pub const NAMED: [Self; 7] = [
Self::Windows,
Self::Linux,
Self::MacOS { swift: false },
Self::Android,
Self::Ios,
Self::Web {
client: WebClient::A,
},
Self::Web {
client: WebClient::K,
},
];
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Windows => "windows",
Self::Linux => "linux",
Self::MacOS { .. } => "macos",
Self::Android => "android",
Self::Ios => "ios",
Self::Web {
client: WebClient::A,
} => "web_a",
Self::Web {
client: WebClient::K,
} => "web_k",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
Self::NAMED
.into_iter()
.find(|platform| platform.name() == name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WebClient {
A,
K,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Device {
pub model: String,
pub system_version: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WeightedDevice<'a> {
pub model: &'a str,
pub system_version: &'a str,
pub weight: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CatalogEntry {
pub model: String,
pub system_version: String,
pub weight: u64,
}
pub const WEB_BROWSER_WEIGHTS: [(&str, u64); 3] = [("chrome", 19), ("edge", 2), ("firefox", 1)];
#[derive(Debug, Clone)]
struct Slot {
model: usize,
version: usize,
weight: u64,
}
#[derive(Debug, Clone)]
pub struct Catalog {
models: Vec<String>,
versions: Vec<String>,
slots: Vec<Slot>,
ends: Vec<u64>,
}
impl Catalog {
fn new(models: Vec<String>, versions: Vec<String>) -> Self {
Self {
models,
versions,
slots: Vec::new(),
ends: Vec::new(),
}
}
fn push(
&mut self,
model: usize,
version: usize,
weight: u64,
) -> std::result::Result<(), String> {
if weight == 0 {
return Ok(());
}
let end = self
.total_weight()
.checked_add(weight)
.ok_or_else(|| "total weight overflows u64".to_owned())?;
self.slots.push(Slot {
model,
version,
weight,
});
self.ends.push(end);
Ok(())
}
fn finish(self, name: &str) -> std::result::Result<Self, String> {
if self.slots.is_empty() {
Err(format!("device catalog {name} is empty after filtering"))
} else {
Ok(self)
}
}
#[must_use]
pub fn len(&self) -> usize {
self.slots.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
#[must_use]
pub fn total_weight(&self) -> u64 {
self.ends.last().copied().unwrap_or(0)
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = WeightedDevice<'_>> + '_ {
self.slots.iter().map(|slot| self.view(slot))
}
#[must_use]
pub fn to_entries(&self) -> Vec<CatalogEntry> {
self.entries()
.map(|entry| CatalogEntry {
model: entry.model.to_owned(),
system_version: entry.system_version.to_owned(),
weight: entry.weight,
})
.collect()
}
#[must_use]
pub fn device_models(&self) -> &[String] {
&self.models
}
#[must_use]
pub fn system_versions(&self) -> &[String] {
&self.versions
}
fn view(&self, slot: &Slot) -> WeightedDevice<'_> {
WeightedDevice {
model: self.models.get(slot.model).map_or("", String::as_str),
system_version: self.versions.get(slot.version).map_or("", String::as_str),
weight: slot.weight,
}
}
fn at(&self, hash: u64) -> Option<WeightedDevice<'_>> {
let total = self.total_weight();
if total == 0 {
return None;
}
let index = hash % total;
let slot = self.ends.partition_point(|&end| end <= index);
self.slots.get(slot).map(|slot| self.view(slot))
}
}
#[must_use]
pub fn seed_hash(seed: &str) -> u64 {
digest_hash(seed.as_bytes())
}
fn digest_hash(bytes: &[u8]) -> u64 {
Sha1::digest(bytes).iter().fold(0u64, |rem, &byte| {
(rem * 256 + u64::from(byte)) % HASH_MODULUS
})
}
fn hash_for(seed: Option<&str>) -> Result<u64> {
if let Some(seed) = seed {
return Ok(seed_hash(seed));
}
let mut bytes = [0u8; 32];
crate::crypto::random_fill(&mut bytes)?;
Ok(digest_hash(&bytes))
}
pub fn desktop_system(seed: Option<&str>) -> Result<Platform> {
Ok(match hash_for(seed)? % 3 {
0 => Platform::Windows,
1 => Platform::MacOS { swift: false },
_ => Platform::Linux,
})
}
pub fn pick(platform: Platform, seed: Option<&str>) -> Result<Device> {
pick_from(catalog(platform)?, platform, seed)
}
pub(crate) fn pick_from(
catalog: &Catalog,
platform: Platform,
seed: Option<&str>,
) -> Result<Device> {
let hash = hash_for(seed)?;
let picked = catalog
.at(hash)
.ok_or_else(|| catalog_error("device catalog is empty".to_owned()))?;
let system_version = match platform {
Platform::MacOS { swift: false } => format!("macOS {}", picked.system_version),
_ => picked.system_version.to_owned(),
};
Ok(Device {
model: picked.model.to_owned(),
system_version,
})
}
pub fn catalog(platform: Platform) -> Result<&'static Catalog> {
static DESKTOP: OnceLock<std::result::Result<DesktopCatalogs, String>> = OnceLock::new();
static MAC: OnceLock<std::result::Result<Catalog, String>> = OnceLock::new();
static ANDROID: OnceLock<std::result::Result<Catalog, String>> = OnceLock::new();
static IOS: OnceLock<std::result::Result<Catalog, String>> = OnceLock::new();
static WEB_A: OnceLock<std::result::Result<Catalog, String>> = OnceLock::new();
static WEB_K: OnceLock<std::result::Result<Catalog, String>> = OnceLock::new();
let built = match platform {
Platform::Windows | Platform::Linux => {
let desktop = DESKTOP
.get_or_init(build_desktop)
.as_ref()
.map_err(|message| catalog_error(message.clone()))?;
if platform == Platform::Windows {
&desktop.windows
} else {
&desktop.linux
}
}
Platform::MacOS { .. } => MAC.get_or_init(build_mac),
Platform::Android => ANDROID.get_or_init(build_android),
Platform::Ios => IOS.get_or_init(build_ios),
Platform::Web {
client: WebClient::A,
} => WEB_A.get_or_init(|| offline_web_catalog(WebClient::A)),
Platform::Web {
client: WebClient::K,
} => WEB_K.get_or_init(|| offline_web_catalog(WebClient::K)),
};
built
.as_ref()
.map_err(|message| catalog_error(message.clone()))
}
pub fn catalog_entries(platform: Platform) -> Result<Vec<CatalogEntry>> {
Ok(catalog(platform)?.to_entries())
}
fn catalog_error(message: String) -> Error {
Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
message,
))
}
fn one() -> u64 {
1
}
#[derive(Deserialize)]
struct WeightedModel {
model: String,
#[serde(default = "one")]
weight: u64,
}
#[derive(Deserialize)]
struct WeightedVersion {
version: String,
#[serde(default = "one")]
weight: u64,
}
#[derive(Deserialize)]
struct DesktopFile {
models: Vec<WeightedModel>,
windows: Vec<WeightedVersion>,
linux: Vec<WeightedVersion>,
}
#[derive(Deserialize)]
struct MacModel {
model: String,
min_macos: String,
#[serde(default = "one")]
weight: u64,
}
#[derive(Deserialize)]
struct MacFile {
models: Vec<MacModel>,
versions: Vec<WeightedVersion>,
}
#[derive(Deserialize)]
struct IosModel {
model: String,
min_ios: String,
max_major: i64,
#[serde(default = "one")]
weight: u64,
}
#[derive(Deserialize)]
struct IosFile {
models: Vec<IosModel>,
versions: Vec<WeightedVersion>,
#[serde(default)]
only: HashMap<String, Vec<String>>,
#[serde(default)]
except: HashMap<String, Vec<String>>,
}
#[derive(Deserialize)]
struct AndroidDevice {
manufacturer: String,
model: String,
min_sdk: i64,
max_sdk: i64,
#[serde(default = "one")]
weight: u64,
#[serde(default)]
sdk_32: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct AndroidFile {
devices: Vec<AndroidDevice>,
}
struct DesktopCatalogs {
windows: std::result::Result<Catalog, String>,
linux: std::result::Result<Catalog, String>,
}
fn parse<'a, T: Deserialize<'a>>(name: &str, text: &'a str) -> std::result::Result<T, String> {
serde_json::from_str(text).map_err(|e| format!("device catalog {name} is malformed: {e}"))
}
fn version_key(version: &str) -> std::result::Result<Vec<i64>, String> {
version
.split('.')
.map(|part| {
part.parse::<i64>()
.map_err(|_| format!("device catalog version {version:?} is not numeric"))
})
.collect()
}
fn last_with<'a, T>(items: &'a [T], item: &'a T, key: impl Fn(&T) -> &str) -> &'a T {
items
.iter()
.rev()
.find(|other| key(other) == key(item))
.unwrap_or(item)
}
fn version_weights(versions: &[WeightedVersion]) -> Vec<u64> {
versions
.iter()
.map(|v| last_with(versions, v, |e| e.version.as_str()).weight)
.collect()
}
fn weight_product(model: u64, version: u64) -> std::result::Result<u64, String> {
model
.checked_mul(version)
.ok_or_else(|| "device weight overflows u64".to_owned())
}
fn clean_and_simplify(text: &str) -> String {
text.split(|c: char| c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c))
.filter(|word| !word.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
fn build_desktop() -> std::result::Result<DesktopCatalogs, String> {
let file: DesktopFile = parse("desktop.json", DESKTOP_JSON)?;
let cleaned: Vec<String> = file
.models
.iter()
.map(|m| clean_and_simplify(&m.model.replace('_', "")))
.collect();
let model_weights: Vec<u64> = cleaned
.iter()
.enumerate()
.map(|(i, name)| {
let last = cleaned.iter().rposition(|other| other == name).unwrap_or(i);
file.models.get(last).map_or(1, |m| m.weight)
})
.collect();
let build = |name: &str, versions: &[WeightedVersion]| {
let weights = version_weights(versions);
let mut catalog = Catalog::new(
cleaned.clone(),
versions.iter().map(|v| v.version.clone()).collect(),
);
for (model, &model_weight) in model_weights.iter().enumerate() {
for (version, &version_weight) in weights.iter().enumerate() {
catalog.push(
model,
version,
weight_product(model_weight, version_weight)?,
)?;
}
}
catalog.finish(name)
};
Ok(DesktopCatalogs {
windows: build("windows", &file.windows),
linux: build("linux", &file.linux),
})
}
fn build_mac() -> std::result::Result<Catalog, String> {
let file: MacFile = parse("mac.json", MAC_JSON)?;
let keys = file
.versions
.iter()
.map(|v| version_key(&v.version))
.collect::<std::result::Result<Vec<_>, _>>()?;
let weights = version_weights(&file.versions);
let mut catalog = Catalog::new(
file.models.iter().map(|m| m.model.clone()).collect(),
file.versions.iter().map(|v| v.version.clone()).collect(),
);
for (model, entry) in file.models.iter().enumerate() {
let entry = last_with(&file.models, entry, |e| e.model.as_str());
let minimum = version_key(&entry.min_macos)?;
for (version, (key, &version_weight)) in keys.iter().zip(&weights).enumerate() {
if *key >= minimum {
catalog.push(
model,
version,
weight_product(entry.weight, version_weight)?,
)?;
}
}
}
catalog.finish("mac")
}
fn build_ios() -> std::result::Result<Catalog, String> {
let file: IosFile = parse("ios.json", IOS_JSON)?;
let keys = file
.versions
.iter()
.map(|v| version_key(&v.version))
.collect::<std::result::Result<Vec<_>, _>>()?;
let weights = version_weights(&file.versions);
let mut catalog = Catalog::new(
file.models.iter().map(|m| m.model.clone()).collect(),
file.versions.iter().map(|v| v.version.clone()).collect(),
);
for (model, entry) in file.models.iter().enumerate() {
let entry = last_with(&file.models, entry, |e| e.model.as_str());
let minimum = version_key(&entry.min_ios)?;
for (version, (key, &version_weight)) in keys.iter().zip(&weights).enumerate() {
let name = file
.versions
.get(version)
.map_or("", |v| v.version.as_str());
let too_old = *key < minimum;
let too_new = key.first().is_some_and(|&major| major > entry.max_major);
let not_only = file
.only
.get(name)
.is_some_and(|models| !models.contains(&entry.model));
let excluded = file
.except
.get(name)
.is_some_and(|models| models.contains(&entry.model));
if !(too_old || too_new || not_only || excluded) {
catalog.push(
model,
version,
weight_product(entry.weight, version_weight)?,
)?;
}
}
}
catalog.finish("ios")
}
fn build_android() -> std::result::Result<Catalog, String> {
let file: AndroidFile = parse("android.json", ANDROID_JSON)?;
let sdk_versions: Vec<String> = ANDROID_SDKS.map(|sdk| format!("SDK {sdk}")).collect();
let mut catalog = Catalog::new(
file.devices
.iter()
.map(|d| format!("{}{}", d.manufacturer, d.model))
.collect(),
sdk_versions,
);
for (model, device) in file.devices.iter().enumerate() {
let allow_12l =
device.sdk_32 == Some(serde_json::Value::Bool(true)) || device.min_sdk == SDK_12L;
for sdk in device.min_sdk..=device.max_sdk {
if sdk == SDK_12L && !allow_12l {
continue;
}
if !ANDROID_SDKS.contains(&sdk) {
continue;
}
let version = usize::try_from(sdk - ANDROID_SDKS.start())
.map_err(|_| format!("Android SDK {sdk} has no version slot"))?;
catalog.push(model, version, device.weight)?;
}
}
catalog.finish("android")
}
struct WebBrowserConfig {
name: &'static str,
min: u32,
max: u32,
os: &'static [&'static str],
count: usize,
major_prefixes: &'static [&'static str],
}
fn web_browser_weight(name: &str) -> u64 {
WEB_BROWSER_WEIGHTS
.iter()
.find(|(browser, _)| *browser == name)
.map_or(1, |&(_, weight)| weight)
}
fn major_of(browser: &str, version: &str) -> Result<u32> {
version
.split('.')
.next()
.and_then(|major| major.trim().parse().ok())
.ok_or_else(|| {
catalog_error(format!(
"{browser} version {version:?} has no numeric major"
))
})
}
enum WebProbe {
Drawn(HeaderGenerator),
Empty,
Failed,
}
fn web_probe(config: &WebBrowserConfig, min: u32, max: Option<u32>, rng: &mut Rng) -> WebProbe {
let options = HeaderOptions {
browsers: vec![Browser {
min_version: Some(min),
max_version: max,
..Browser::new(config.name)
}],
os: config.os.iter().map(|&os| os.to_owned()).collect(),
devices: vec!["desktop".to_owned()],
strict: true,
..HeaderOptions::default()
};
let Ok(generator) = HeaderGenerator::new(options) else {
return WebProbe::Failed;
};
match generator.generate(rng, &HeaderOverrides::default()) {
Ok(_) => WebProbe::Drawn(generator),
Err(BrowserError::NoHeaders) => WebProbe::Empty,
Err(_) => WebProbe::Failed,
}
}
fn web_dataset_lag(config: &WebBrowserConfig) -> Option<u32> {
let mut rng = Rng::from_seed(&format!("{WEB_POOL_SEED}/dataset-lag/{}", config.name));
for offset in 0..=MAX_DATASET_LAG {
let floor = config.max.checked_sub(offset)?;
match web_probe(config, floor, None, &mut rng) {
WebProbe::Drawn(_) => return Some(offset),
WebProbe::Empty => {}
WebProbe::Failed => return None,
}
}
None
}
fn web_generator(config: &WebBrowserConfig, rng: &mut Rng) -> Option<(HeaderGenerator, u32)> {
if let Some(offset) = web_dataset_lag(config)
&& let (Some(min), Some(max)) = (
config.min.checked_sub(offset),
config.max.checked_sub(offset),
)
{
match web_probe(config, min, Some(max), rng) {
WebProbe::Drawn(generator) => return Some((generator, offset)),
WebProbe::Empty => {}
WebProbe::Failed => return None,
}
}
for offset in 0..=MAX_DATASET_LAG {
let (Some(min), Some(max)) = (
config.min.checked_sub(offset),
config.max.checked_sub(offset),
) else {
break;
};
match web_probe(config, min, Some(max), rng) {
WebProbe::Drawn(generator) => return Some((generator, offset)),
WebProbe::Empty => {}
WebProbe::Failed => return None,
}
}
None
}
fn shift_majors(user_agent: &str, prefixes: &[&str], offset: u32) -> String {
let mut shifted = user_agent.to_owned();
if offset == 0 {
return shifted;
}
for prefix in prefixes {
let mut out = String::with_capacity(shifted.len());
let mut rest = shifted.as_str();
while let Some(at) = rest.find(prefix) {
let (head, tail) = rest.split_at(at + prefix.len());
out.push_str(head);
let digits = tail.bytes().take_while(u8::is_ascii_digit).count();
let (number, after) = tail.split_at(digits);
match number.parse::<u64>() {
Ok(major) => out.push_str(&major.saturating_add(u64::from(offset)).to_string()),
Err(_) => out.push_str(number),
}
rest = after;
}
out.push_str(rest);
shifted = out;
}
shifted
}
fn web_a_system(user_agent: &str) -> &'static str {
let ua = user_agent.to_lowercase();
if ua.contains("windows") {
"Windows"
} else if ua.contains("macintosh") || ua.contains("mac os") {
"macOS"
} else if ua.contains("cros") {
"ChromeOS"
} else if ua.contains("linux") {
"Linux"
} else {
"Windows"
}
}
fn web_k_system(user_agent: &str) -> &'static str {
let ua = user_agent.to_lowercase();
if ua.contains("win64") || ua.contains("windows") {
"Win32"
} else if ua.contains("macintosh") || ua.contains("mac os") {
"MacIntel"
} else if ua.contains("linux") {
if ua.contains("x86_64") || ua.contains("x64") {
"Linux x86_64"
} else if ua.contains("aarch64") || ua.contains("arm") {
"Linux aarch64"
} else {
"Linux x86_64"
}
} else {
"Win32"
}
}
fn web_pool(chrome: u32, firefox: u32) -> std::result::Result<Vec<(String, u64)>, String> {
let mut rng = Rng::from_seed(&format!(
"{WEB_POOL_SEED}/chrome/{chrome}/firefox/{firefox}"
));
let spread = 2 + u32::try_from(rng.next_u64() % 3).unwrap_or(0);
let chrome_min = chrome.saturating_sub(spread);
let all_os: &[&str] = &["windows", "macos", "linux"];
let configs = [
WebBrowserConfig {
name: "chrome",
min: chrome_min,
max: chrome,
os: all_os,
count: 60,
major_prefixes: &["Chrome/"],
},
WebBrowserConfig {
name: "edge",
min: chrome_min.saturating_sub(2),
max: chrome,
os: &["windows", "macos"],
count: 30,
major_prefixes: &["Chrome/", "Edg/"],
},
WebBrowserConfig {
name: "firefox",
min: firefox.saturating_sub(3),
max: firefox,
os: all_os,
count: 20,
major_prefixes: &["rv:", "Firefox/"],
},
];
let overrides = HeaderOverrides::default();
let mut seen = HashSet::new();
let mut pool = Vec::new();
for config in &configs {
let weight = web_browser_weight(config.name);
let Some((generator, offset)) = web_generator(config, &mut rng) else {
continue;
};
for _ in 0..config.count {
let headers = match generator.generate(&mut rng, &overrides) {
Ok(headers) => headers,
Err(BrowserError::NoHeaders) => continue,
Err(e) => return Err(format!("web pool {}: {e}", config.name)),
};
let Some(user_agent) = get_user_agent(&headers) else {
continue;
};
let user_agent = shift_majors(user_agent, config.major_prefixes, offset);
if seen.insert(user_agent.clone()) {
pool.push((user_agent, weight));
}
}
}
if pool.is_empty() {
pool.push((
format!(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/{chrome}.0.0.0 Safari/537.36"
),
1,
));
}
Ok(pool)
}
fn web_pool_catalog(
pool: &[(String, u64)],
system: fn(&str) -> &'static str,
name: &str,
) -> std::result::Result<Catalog, String> {
let mut versions: Vec<String> = Vec::new();
let mut slots = Vec::with_capacity(pool.len());
for (model, (user_agent, weight)) in pool.iter().enumerate() {
let platform = system(user_agent);
let version = versions
.iter()
.position(|known| known == platform)
.unwrap_or_else(|| {
versions.push(platform.to_owned());
versions.len() - 1
});
slots.push((model, version, *weight));
}
let mut catalog = Catalog::new(pool.iter().map(|(ua, _)| ua.clone()).collect(), versions);
for (model, version, weight) in slots {
catalog.push(model, version, weight)?;
}
catalog.finish(name)
}
pub fn web_catalog_for(client: WebClient, versions: &Versions) -> Result<Catalog> {
type WebKey = (WebClient, u32, u32);
static CACHE: OnceLock<Mutex<HashMap<WebKey, Catalog>>> = OnceLock::new();
let chrome = major_of("Chrome", &versions.chrome_version)?;
let firefox = major_of("Firefox", &versions.firefox_version)?;
let mut cache = CACHE
.get_or_init(Mutex::default)
.lock()
.unwrap_or_else(PoisonError::into_inner);
if let Some(cached) = cache.get(&(client, chrome, firefox)) {
return Ok(cached.clone());
}
let pool = web_pool(chrome, firefox).map_err(catalog_error)?;
let a = web_pool_catalog(&pool, web_a_system, "web A").map_err(catalog_error)?;
let k = web_pool_catalog(&pool, web_k_system, "web K").map_err(catalog_error)?;
let wanted = match client {
WebClient::A => a.clone(),
WebClient::K => k.clone(),
};
cache.insert((WebClient::A, chrome, firefox), a);
cache.insert((WebClient::K, chrome, firefox), k);
Ok(wanted)
}
fn offline_web_catalog(client: WebClient) -> std::result::Result<Catalog, String> {
web_catalog_for(client, &Versions::offline()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::{WebBrowserConfig, web_dataset_lag};
const ALL_OS: &[&str] = &["windows", "macos", "linux"];
fn config(
name: &'static str,
min: u32,
max: u32,
os: &'static [&'static str],
) -> WebBrowserConfig {
WebBrowserConfig {
name,
min,
max,
os,
count: 1,
major_prefixes: &[],
}
}
#[test]
fn dataset_lag_is_the_distance_to_the_newest_major() {
assert_eq!(
web_dataset_lag(&config("chrome", 152, 155, ALL_OS)),
Some(6)
);
assert_eq!(
web_dataset_lag(&config("chrome", 146, 149, ALL_OS)),
Some(0)
);
assert_eq!(
web_dataset_lag(&config("chrome", 140, 143, ALL_OS)),
Some(0)
);
let edge_os: &[&str] = &["windows", "macos"];
assert_eq!(web_dataset_lag(&config("edge", 150, 155, edge_os)), Some(7));
assert_eq!(web_dataset_lag(&config("edge", 143, 148, edge_os)), Some(0));
assert_eq!(
web_dataset_lag(&config("firefox", 147, 150, ALL_OS)),
Some(0)
);
assert_eq!(
web_dataset_lag(&config("firefox", 152, 155, ALL_OS)),
Some(4)
);
}
#[test]
fn dataset_lag_beyond_the_limit_is_none() {
assert_eq!(web_dataset_lag(&config("chrome", 200, 200, ALL_OS)), None);
}
}