Files
rdbms-playground/src/seed/generators.rs
T
claude@clouddev1 6b4c4dcea4
ci / gate (push) Failing after 35s
ci / manifests (push) Successful in 3s
website / deploy (push) Successful in 35s
feat(types)!: drop the blob column type (ADR-0005 Amendment 2)
blob was a dead-end: declarable but never fillable (no literal in either
mode, seed-unsupported), so a blob column could only ever hold NULL. Remove
Type::Blob + Value/CellValue::Blob + the base64 CSV path + the grammar slot
+ completion/render/type-change/seed handling + the binder refusal (whose
message also carried a user-facing "DSL" copy-rule bug). The vocabulary is
now nine types; base64 stays (clipboard OSC-52).

Backward compat is the ADR-0015 migration framework's first real use: a
v1->v2 format bump (CURRENT_SCHEMA_VERSION across serializer/parser/skeleton)
with a migrator that rewrites `type: blob` -> `type: text`, and a forced .db
rebuild from the migrated text when a blob column was actually converted
(the stale .db keeps a STRICT BLOB engine column). Conversion to text is
non-destructive and CSV-free. Covered by a full-stack integration test and a
Tier-4 PTY test that opens a real legacy v1-blob project.

Also sweeps the nine-type vocabulary through CLAUDE.md, requirements.md, the
website (type reference, seed doc, highlight grammar), and the ADR-0030/0033/
0035 cross-references; CHANGELOG Removed entry; handoff-79.

BREAKING CHANGE: the `blob` column type is removed. Existing projects that
declared a blob column are migrated on first open (the column becomes text;
the original project.yaml is kept as a .v1.bak).
2026-06-22 21:25:38 +00:00

716 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Value production: turn a [`Generator`] + a seeded RNG into a
//! [`Value`] (ADR-0048 D8/D9). Realistic generators come from the
//! `fake` crate (English locale); `product` is hand-rolled (D9, no
//! commerce module exists); dates are generated against a **fixed
//! reference epoch** so a `--seed` run is fully reproducible without
//! depending on the wall clock (D8 bounded windows).
//!
//! The stateful markers ([`Generator::IdentitySequential`],
//! [`Generator::ForeignKeySample`]) are resolved by the executor with
//! database context; if one reaches here un-intercepted it falls back
//! to type-based generation rather than panicking.
use chrono::{Datelike, NaiveDate};
use fake::Fake;
use rand::RngExt;
use crate::dsl::types::Type;
use crate::dsl::value::Value;
use crate::seed::{Generator, SeedRng};
/// Fixed anchor for bounded date/datetime windows. Using a constant
/// (rather than `now()`) keeps `--seed` output reproducible across days
/// and makes tests deterministic. It advances with releases.
const REF_YEAR: i32 = 2025;
const REF_MONTH: u32 = 6;
const REF_DAY: u32 = 1;
/// `~3 years` window for "recent" dates, in days.
const RECENT_WINDOW_DAYS: i64 = 3 * 365;
/// Adult birth window (≈18–80 years ago), in days.
const ADULT_MIN_DAYS: i64 = 18 * 365;
const ADULT_MAX_DAYS: i64 = 80 * 365;
/// Year windows for the `int`-typed year heuristics (issue #33),
/// expressed relative to [`REF_YEAR`] so they advance with releases —
/// the year siblings of the `DateRecent` / `DateAdult` windows above.
/// `YearRecent` spans ~75 years (1950–2025 at REF_YEAR=2025), wide
/// enough for `published` / `founded` / `release_year`; `YearBirth`
/// mirrors the adult birth window (1945–2007).
const YEAR_RECENT_SPAN: i32 = 75;
const YEAR_BIRTH_MIN_AGE: i32 = 18;
const YEAR_BIRTH_MAX_AGE: i32 = 80;
/// Produce one value for `generator` against destination type `ty`.
#[must_use]
pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Value {
use fake::faker::address::en as addr;
use fake::faker::company::en as company;
use fake::faker::internet::en as net;
use fake::faker::job::en as job;
use fake::faker::lorem::en as lorem;
use fake::faker::name::en as name;
use fake::faker::phone_number::en as phone;
match generator {
Generator::FirstName => Value::Text(name::FirstName().fake_with_rng(rng)),
Generator::LastName => Value::Text(name::LastName().fake_with_rng(rng)),
Generator::FullName => Value::Text(name::Name().fake_with_rng(rng)),
Generator::Email => Value::Text(net::FreeEmail().fake_with_rng(rng)),
Generator::Username => Value::Text(net::Username().fake_with_rng(rng)),
Generator::Password => Value::Text(net::Password(8..16).fake_with_rng(rng)),
Generator::Phone => Value::Text(phone::PhoneNumber().fake_with_rng(rng)),
Generator::City => Value::Text(addr::CityName().fake_with_rng(rng)),
Generator::Country => Value::Text(addr::CountryName().fake_with_rng(rng)),
Generator::StateName => Value::Text(addr::StateName().fake_with_rng(rng)),
Generator::Street => Value::Text(addr::StreetName().fake_with_rng(rng)),
Generator::ZipCode => Value::Text(addr::ZipCode().fake_with_rng(rng)),
Generator::Company => Value::Text(company::CompanyName().fake_with_rng(rng)),
Generator::JobTitle => Value::Text(job::Title().fake_with_rng(rng)),
Generator::ProductName => Value::Text(product_name(rng)),
Generator::Sentence => Value::Text(lorem::Sentence(5..12).fake_with_rng(rng)),
Generator::Paragraph => Value::Text(lorem::Paragraph(2..4).fake_with_rng(rng)),
Generator::Url => {
let word: String = lorem::Word().fake_with_rng(rng);
let suffix: String = net::DomainSuffix().fake_with_rng(rng);
Value::Text(format!("https://{word}.{suffix}"))
}
// Hand-rolled — `fake`'s color module is feature-gated (it pulls
// an extra crate); a hex colour is trivial from the RNG.
Generator::HexColor => Value::Text(format!("#{:06X}", rng.random_range(0..0x0100_0000))),
Generator::CurrencyAmount => currency_amount(ty, rng),
Generator::Age => Value::Number(rng.random_range(18..=80).to_string()),
Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()),
Generator::YearRecent => Value::Number(
rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR)
.to_string(),
),
Generator::YearBirth => Value::Number(
rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE))
.to_string(),
),
Generator::DateRecent => {
Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS)))
}
Generator::DateAdult => Value::Text(format_date(random_past_date(
rng,
ADULT_MIN_DAYS,
ADULT_MAX_DAYS,
))),
Generator::DateTimeRecent => Value::Text(random_recent_datetime(rng)),
Generator::Boolean => Value::Bool(rng.random_range(0..2) == 1),
Generator::PickFrom(values) if !values.is_empty() => {
let chosen: &String = pick(rng, values);
literal_to_value(chosen, ty)
}
// The `set <col> between low and high` override (D2). Bounds are
// interpreted per the destination type; the executor has already
// validated they parse, so a defensive parse failure here falls
// back to type-based generation rather than producing junk.
Generator::Range { low, high } => range_value(low, high, ty, rng),
// Un-intercepted markers + an empty pick list → type-based.
Generator::PickFrom(_)
| Generator::IdentitySequential
| Generator::ForeignKeySample
| Generator::Generic => generic_for_type(ty, rng),
}
}
/// Uniform value in `[low, high]` for the `between` override (D2).
///
/// Bounds are interpreted by destination type. Returns the type-based
/// fallback for a bound that does not parse or a type that has no range
/// meaning — the executor pre-validates, so this is defensive only.
fn range_value(low: &str, high: &str, ty: Type, rng: &mut SeedRng) -> Value {
match ty {
Type::Int | Type::Serial => parse_int_range(low, high)
.map(|(lo, hi)| Value::Number(rng.random_range(lo..=hi).to_string()))
.unwrap_or_else(|| generic_for_type(ty, rng)),
Type::Real | Type::Decimal => parse_real_range(low, high)
.map(|(lo, hi)| {
let v = rng.random::<f64>().mul_add(hi - lo, lo);
Value::Number(format!("{v:.2}"))
})
.unwrap_or_else(|| generic_for_type(ty, rng)),
Type::Date => parse_date_range(low, high)
.map(|(lo, hi)| Value::Text(format_date(random_date_between(rng, lo, hi))))
.unwrap_or_else(|| generic_for_type(ty, rng)),
Type::DateTime => parse_datetime_range(low, high)
.map(|(lo, hi)| Value::Text(random_datetime_between(rng, lo, hi)))
.unwrap_or_else(|| generic_for_type(ty, rng)),
// text / bool / shortid have no range meaning.
_ => generic_for_type(ty, rng),
}
}
/// Validate that `low`/`high` parse as bounds for `ty`.
///
/// The `between` override (D2) is checked by the executor *before*
/// generation. Returns a short human reason on failure (the executor
/// wraps it in a friendly error naming the column), `None` when valid.
#[must_use]
pub fn range_bounds_reason(ty: Type, low: &str, high: &str) -> Option<String> {
let ok = match ty {
Type::Int | Type::Serial => parse_int_range(low, high).is_some(),
Type::Real | Type::Decimal => parse_real_range(low, high).is_some(),
Type::Date => parse_date_range(low, high).is_some(),
Type::DateTime => parse_datetime_range(low, high).is_some(),
// text / bool / shortid have no range meaning.
Type::Text | Type::Bool | Type::ShortId => false,
};
if ok {
return None;
}
Some(match ty {
Type::Int | Type::Serial => "expected two whole numbers, e.g. `between 1 and 100`".to_string(),
Type::Real | Type::Decimal => "expected two numbers, e.g. `between 1.0 and 9.99`".to_string(),
Type::Date => "expected two quoted dates, e.g. `between '2023-01-01' and '2024-12-31'`".to_string(),
Type::DateTime => {
"expected two quoted datetimes, e.g. `between '2023-01-01T00:00:00' and '2024-12-31T23:59:59'`"
.to_string()
}
Type::Text | Type::Bool | Type::ShortId => {
"a `between` range only applies to numeric and date/datetime columns".to_string()
}
})
}
/// Parse and order an integer range; `None` if either bound is not an
/// integer.
fn parse_int_range(low: &str, high: &str) -> Option<(i64, i64)> {
let lo: i64 = low.trim().parse().ok()?;
let hi: i64 = high.trim().parse().ok()?;
Some(if lo <= hi { (lo, hi) } else { (hi, lo) })
}
fn parse_real_range(low: &str, high: &str) -> Option<(f64, f64)> {
let lo: f64 = low.trim().parse().ok()?;
let hi: f64 = high.trim().parse().ok()?;
if !lo.is_finite() || !hi.is_finite() {
return None;
}
Some(if lo <= hi { (lo, hi) } else { (hi, lo) })
}
fn parse_date_range(low: &str, high: &str) -> Option<(NaiveDate, NaiveDate)> {
let lo = NaiveDate::parse_from_str(low.trim(), "%Y-%m-%d").ok()?;
let hi = NaiveDate::parse_from_str(high.trim(), "%Y-%m-%d").ok()?;
Some(if lo <= hi { (lo, hi) } else { (hi, lo) })
}
/// Accept both the `T`-separated and space-separated datetime spellings
/// the app validates (`bind_datetime` / `validate_datetime`).
fn parse_one_datetime(s: &str) -> Option<chrono::NaiveDateTime> {
let t = s.trim();
chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%d %H:%M:%S"))
.ok()
}
fn parse_datetime_range(
low: &str,
high: &str,
) -> Option<(chrono::NaiveDateTime, chrono::NaiveDateTime)> {
let lo = parse_one_datetime(low)?;
let hi = parse_one_datetime(high)?;
Some(if lo <= hi { (lo, hi) } else { (hi, lo) })
}
/// Uniform date in `[lo, hi]` (inclusive).
fn random_date_between(rng: &mut SeedRng, lo: NaiveDate, hi: NaiveDate) -> NaiveDate {
let lo_ce = lo.num_days_from_ce();
let hi_ce = hi.num_days_from_ce();
let day = rng.random_range(lo_ce..=hi_ce);
NaiveDate::from_num_days_from_ce_opt(day).unwrap_or(lo)
}
/// Uniform datetime in `[lo, hi]`, rendered `YYYY-MM-DDTHH:MM:SS`.
fn random_datetime_between(
rng: &mut SeedRng,
lo: chrono::NaiveDateTime,
hi: chrono::NaiveDateTime,
) -> String {
let lo_s = lo.and_utc().timestamp();
let hi_s = hi.and_utc().timestamp();
let secs = if lo_s <= hi_s {
rng.random_range(lo_s..=hi_s)
} else {
rng.random_range(hi_s..=lo_s)
};
let dt = chrono::DateTime::from_timestamp(secs, 0).map_or(lo, |d| d.naive_utc());
dt.format("%Y-%m-%dT%H:%M:%S").to_string()
}
/// Type-based fallback generation (D8). Never produces NULL for a
/// generatable type; `serial`/`shortid` are handled by the executor
/// (autogen) and yield NULL here only as a last resort.
fn generic_for_type(ty: Type, rng: &mut SeedRng) -> Value {
use fake::faker::lorem::en as lorem;
match ty {
Type::Text => {
let words: Vec<String> = lorem::Words(2..4).fake_with_rng(rng);
Value::Text(words.join(" "))
}
Type::ShortId => Value::Text(crate::dsl::shortid::generate_with_rng(rng)),
Type::Int => Value::Number(rng.random_range(1..=10_000).to_string()),
Type::Serial => Value::Number(rng.random_range(1..=10_000).to_string()),
Type::Real => {
let n: f64 = rng.random_range(0..100_000) as f64 / 100.0;
Value::Number(format!("{n:.2}"))
}
Type::Decimal => {
let dollars = rng.random_range(0..10_000);
let cents = rng.random_range(0..100);
Value::Number(format!("{dollars}.{cents:02}"))
}
Type::Bool => Value::Bool(rng.random_range(0..2) == 1),
Type::Date => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))),
Type::DateTime => Value::Text(random_recent_datetime(rng)),
}
}
/// Wrap a fixed-list literal as the right `Value` shape for `ty` (used
/// by `PickFrom` — enum / `IN`-CHECK values).
fn literal_to_value(s: &str, ty: Type) -> Value {
match ty {
Type::Int | Type::Serial | Type::Real | Type::Decimal => Value::Number(s.to_string()),
Type::Bool => Value::Bool(matches!(s.to_ascii_lowercase().as_str(), "true" | "1")),
_ => Value::Text(s.to_string()),
}
}
/// A money-shaped amount: whole for `int`/`serial`, two-decimal for the
/// fractional numeric types.
fn currency_amount(ty: Type, rng: &mut SeedRng) -> Value {
match ty {
Type::Real | Type::Decimal => {
let dollars = rng.random_range(1..=1_000);
let cents = rng.random_range(0..100);
Value::Number(format!("{dollars}.{cents:02}"))
}
// int / serial / anything else numeric → whole amount.
_ => Value::Number(rng.random_range(1..=1_000).to_string()),
}
}
// — the hand-rolled `product` generator (D9) —
const PRODUCT_ADJECTIVES: &[&str] = &[
"Sleek",
"Rustic",
"Ergonomic",
"Handcrafted",
"Refined",
"Modern",
"Vintage",
"Compact",
"Premium",
"Lightweight",
"Durable",
"Elegant",
"Sturdy",
"Smooth",
"Gorgeous",
"Intelligent",
"Practical",
"Awesome",
"Incredible",
"Recycled",
];
const PRODUCT_MATERIALS: &[&str] = &[
"Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo", "Plastic", "Ceramic",
"Glass", "Concrete", "Rubber", "Bronze", "Marble", "Linen", "Silk", "Aluminum", "Wool", "Gold",
"Carbon",
];
const PRODUCT_NOUNS: &[&str] = &[
"Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug", "Shoes", "Jacket", "Watch",
"Wallet", "Bench", "Hat", "Gloves", "Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket",
];
fn product_name(rng: &mut SeedRng) -> String {
format!(
"{} {} {}",
pick(rng, PRODUCT_ADJECTIVES),
pick(rng, PRODUCT_MATERIALS),
pick(rng, PRODUCT_NOUNS),
)
}
// — bounded dates (D8) —
const fn reference_date() -> NaiveDate {
match NaiveDate::from_ymd_opt(REF_YEAR, REF_MONTH, REF_DAY) {
Some(d) => d,
None => panic!("reference date constants must be valid"),
}
}
/// A date between `min_days_ago` and `max_days_ago` before the
/// reference epoch (inclusive).
fn random_past_date(rng: &mut SeedRng, min_days_ago: i64, max_days_ago: i64) -> NaiveDate {
let days_ago = rng.random_range(min_days_ago..=max_days_ago);
let ce = reference_date().num_days_from_ce();
let target = ce - i32::try_from(days_ago).unwrap_or(0);
NaiveDate::from_num_days_from_ce_opt(target).unwrap_or_else(reference_date)
}
fn format_date(date: NaiveDate) -> String {
date.format("%Y-%m-%d").to_string()
}
/// A recent datetime: a recent date plus a random time-of-day, rendered
/// as `YYYY-MM-DDTHH:MM:SS`.
fn random_recent_datetime(rng: &mut SeedRng) -> String {
let date = random_past_date(rng, 0, RECENT_WINDOW_DAYS);
let h = rng.random_range(0..24);
let m = rng.random_range(0..60);
let s = rng.random_range(0..60);
format!("{}T{h:02}:{m:02}:{s:02}", format_date(date))
}
/// Pick a uniformly random element from a non-empty slice.
fn pick<'a, T>(rng: &mut SeedRng, items: &'a [T]) -> &'a T {
&items[rng.random_range(0..items.len())]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seed::make_rng;
use pretty_assertions::assert_eq;
fn gen_once(generator: &Generator, ty: Type, seed: u64) -> Value {
let mut rng = make_rng(Some(seed));
generate_value(generator, ty, &mut rng)
}
#[test]
fn generation_is_deterministic_for_a_fixed_seed() {
for generator in [
Generator::FullName,
Generator::Email,
Generator::ProductName,
Generator::DateRecent,
Generator::CurrencyAmount,
] {
let a = gen_once(&generator, Type::Text, 7);
let b = gen_once(&generator, Type::Text, 7);
assert_eq!(a, b, "{generator:?} must reproduce for a fixed seed");
}
}
#[test]
fn text_generators_produce_nonempty_text() {
for generator in [
Generator::FirstName,
Generator::LastName,
Generator::FullName,
Generator::Email,
Generator::Username,
Generator::Company,
Generator::City,
Generator::ProductName,
] {
let v = gen_once(&generator, Type::Text, 3);
match v {
Value::Text(s) => {
assert!(!s.trim().is_empty(), "{generator:?} produced empty text")
}
other => panic!("{generator:?} produced non-text {other:?}"),
}
}
}
#[test]
fn email_looks_like_an_email() {
let v = gen_once(&Generator::Email, Type::Text, 11);
let Value::Text(s) = v else {
panic!("not text")
};
assert!(s.contains('@'), "email should contain @: {s}");
}
#[test]
fn product_name_is_three_capitalised_words() {
let v = gen_once(&Generator::ProductName, Type::Text, 99);
let Value::Text(s) = v else {
panic!("not text")
};
let words: Vec<&str> = s.split(' ').collect();
assert_eq!(words.len(), 3, "product name should be 3 words: {s}");
for w in words {
assert!(
w.chars().next().unwrap().is_ascii_uppercase(),
"word `{w}` not capitalised"
);
}
}
#[test]
fn recent_dates_fall_within_the_bounded_window() {
let mut rng = make_rng(Some(1));
let earliest = reference_date()
.checked_sub_days(chrono::Days::new(RECENT_WINDOW_DAYS as u64))
.unwrap();
let latest = reference_date();
for _ in 0..200 {
let v = generate_value(&Generator::DateRecent, Type::Date, &mut rng);
let Value::Text(s) = v else {
panic!("date not text")
};
let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date");
assert!(
d >= earliest && d <= latest,
"date {d} outside recent window"
);
}
}
#[test]
fn dob_dates_fall_within_the_adult_window() {
let mut rng = make_rng(Some(2));
let earliest = reference_date()
.checked_sub_days(chrono::Days::new(ADULT_MAX_DAYS as u64))
.unwrap();
let latest = reference_date()
.checked_sub_days(chrono::Days::new(ADULT_MIN_DAYS as u64))
.unwrap();
for _ in 0..200 {
let v = generate_value(&Generator::DateAdult, Type::Date, &mut rng);
let Value::Text(s) = v else {
panic!("date not text")
};
let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date");
assert!(d >= earliest && d <= latest, "dob {d} outside adult window");
}
}
#[test]
fn datetime_is_iso_shaped() {
let v = gen_once(&Generator::DateTimeRecent, Type::DateTime, 5);
let Value::Text(s) = v else {
panic!("not text")
};
assert!(s.contains('T'), "datetime needs a T separator: {s}");
// Parses as a naive datetime.
chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S")
.unwrap_or_else(|e| panic!("invalid datetime {s}: {e}"));
}
#[test]
fn currency_is_whole_for_int_and_fractional_for_decimal() {
let Value::Number(int_amt) = gen_once(&Generator::CurrencyAmount, Type::Int, 4) else {
panic!("not a number")
};
assert!(
!int_amt.contains('.'),
"int currency should be whole: {int_amt}"
);
let Value::Number(dec_amt) = gen_once(&Generator::CurrencyAmount, Type::Decimal, 4) else {
panic!("not a number")
};
assert!(
dec_amt.contains('.'),
"decimal currency should have cents: {dec_amt}"
);
}
#[test]
fn age_is_in_human_range() {
let mut rng = make_rng(Some(8));
for _ in 0..100 {
let Value::Number(a) = generate_value(&Generator::Age, Type::Int, &mut rng) else {
panic!("age not a number")
};
let n: i64 = a.parse().unwrap();
assert!((18..=80).contains(&n), "age {n} out of range");
}
}
#[test]
fn pick_from_chooses_a_listed_value() {
let generator = Generator::PickFrom(vec!["active".into(), "closed".into()]);
let mut rng = make_rng(Some(6));
for _ in 0..50 {
let Value::Text(s) = generate_value(&generator, Type::Text, &mut rng) else {
panic!("not text")
};
assert!(
matches!(s.as_str(), "active" | "closed"),
"unexpected pick {s}"
);
}
}
#[test]
fn pick_from_wraps_numeric_values_as_numbers() {
let generator = Generator::PickFrom(vec!["1".into(), "2".into(), "3".into()]);
let mut rng = make_rng(Some(6));
let v = generate_value(&generator, Type::Int, &mut rng);
assert!(
matches!(v, Value::Number(_)),
"numeric pick should be a Number: {v:?}"
);
}
#[test]
fn year_generators_stay_within_their_bounded_windows() {
// Issue #33: both year generators emit a plain `int` inside a
// bounded, plausible window — never the unbounded-int nonsense.
let mut rng = make_rng(Some(7));
for _ in 0..300 {
let Value::Number(s) = generate_value(&Generator::YearRecent, Type::Int, &mut rng)
else {
panic!("YearRecent must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!(
(1950..=2025).contains(&n),
"YearRecent {n} out of [1950,2025]"
);
}
for _ in 0..300 {
let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng)
else {
panic!("YearBirth must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!(
(1945..=2007).contains(&n),
"YearBirth {n} out of [1945,2007]"
);
}
}
#[test]
fn year_generators_are_deterministic_for_a_fixed_seed() {
assert_eq!(
gen_once(&Generator::YearRecent, Type::Int, 42),
gen_once(&Generator::YearRecent, Type::Int, 42),
);
assert_eq!(
gen_once(&Generator::YearBirth, Type::Int, 42),
gen_once(&Generator::YearBirth, Type::Int, 42),
);
}
#[test]
fn int_range_stays_within_inclusive_bounds() {
let g = Generator::Range {
low: "10".into(),
high: "20".into(),
};
let mut rng = make_rng(Some(5));
for _ in 0..200 {
let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
panic!("int range should be a number")
};
let n: i64 = s.parse().unwrap();
assert!((10..=20).contains(&n), "int {n} out of [10,20]");
}
}
#[test]
fn real_range_stays_within_bounds_and_has_cents() {
let g = Generator::Range {
low: "1.0".into(),
high: "9.0".into(),
};
let mut rng = make_rng(Some(5));
for _ in 0..200 {
let Value::Number(s) = generate_value(&g, Type::Real, &mut rng) else {
panic!("real range should be a number")
};
let n: f64 = s.parse().unwrap();
assert!((1.0..=9.0).contains(&n), "real {n} out of [1,9]");
assert!(s.contains('.'), "real should be formatted with cents: {s}");
}
}
#[test]
fn date_range_stays_within_quoted_bounds() {
let g = Generator::Range {
low: "2023-01-01".into(),
high: "2023-12-31".into(),
};
let lo = NaiveDate::parse_from_str("2023-01-01", "%Y-%m-%d").unwrap();
let hi = NaiveDate::parse_from_str("2023-12-31", "%Y-%m-%d").unwrap();
let mut rng = make_rng(Some(9));
for _ in 0..200 {
let Value::Text(s) = generate_value(&g, Type::Date, &mut rng) else {
panic!("date range should be text")
};
let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid date");
assert!(d >= lo && d <= hi, "date {d} out of range");
}
}
#[test]
fn reversed_bounds_are_tolerated() {
let g = Generator::Range {
low: "20".into(),
high: "10".into(),
};
let mut rng = make_rng(Some(1));
let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
panic!("number")
};
let n: i64 = s.parse().unwrap();
assert!(
(10..=20).contains(&n),
"reversed bounds still produce in-range: {n}"
);
}
#[test]
fn range_bounds_reason_accepts_compatible_and_rejects_incompatible() {
// Numeric / date / datetime accept; text / bool reject.
assert!(range_bounds_reason(Type::Int, "1", "10").is_none());
assert!(range_bounds_reason(Type::Real, "1.5", "9.9").is_none());
assert!(range_bounds_reason(Type::Date, "2023-01-01", "2024-01-01").is_none());
assert!(
range_bounds_reason(Type::DateTime, "2023-01-01T00:00:00", "2024-01-01T00:00:00")
.is_none()
);
// Non-numeric bound on a numeric column.
assert!(range_bounds_reason(Type::Int, "abc", "10").is_some());
// A range on a text column is meaningless.
assert!(range_bounds_reason(Type::Text, "a", "z").is_some());
assert!(range_bounds_reason(Type::Bool, "0", "1").is_some());
}
#[test]
fn markers_fall_back_to_type_based_generation() {
// An un-intercepted marker must not panic; it generates by type.
let v = gen_once(&Generator::IdentitySequential, Type::Text, 1);
assert!(matches!(v, Value::Text(_)));
let v = gen_once(&Generator::ForeignKeySample, Type::Int, 1);
assert!(matches!(v, Value::Number(_)));
}
#[test]
fn generic_fallback_matches_each_type() {
let mut rng = make_rng(Some(0));
assert!(matches!(
generate_value(&Generator::Generic, Type::Text, &mut rng),
Value::Text(_)
));
assert!(matches!(
generate_value(&Generator::Generic, Type::Int, &mut rng),
Value::Number(_)
));
assert!(matches!(
generate_value(&Generator::Generic, Type::Bool, &mut rng),
Value::Bool(_)
));
// shortid fallback is a valid base58 id.
let Value::Text(sid) = generate_value(&Generator::Generic, Type::ShortId, &mut rng) else {
panic!("shortid not text")
};
assert!(
crate::dsl::shortid::validate(&sid).is_ok(),
"invalid shortid {sid}"
);
}
}