import improvements2

This commit is contained in:
Priec
2026-08-17 15:20:31 +02:00
parent 1b9136f1de
commit 71807f9a6d
15 changed files with 944 additions and 1292 deletions

View File

@@ -80,52 +80,6 @@ fn is_read_omitted_column(name: &str) -> bool {
.any(|column| column.name == name)
}
/// The columns an import may write into: the destinations the mapping step
/// offers, in the order the table declares them.
///
/// This is the list the whole import rests on. A source position lands in a
/// column because the user picked it from here, so what is not here cannot be
/// written to at all — which is why the exclusions are the server's own flags
/// rather than a guess:
///
/// * `is_primary_key` — `id` comes from a sequence.
/// * `read_only` — the server sets this for a quantity-ledger column and for a
/// link projection, and refuses an insert that names either. Accounting
/// columns are marked `generated` but *not* read-only, so they stay: they are
/// a user's to fill in.
/// * system columns, except the ones an insert actually takes. `deleted` is
/// offered, because writing it is how a file that recorded deleted rows loads
/// back as deleted rows; `row_revision` and `created_at` are not, because the
/// server assigns them and answers `Invalid column` to anything else.
pub(crate) fn importable_columns(schema: &TableStructureResponse) -> Vec<String> {
schema
.columns
.iter()
.filter(|column| !column.is_primary_key && !column.read_only)
.filter(|column| {
!is_system_column(&column.name) || is_importable_system_column(&column.name)
})
.map(|column| column.name.clone())
.collect()
}
/// The writable columns the table declares `NOT NULL`.
///
/// Shown in the preview as a warning rather than enforced as a rule: a
/// `NOT NULL` column may still have a default, and `information_schema` does
/// not say which do. Refusing here would block imports the server would have
/// accepted, so the server stays the one that decides and this only tells the
/// user which columns are the likely reason if it refuses.
pub(crate) fn required_columns(schema: &TableStructureResponse) -> Vec<String> {
schema
.columns
.iter()
.filter(|column| !column.is_primary_key && !column.read_only)
.filter(|column| !is_system_column(&column.name) && !column.is_nullable)
.map(|column| column.name.clone())
.collect()
}
pub(crate) fn column_types(schema: &TableStructureResponse) -> HashMap<String, String> {
schema
.columns
@@ -234,64 +188,5 @@ mod tests {
);
}
/// What the mapping step may offer as a destination. `deleted` is in,
/// because an insert takes it; the columns the server assigns and the ones
/// it marks read-only are out, because an insert naming them is refused.
#[test]
fn only_the_columns_an_insert_takes_are_offered_as_destinations() {
let ledger = TableColumn {
read_only: true,
..column("stock", false)
};
// Accounting columns are generated companions, but the server leaves
// them writable — so they are a destination like any other.
let accounting = TableColumn {
generated: true,
generated_from: "accounting".to_string(),
..column("debit", false)
};
let schema = TableStructureResponse {
columns: vec![
column("id", true),
column("deleted", false),
column("row_revision", false),
column("number", false),
ledger,
accounting,
column("created_at", false),
],
};
assert_eq!(
importable_columns(&schema),
vec![
"deleted".to_string(),
"number".to_string(),
"debit".to_string(),
]
);
}
/// Only the user's own `NOT NULL` columns are reported as required.
/// `deleted` is `NOT NULL` on every managed table and has a default, so
/// reporting it would warn about every import ever prepared.
#[test]
fn required_columns_are_the_users_own_not_null_ones() {
let schema = TableStructureResponse {
columns: vec![
column("id", true),
TableColumn {
is_nullable: false,
..column("deleted", false)
},
TableColumn {
is_nullable: false,
..column("number", false)
},
column("note", false),
],
};
assert_eq!(required_columns(&schema), vec!["number".to_string()]);
}
}

View File

@@ -0,0 +1,227 @@
//! The columns an import may write into, and how the form names one.
//!
//! The mapping is read destination-first: every writable column of the table
//! gets one row, and the row asks where its value comes from. That is what
//! makes "one destination filled twice" impossible to express rather than
//! merely refused — each destination appears exactly once, by construction.
use crate::definitions::table_structure::{TableColumn, TableStructureResponse};
use super::super::common::schema::{is_importable_system_column, is_system_column};
/// How a destination column is named in the form.
///
/// Not by its display name. A user column has a stable identity the server
/// assigns once — `column_id` — and it keeps that identity through a rename, so
/// a mapping built before a rename still means the same column afterwards
/// rather than silently pointing at whatever now answers to the old name.
///
/// The system columns have no such id (`column_id` is zero for them), so they
/// are named by the one thing they do have. That is safe for exactly the reason
/// the id exists: a system column's name is the server's and cannot be renamed.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum DestinationKey {
Column(i64),
System(String),
}
impl DestinationKey {
fn of(column: &TableColumn) -> Self {
if column.column_id == 0 {
Self::System(column.name.clone())
} else {
Self::Column(column.column_id)
}
}
/// What the hidden field carries.
pub(crate) fn encode(&self) -> String {
match self {
Self::Column(id) => format!("id:{id}"),
Self::System(name) => format!("system:{name}"),
}
}
pub(crate) fn parse(raw: &str) -> Option<Self> {
if let Some(id) = raw.strip_prefix("id:") {
return id.parse().ok().map(Self::Column);
}
raw.strip_prefix("system:")
.map(|name| Self::System(name.to_string()))
}
}
/// One column an import may write into.
#[derive(Clone, Debug)]
pub(crate) struct DestinationColumn {
pub key: DestinationKey,
/// The name the table currently shows, which is what the page displays and
/// what the prepared CSV's header says. The identity is `key`.
pub name: String,
/// Declared `NOT NULL`. A warning in the preview rather than a rule: the
/// column may have a default, and `information_schema` does not say which
/// do, so refusing here would block imports the server would accept.
pub required: bool,
}
/// The columns of `schema` an import may write into, in the order the table
/// declares them.
///
/// The exclusions are the server's own flags rather than a guess:
///
/// * `is_primary_key` — `id` comes from a sequence.
/// * `read_only` — set for a quantity-ledger column and for a link projection,
/// and an insert naming either is refused. Accounting columns are marked
/// `generated` but *not* read-only, so they stay: they are a user's to fill
/// in.
/// * system columns, except the ones an insert actually takes. `deleted` is
/// offered, because writing it is how a file that recorded deleted rows loads
/// back as deleted rows; `row_revision` and `created_at` are not, because the
/// server assigns them and answers `Invalid column` to anything else.
pub(crate) fn destination_columns(schema: &TableStructureResponse) -> Vec<DestinationColumn> {
schema
.columns
.iter()
.filter(|column| !column.is_primary_key && !column.read_only)
.filter(|column| {
!is_system_column(&column.name) || is_importable_system_column(&column.name)
})
.map(|column| DestinationColumn {
key: DestinationKey::of(column),
name: column.name.clone(),
// A system column's nullability is the server's business, and
// `deleted` is NOT NULL with a default on every managed table —
// reporting it would warn about every import ever prepared.
required: !column.is_nullable && !is_system_column(&column.name),
})
.collect()
}
/// The column a posted key names, in the table as it stands right now.
///
/// `None` means the table no longer has it — dropped, or made read-only, while
/// the form sat open. The caller turns that into a refusal; it must never
/// become "write it somewhere else".
pub(crate) fn resolve<'a>(
columns: &'a [DestinationColumn],
raw: &str,
) -> Option<&'a DestinationColumn> {
let key = DestinationKey::parse(raw)?;
columns.iter().find(|column| column.key == key)
}
#[cfg(test)]
mod tests {
use super::*;
fn column(name: &str, column_id: i64) -> TableColumn {
TableColumn {
name: name.to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
column_id,
..Default::default()
}
}
fn schema() -> TableStructureResponse {
TableStructureResponse {
columns: vec![
TableColumn {
is_primary_key: true,
..column("id", 0)
},
column("deleted", 0),
column("row_revision", 0),
TableColumn {
is_nullable: false,
..column("number", 42)
},
// A quantity-ledger column: the server refuses an insert that
// names it, so it is not a destination.
TableColumn {
read_only: true,
..column("stock", 57)
},
// Accounting columns are generated companions but stay
// writable, so they are destinations like any other.
TableColumn {
generated: true,
generated_from: "accounting".to_string(),
..column("debit", 81)
},
column("created_at", 0),
],
}
}
#[test]
fn only_the_columns_an_insert_takes_are_offered() {
let columns = destination_columns(&schema());
assert_eq!(
columns
.iter()
.map(|column| column.name.as_str())
.collect::<Vec<_>>(),
vec!["deleted", "number", "debit"]
);
}
/// A user column is identified by the id the server gave it, so a rename
/// cannot move a mapping onto a different column. A system column has no
/// id and no rename, so its name is identity enough.
#[test]
fn a_user_column_is_named_by_its_stable_id_and_a_system_one_by_its_name() {
let columns = destination_columns(&schema());
assert_eq!(columns[0].key.encode(), "system:deleted");
assert_eq!(columns[1].key.encode(), "id:42");
for column in &columns {
assert_eq!(
DestinationKey::parse(&column.key.encode()).as_ref(),
Some(&column.key)
);
}
}
/// The point of the id: the mapping survives the rename, and follows the
/// column rather than the name.
#[test]
fn a_renamed_column_is_still_the_same_destination() {
let posted = destination_columns(&schema())[1].key.encode();
let mut renamed = schema();
renamed.columns[3].name = "invoice_number".to_string();
let columns = destination_columns(&renamed);
let resolved =
resolve(&columns, &posted).expect("column 42 is column 42 whatever it is called");
assert_eq!(resolved.name, "invoice_number");
}
/// A destination that is gone, or that the table has since made read-only,
/// resolves to nothing — which the caller has to refuse rather than guess
/// past.
#[test]
fn a_destination_the_table_no_longer_offers_resolves_to_nothing() {
let columns = destination_columns(&schema());
assert!(resolve(&columns, "id:57").is_none());
assert!(resolve(&columns, "id:999").is_none());
assert!(resolve(&columns, "system:row_revision").is_none());
assert!(resolve(&columns, "number").is_none());
assert!(resolve(&columns, "").is_none());
}
/// Only the user's own `NOT NULL` columns are reported as required.
#[test]
fn required_is_the_users_own_not_null_columns() {
let columns = destination_columns(&schema());
assert_eq!(
columns
.iter()
.filter(|column| column.required)
.map(|column| column.name.as_str())
.collect::<Vec<_>>(),
vec!["number"]
);
}
}

View File

@@ -5,8 +5,9 @@ use axum::{
http::{HeaderMap, HeaderValue, header},
response::{Html, IntoResponse, Redirect, Response},
};
// A mapping posts `mapping` once per source position, and `axum::Form`
// (serde_urlencoded) cannot decode repeated keys into a `Vec`.
// The mapping posts `destination` and `source_position` once per destination
// row, and `axum::Form` (serde_urlencoded) cannot decode repeated keys into a
// `Vec`.
use axum_extra::extract::Form;
use crate::{
@@ -22,16 +23,12 @@ use crate::{
use super::{
super::common::{
loader::LoadError,
schema::{column_types, csv_value, importable_columns, required_columns},
schema::{column_types, csv_value},
},
destination::{DestinationColumn, destination_columns, resolve},
loader::load_page,
prepare::{
Prepared, SourceMode, canonical_csv, prepare, read_mapping, read_source, suggest_mapping,
template_csv,
},
state::{
ImportForm, MappingRow, MappingStep, PreviewStep, Step, TemplateRow, TemplateStep,
},
prepare::{Prepared, Source, canonical_csv, prepare, read_mapping, read_source},
state::{ImportForm, MappingRow, MappingStep, PreviewStep, SourceOption, Step},
ui,
};
@@ -42,17 +39,25 @@ const PREVIEW_ROWS: usize = 20;
///
/// Loaded again at every step rather than carried in the form. The destination
/// list is what makes a mapping mean anything, so it comes from the table each
/// time: a column dropped or renamed while the form sat open turns into a
/// refusal on the next click instead of a value written somewhere else.
/// time: a column dropped or made read-only while the form sat open turns into
/// a refusal on the next click instead of a value written somewhere else.
struct Destination {
table_name: String,
/// The columns a mapping may point at.
columns: Vec<String>,
/// Of those, the ones declared `NOT NULL`.
required: Vec<String>,
columns: Vec<DestinationColumn>,
types: HashMap<String, String>,
}
impl Destination {
/// The destination names, for the parts of the preparation that only need
/// to know which columns exist.
fn names(&self) -> Vec<String> {
self.columns
.iter()
.map(|column| column.name.clone())
.collect()
}
}
/// GET /admin/import
pub(crate) async fn import_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
match load_page(state, &headers, ImportForm::default(), Step::Source).await {
@@ -74,11 +79,8 @@ pub(crate) async fn source_step(
render_step(state, &headers, form, Step::Source).await
}
/// POST /admin/import/prepare — the second step: the mapping table for a file,
/// or the column chooser for a template.
///
/// Also where the template's move buttons land, because reordering is a change
/// to the same step rather than a step of its own.
/// POST /admin/import/prepare — the mapping: one row per writable destination
/// column, asking where its value comes from.
pub(crate) async fn prepare_step(
State(state): State<AppState>,
headers: HeaderMap,
@@ -92,43 +94,48 @@ pub(crate) async fn prepare_step(
Ok(destination) => destination,
Err(response) => return response,
};
let mode = match form.mode(locale) {
Ok(mode) => mode,
let source = match read_source(locale, &form.csv_data) {
Ok(source) => source,
Err(message) => return reject(&headers, message),
};
let step = match mode {
SourceMode::Template => template_step(&form, &destination),
SourceMode::Header | SourceMode::Data => {
let source = match read_source(locale, &form.csv_data, mode) {
Ok(source) => source,
Err(message) => return reject(&headers, message),
};
// A mapping that does not cover this file was built against a
// different one — the user edited the CSV and came back. Start it
// over rather than lining up positions that no longer correspond.
let chosen = if form.mapping.len() == source.width {
form.mapping.clone()
} else {
suggest_mapping(&source, &destination.columns)
};
let examples = source.examples();
Step::Mapping(MappingStep {
table_name: destination.table_name.clone(),
columns: destination.columns.clone(),
rows: (0..source.width)
.map(|position| MappingRow {
position: position + 1,
source_name: source.source_name(position),
example: examples.get(position).cloned().unwrap_or_default(),
target: chosen.get(position).cloned().unwrap_or_default(),
})
.collect(),
source_rows: source.rows.len(),
has_source_names: source.header.is_some(),
// What the user has already answered, if they are coming back from the
// preview. Keyed by the destination's identity rather than by row order, so
// a table that gained or lost a column keeps the answers for the rest.
let answered = form
.rows()
.into_iter()
.filter_map(|(key, position)| Some((key.to_string(), position?)))
.collect::<HashMap<_, _>>();
let step = Step::Mapping(MappingStep {
table_name: destination.table_name.clone(),
rows: destination
.columns
.iter()
.map(|column| {
let key = column.key.encode();
// Nothing starts out mapped. An exact name match would be a
// reasonable guess, but a guess is what this page exists to
// avoid — every destination is the user's to answer.
let chosen = answered
.get(&key)
.copied()
.filter(|index| *index < source.width());
MappingRow {
key,
name: column.name.clone(),
required: column.required,
example: chosen
.map(|index| source.example(index).to_string())
.unwrap_or_default(),
chosen: chosen.map(|index| (index + 1).to_string()).unwrap_or_default(),
}
})
}
};
.collect(),
sources: source_options(locale, &source),
source_rows: source.rows.len(),
});
render_step(state, &headers, form, step).await
}
@@ -142,12 +149,12 @@ pub(crate) async fn preview_step(
return rejection;
}
let locale = Locale::from_headers(&headers);
let (destination, prepared) = match prepared(state.clone(), &headers, &form).await {
let (destination, source, prepared) = match prepared(state.clone(), &headers, &form).await {
Ok(parts) => parts,
Err(response) => return response,
};
let source_names = source_names(locale, &form);
let sources = source_options(locale, &source);
let step = Step::Preview(PreviewStep {
table_name: destination.table_name.clone(),
columns: prepared.columns.clone(),
@@ -157,16 +164,13 @@ pub(crate) async fn preview_step(
ignored: prepared
.ignored
.iter()
.map(|position| match source_names.get(position - 1) {
Some(name) if !name.is_empty() => format!("{position} ({name})"),
_ => position.to_string(),
})
.filter_map(|position| Some(sources.get(position - 1)?.label.clone()))
.collect(),
missing_required: destination
.required
.columns
.iter()
.filter(|column| !prepared.columns.contains(column))
.cloned()
.filter(|column| column.required && !prepared.columns.contains(&column.name))
.map(|column| column.name.clone())
.collect(),
omitted: prepared.omitted.clone(),
csv: canonical_csv(&prepared),
@@ -192,7 +196,7 @@ pub(crate) async fn import_csv(
Ok(target) => target,
Err(message) => return reject(&headers, message),
};
let (destination, prepared) = match prepared(state.clone(), &headers, &form).await {
let (destination, _, prepared) = match prepared(state.clone(), &headers, &form).await {
Ok(parts) => parts,
Err(response) => return response,
};
@@ -206,7 +210,9 @@ pub(crate) async fn import_csv(
tr!(
locale,
"import-err-csv-row",
"row" => (index + 1) as i64,
// The header is row 1 of the file the user is looking at,
// so the first data row is row 2.
"row" => (index + 2) as i64,
"error" => error,
)
})
@@ -261,39 +267,24 @@ pub(crate) async fn download_prepared(
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let (destination, prepared) = match prepared(state, &headers, &form).await {
let (destination, _, prepared) = match prepared(state, &headers, &form).await {
Ok(parts) => parts,
Err(response) => return response,
};
attachment(
canonical_csv(&prepared),
&format!("{}_prepared.csv", destination.table_name),
)
}
let csv = canonical_csv(&prepared);
let filename = format!("{}_prepared.csv", destination.table_name);
/// POST /admin/import/template.csv — the chosen columns as a header row, and
/// nothing else.
pub(crate) async fn download_template(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<ImportForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
let mut response = csv.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/csv; charset=utf-8"),
);
if let Ok(value) = HeaderValue::try_from(format!("attachment; filename=\"{filename}\"")) {
response
.headers_mut()
.insert(header::CONTENT_DISPOSITION, value);
}
let locale = Locale::from_headers(&headers);
let destination = match destination(state, &headers, &form).await {
Ok(destination) => destination,
Err(response) => return response,
};
let columns = match template_selection(locale, &form, &destination) {
Ok(columns) => columns,
Err(message) => return reject(&headers, message),
};
attachment(
template_csv(&columns),
&format!("{}_template.csv", destination.table_name),
)
response
}
/// The table this import writes into, once the form's scope and table have been
@@ -340,7 +331,7 @@ async fn destination(
.remove(&table_name)
.ok_or_else(|| unavailable(headers, tr!(locale, "import-err-missing-structure")))?;
let columns = importable_columns(&structure);
let columns = destination_columns(&structure);
if columns.is_empty() {
return Err(reject(
headers,
@@ -353,131 +344,72 @@ async fn destination(
}
Ok(Destination {
table_name,
required: required_columns(&structure),
types: column_types(&structure),
columns,
})
}
/// The prepared import: the source read as the user said it is laid out, the
/// mapping checked against the table as it stands, and the two applied.
/// The prepared import: the file read, the mapping resolved against the table
/// as it stands, and the two applied.
///
/// Shared by preview, download and import so that all three are the same file
/// looking at it and importing it cannot disagree about what it contains.
/// Shared by preview, download and import so that all three are the same file
/// looking at it and importing it cannot disagree about what it contains.
async fn prepared(
state: AppState,
headers: &HeaderMap,
form: &ImportForm,
) -> Result<(Destination, Prepared), Response> {
) -> Result<(Destination, Source, Prepared), Response> {
let locale = Locale::from_headers(headers);
let destination = destination(state, headers, form).await?;
let mode = form.mode(locale).map_err(|message| reject(headers, message))?;
if !mode.reads_a_file() {
return Err(reject(
headers,
tr!(locale, "import-err-template-has-no-source"),
));
}
let source = read_source(locale, &form.csv_data, mode)
.map_err(|message| reject(headers, message))?;
let mapping = read_mapping(locale, &form.mapping, source.width, &destination.columns)
.map_err(|message| reject(headers, message))?;
let prepared = prepare(&mapping, &source, &destination.columns);
Ok((destination, prepared))
}
let source = read_source(locale, &form.csv_data).map_err(|message| reject(headers, message))?;
/// The column chooser, with the arrangement the form carries and whatever move
/// button was pressed applied to it.
fn template_step(form: &ImportForm, destination: &Destination) -> Step {
let mut order = arranged_columns(form, destination);
if let (Some(action), Some(index)) = (form.action.as_deref(), form.index) {
let swap_with = match action {
"up" if index > 0 => Some(index - 1),
"down" if index + 1 < order.len() => Some(index + 1),
_ => None,
};
if let Some(other) = swap_with {
order.swap(index, other);
}
// Every posted destination has to still exist and still be writable. A key
// that resolves to nothing is refused rather than skipped: skipping it
// would quietly import a subset of what the preview promised.
let mut chosen = Vec::new();
for (key, position) in form.rows() {
let column = resolve(&destination.columns, key).ok_or_else(|| {
reject(
headers,
tr!(
locale,
"import-err-destination-missing",
"table" => destination.table_name.clone(),
),
)
})?;
chosen.push((column.name.clone(), position));
}
let chosen = order
.iter()
.filter(|column| form.template_columns.contains(column))
.cloned()
.collect::<Vec<_>>();
let last = order.len().saturating_sub(1);
Step::Template(TemplateStep {
table_name: destination.table_name.clone(),
rows: order
.iter()
.enumerate()
.map(|(index, name)| TemplateRow {
index,
chosen: form.template_columns.contains(name),
required: destination.required.contains(name),
name: name.clone(),
first: index == 0,
last: index == last,
})
.collect(),
header: if chosen.is_empty() {
String::new()
} else {
template_csv(&chosen)
},
chosen: chosen.len(),
})
let assignments =
read_mapping(locale, &chosen, &source).map_err(|message| reject(headers, message))?;
let prepared = prepare(&assignments, &source, &destination.names());
Ok((destination, source, prepared))
}
/// The destination columns in the order the template step has them arranged.
///
/// The table's own order until the user moves something, and thereafter what
/// the form carries — minus anything the table no longer has, plus anything it
/// has gained, so a table changed mid-arrangement neither drops a column from
/// the chooser nor offers one that is gone.
fn arranged_columns(form: &ImportForm, destination: &Destination) -> Vec<String> {
let mut order = form
.template_order
.iter()
.filter(|column| destination.columns.contains(column))
.cloned()
.collect::<Vec<_>>();
order.dedup();
for column in &destination.columns {
if !order.contains(column) {
order.push(column.clone());
}
}
order
}
/// The template's columns, in the arranged order, checked for being answered.
fn template_selection(
locale: Locale,
form: &ImportForm,
destination: &Destination,
) -> Result<Vec<String>, String> {
let chosen = arranged_columns(form, destination)
.into_iter()
.filter(|column| form.template_columns.contains(column))
.collect::<Vec<_>>();
if chosen.is_empty() {
return Err(tr!(locale, "import-err-template-empty"));
}
Ok(chosen)
}
/// The source's own names for its positions, for labelling the ignored ones in
/// the summary. Best effort: a source that cannot be read at all has already
/// been refused elsewhere.
fn source_names(locale: Locale, form: &ImportForm) -> Vec<String> {
form.mode(locale)
.ok()
.filter(|mode| *mode == SourceMode::Header)
.and_then(|mode| read_source(locale, &form.csv_data, mode).ok())
.and_then(|source| source.header)
.unwrap_or_default()
/// The file's columns as every picker offers them: `hl — position 3`, and just
/// the position when the file left the name blank.
fn source_options(locale: Locale, source: &Source) -> Vec<SourceOption> {
(0..source.width())
.map(|index| {
let position = (index + 1) as i64;
let name = source.name(index).trim();
SourceOption {
position: index + 1,
label: if name.is_empty() {
tr!(locale, "import-source-unnamed", "position" => position)
} else {
tr!(
locale,
"import-source-option",
"name" => name.to_string(),
"position" => position,
)
},
example: source.example(index).to_string(),
}
})
.collect()
}
/// One prepared row as the bulk insert takes it: values under their destination
@@ -519,22 +451,8 @@ async fn render_step(
}
}
fn attachment(csv: String, filename: &str) -> Response {
let mut response = csv.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/csv; charset=utf-8"),
);
if let Ok(value) = HeaderValue::try_from(format!("attachment; filename=\"{filename}\"")) {
response
.headers_mut()
.insert(header::CONTENT_DISPOSITION, value);
}
response
}
/// Something this crate refused before any of it reached the backend: an
/// unknown scope, a mapping that does not fit the file, a cell no type accepts.
/// unknown scope, a position the file does not have, a cell no type accepts.
/// The buttons target `#submission-status`, so the answer is the alert fragment
/// rather than a whole page.
fn reject(headers: &HeaderMap, message: String) -> Response {
@@ -569,134 +487,34 @@ fn unavailable(headers: &HeaderMap, message: String) -> Response {
mod tests {
use super::*;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| value.to_string()).collect()
fn source() -> Source {
read_source(
Locale::default(),
"\"1\",\"1b\",\"hl\",\"\",\"s\"\n\"x1\",\"x2\",\"value-a\",\"value-b\",\"value-c\"\n",
)
.unwrap()
}
fn destination() -> Destination {
Destination {
table_name: "customers".to_string(),
columns: strings(&["name", "company_number", "active"]),
required: strings(&["name"]),
types: HashMap::new(),
}
}
fn template_rows(step: &Step) -> Vec<(String, bool)> {
match step {
Step::Template(step) => step
.rows
.iter()
.map(|row| (row.name.clone(), row.chosen))
.collect(),
_ => panic!("not the template step"),
}
}
/// The chooser starts in the table's own order, with nothing ticked.
/// Every picker offers the file's columns by name *and* position, because
/// the name is what a person recognises and the position is what is stored.
#[test]
fn the_template_starts_in_table_order() {
let step = template_step(&ImportForm::default(), &destination());
assert_eq!(
template_rows(&step),
vec![
("name".to_string(), false),
("company_number".to_string(), false),
("active".to_string(), false),
]
);
fn a_source_column_is_offered_by_name_and_position() {
let options = source_options(Locale::default(), &source());
assert_eq!(options.len(), 5);
assert_eq!(options[2].position, 3);
assert!(options[2].label.contains("hl"), "{}", options[2].label);
assert!(options[2].label.contains('3'), "{}", options[2].label);
assert_eq!(options[2].example, "value-a");
}
/// Moving a row rearranges the chooser, and the generated header follows
/// the arrangement rather than the table.
/// A file that left a column unnamed still has that column, and it is still
/// selectable — by the one thing it does have.
#[test]
fn moving_a_row_rearranges_the_generated_header() {
let form = ImportForm {
template_order: strings(&["name", "company_number", "active"]),
template_columns: strings(&["name", "active"]),
action: Some("down".to_string()),
index: Some(0),
..ImportForm::default()
};
let step = template_step(&form, &destination());
assert_eq!(
template_rows(&step)
.into_iter()
.map(|(name, _)| name)
.collect::<Vec<_>>(),
strings(&["company_number", "name", "active"])
);
match step {
Step::Template(step) => {
assert_eq!(step.header, "\"name\",\"active\"\n");
assert_eq!(step.chosen, 2);
}
_ => panic!("not the template step"),
}
}
fn an_unnamed_source_column_is_offered_by_position_alone() {
let options = source_options(Locale::default(), &source());
/// A move off either end does nothing rather than wrapping around.
#[test]
fn a_move_past_the_end_is_ignored() {
for (action, index) in [("up", 0), ("down", 2)] {
let form = ImportForm {
template_order: strings(&["name", "company_number", "active"]),
action: Some(action.to_string()),
index: Some(index),
..ImportForm::default()
};
assert_eq!(
template_rows(&template_step(&form, &destination()))
.into_iter()
.map(|(name, _)| name)
.collect::<Vec<_>>(),
strings(&["name", "company_number", "active"])
);
}
}
/// A table changed while the chooser was open: the column it lost leaves
/// the arrangement, and the one it gained joins the end of it.
#[test]
fn the_arrangement_follows_the_table_it_is_for() {
let form = ImportForm {
template_order: strings(&["active", "gone", "name"]),
..ImportForm::default()
};
assert_eq!(
arranged_columns(&form, &destination()),
strings(&["active", "name", "company_number"])
);
}
/// Generating a template with nothing ticked is refused rather than
/// producing an empty header.
#[test]
fn a_template_needs_at_least_one_column() {
assert!(
template_selection(Locale::default(), &ImportForm::default(), &destination()).is_err()
);
}
/// The ignored positions are labelled with the source's own names when the
/// file gave any, which is what makes the summary readable.
#[test]
fn a_header_files_names_are_read_back_for_the_summary() {
let form = ImportForm {
source_mode: "header".to_string(),
csv_data: "\"Company\",\"Internal note\"\n\"Acme\",\"old\"\n".to_string(),
..ImportForm::default()
};
assert_eq!(
source_names(Locale::default(), &form),
strings(&["Company", "Internal note"])
);
// A data-only file has none, and the summary falls back to positions.
let form = ImportForm {
source_mode: "data".to_string(),
..form
};
assert!(source_names(Locale::default(), &form).is_empty());
assert!(options[3].label.contains('4'), "{}", options[3].label);
assert_eq!(options[3].example, "value-b");
}
}

View File

@@ -1,3 +1,4 @@
mod destination;
mod loader;
mod logic;
mod prepare;
@@ -24,6 +25,5 @@ pub(crate) fn router() -> Router<AppState> {
// to look at first. Both read the same preparation.
.route("/admin/import", post(logic::import_csv))
.route("/admin/import/prepared.csv", post(logic::download_prepared))
.route("/admin/import/template.csv", post(logic::download_template))
.layer(DefaultBodyLimit::max(128 * 1024 * 1024))
}

View File

@@ -1,15 +1,19 @@
//! Turning what the user uploaded into the one CSV the import understands.
//! Turning the uploaded file plus the user's mapping into the one CSV the
//! import understands.
//!
//! Everything here is about *position*. A value lands in a column because the
//! user pointed position 3 at `active`, and for no other reason — not because
//! a source header spells something similar, not because the words look alike.
//! The source's own header, when it has one, is read out for orientation and
//! then thrown away.
//! There is one shape of input: a header row, then data rows. The header is
//! read so the user can tell the file's columns apart, and is then done with —
//! it is never imported, and it never decides anything. What decides is the
//! mapping: destination column `a` takes source position 3, because the user
//! said so.
//!
//! Positions, not names, all the way through. A file may perfectly well have
//! two columns called `name`; the page shows them as `name — position 1` and
//! `name — position 2`, and what it stores is 1 and 2.
//!
//! The output is the canonical form: fully quoted CSV whose header names real
//! destination columns, in the order the source presents them. From there the
//! import is the strict one it always was — types, validations, scripts, links
//! and permissions are all the server's, unchanged.
//! destination columns. From there the import is the strict one it always was —
//! types, validations, scripts, links and permissions are all the server's.
use std::collections::HashSet;
@@ -17,210 +21,128 @@ use crate::{i18n::Locale, tr};
use super::super::common::csv::{parse_csv, write_record};
/// How the uploaded text is laid out.
///
/// Always the user's answer, never inferred. A data row can hold words that
/// read exactly like column names — `"name","num"` is a perfectly good pair of
/// customer records — so deciding for them is how a real row gets silently
/// eaten as a header.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum SourceMode {
/// The first row is the source's own header. It labels the mapping rows
/// and is not imported.
#[default]
Header,
/// Every row is data.
Data,
/// No source at all: pick and order destination columns, and take away a
/// header to fill in elsewhere.
Template,
}
impl SourceMode {
pub(crate) fn parse(locale: Locale, raw: &str) -> Result<Self, String> {
match raw {
"header" => Ok(Self::Header),
"data" => Ok(Self::Data),
"template" => Ok(Self::Template),
_ => Err(tr!(locale, "import-err-source-mode")),
}
}
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Header => "header",
Self::Data => "data",
Self::Template => "template",
}
}
/// Whether a source file is needed at all. A template is generated from the
/// table, so there is nothing to upload.
pub(crate) fn reads_a_file(self) -> bool {
!matches!(self, Self::Template)
}
}
/// The source file, split into the header it states and the rows that carry
/// values.
/// The uploaded file: its header, and its data.
#[derive(Debug)]
pub(crate) struct Source {
/// The source's own header row, kept only to label the mapping rows. It is
/// `None` for a data-only file, and it never decides anything.
pub header: Option<Vec<String>>,
/// The names the file gives its own columns. Shown to the user so they can
/// recognise the file; never matched against anything.
pub header: Vec<String>,
pub rows: Vec<Vec<String>>,
/// How many positions every row has. The mapping has exactly this many
/// entries, which is what makes "position 3" mean one thing.
pub width: usize,
}
impl Source {
/// What the first data row holds at each position, for the mapping step to
/// show beside the destination picker. An example is the one thing that
/// reliably tells a user which column they are looking at.
pub(crate) fn examples(&self) -> Vec<String> {
(0..self.width)
.map(|position| {
self.rows
.first()
.and_then(|row| row.get(position))
.cloned()
.unwrap_or_default()
})
.collect()
/// How many positions the file has. Every row has exactly these, which is
/// what makes "position 3" mean one thing.
pub(crate) fn width(&self) -> usize {
self.header.len()
}
/// The source's name for a position, when it has one.
pub(crate) fn source_name(&self, position: usize) -> Option<String> {
self.header
.as_ref()
.and_then(|header| header.get(position))
.cloned()
/// The name the file gives position `index`, empty when it gave none.
pub(crate) fn name(&self, index: usize) -> &str {
self.header.get(index).map(String::as_str).unwrap_or_default()
}
/// What the first data row holds at `index`. An example is the fastest way
/// to tell which column of a file you are looking at.
pub(crate) fn example(&self, index: usize) -> &str {
self.rows
.first()
.and_then(|row| row.get(index))
.map(String::as_str)
.unwrap_or_default()
}
}
/// Reads the uploaded text as `mode` says it is laid out.
pub(crate) fn read_source(locale: Locale, csv: &str, mode: SourceMode) -> Result<Source, String> {
/// Reads the uploaded text: header row, then at least one data row, every row
/// the same width.
pub(crate) fn read_source(locale: Locale, csv: &str) -> Result<Source, String> {
let mut rows = parse_csv(locale, csv)?;
let header = match mode {
SourceMode::Header => {
if rows.len() < 2 {
return Err(tr!(locale, "import-err-no-data-rows"));
}
Some(rows.remove(0))
}
SourceMode::Data => None,
// Nothing to read: the caller decides the columns from the table.
SourceMode::Template => return Err(tr!(locale, "import-err-template-has-no-source")),
};
if rows.is_empty() {
// One row is a header with nothing under it, which is not an import.
if rows.len() < 2 {
return Err(tr!(locale, "import-err-no-data-rows"));
}
let width = header
.as_ref()
.map_or_else(|| rows[0].len(), |header| header.len());
if width == 0 {
let header = rows.remove(0);
if header.is_empty() {
return Err(tr!(locale, "import-err-empty"));
}
// Positions only mean anything if every row has the same ones. A short row
// would otherwise shift every value after the gap into the wrong column.
if let Some(row) = rows.iter().find(|row| row.len() != width) {
if let Some(row) = rows.iter().find(|row| row.len() != header.len()) {
return Err(tr!(
locale,
"import-err-row-width",
"expected" => width as i64,
"expected" => header.len() as i64,
"found" => row.len() as i64,
));
}
Ok(Source { header, rows, width })
Ok(Source { header, rows })
}
/// Where each source position is to be written, `None` being "ignore".
#[derive(Debug)]
pub(crate) struct Mapping(Vec<Option<String>>);
impl Mapping {
/// The destination chosen for a position, for the form to re-render with
/// the user's own answers still selected.
pub(crate) fn target(&self, position: usize) -> Option<&str> {
self.0.get(position).and_then(Option::as_deref)
}
pub(crate) fn len(&self) -> usize {
self.0.len()
}
/// One destination column, and the source position its values come from.
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct Assignment {
/// The destination's current name, which is what the prepared header says.
pub column: String,
/// Index into a source row, from 0. The page numbers positions from 1.
pub source: usize,
}
/// Reads the posted mapping and checks the rules that make "proper data in the
/// proper place" true rather than hoped for.
/// Checks the mapping the user built and reduces it to the assignments that
/// actually write something.
///
/// `writable` is the destination list the table itself declares, loaded again
/// on every step: a column dropped or renamed while the form was open is caught
/// here instead of writing values into a column that no longer means what the
/// user chose.
/// `chosen` is one entry per writable destination column, in the order the
/// mapping step rendered them, `None` meaning "do not import". A destination
/// cannot appear twice because it only ever has one row, so what is left to
/// check is the source side.
pub(crate) fn read_mapping(
locale: Locale,
posted: &[String],
width: usize,
writable: &[String],
) -> Result<Mapping, String> {
// One select per position, always posted, so the form's shape and the
// file's have to agree. They will not if the CSV was edited after the
// mapping was built.
if posted.len() != width {
return Err(tr!(
locale,
"import-err-mapping-stale",
"positions" => width as i64,
"mapped" => posted.len() as i64,
));
}
let known = writable.iter().map(String::as_str).collect::<HashSet<_>>();
chosen: &[(String, Option<usize>)],
source: &Source,
) -> Result<Vec<Assignment>, String> {
let mut used = HashSet::new();
let mut targets = Vec::with_capacity(width);
for (position, target) in posted.iter().enumerate() {
let target = target.trim();
if target.is_empty() {
targets.push(None);
continue;
}
if !known.contains(target) {
let mut assignments = Vec::new();
for (column, position) in chosen {
let Some(index) = position else { continue };
// A position the file does not have — the CSV was edited after the
// mapping was built, or the form was tampered with.
if *index >= source.width() {
return Err(tr!(
locale,
"import-err-mapping-unknown-column",
"column" => target.to_string(),
"position" => (position + 1) as i64,
"import-err-source-position-missing",
"position" => (*index + 1) as i64,
"column" => column.clone(),
"positions" => source.width() as i64,
));
}
// Two positions pointed at one column let whichever is read second
// decide the value, silently. Refused instead.
if !used.insert(target.to_string()) {
// One source column feeding two destinations is more often a misclick
// than an intention, and the page stops offering a position once it is
// taken. Saying so beats copying a value into two columns quietly.
if !used.insert(*index) {
return Err(tr!(
locale,
"import-err-mapping-duplicate",
"column" => target.to_string(),
"import-err-source-used-twice",
"position" => (*index + 1) as i64,
"name" => source.name(*index).to_string(),
));
}
targets.push(Some(target.to_string()));
assignments.push(Assignment {
column: column.clone(),
source: *index,
});
}
if used.is_empty() {
if assignments.is_empty() {
return Err(tr!(locale, "import-err-mapping-empty"));
}
Ok(Mapping(targets))
Ok(assignments)
}
/// The canonical import, and what it leaves behind.
pub(crate) struct Prepared {
/// The destination header, in source-position order — which is the only
/// order the data can be written in, since the values arrive in it.
/// The destination header, in the order the mapping step lists the columns.
pub columns: Vec<String>,
/// Data rows holding only the mapped positions, aligned to `columns`.
/// Data rows holding only the mapped values, aligned to `columns`.
pub rows: Vec<Vec<String>>,
/// The source positions sent nowhere, numbered from 1 as the mapping step
/// numbers them.
/// The source positions no destination takes, from 1.
pub ignored: Vec<usize>,
/// Destination columns this import does not write. The server fills them
/// with their defaults, or refuses the row if it cannot.
@@ -232,8 +154,8 @@ impl Prepared {
self.rows.len()
}
/// The first `limit` rows, for the preview. A preview is for recognising
/// your own data, and nobody recognises it on row 40 000.
/// The first `limit` rows. A preview is for recognising your own data, and
/// nobody recognises it on row 40 000.
pub(crate) fn preview_rows(&self, limit: usize) -> &[Vec<String>] {
&self.rows[..self.rows.len().min(limit)]
}
@@ -243,42 +165,47 @@ impl Prepared {
}
}
/// Applies the mapping: the values that were pointed somewhere, under the names
/// they were pointed at.
pub(crate) fn prepare(mapping: &Mapping, source: &Source, writable: &[String]) -> Prepared {
let taken = (0..mapping.len())
.filter_map(|position| Some((position, mapping.target(position)?.to_string())))
.collect::<Vec<_>>();
let columns = taken
/// Applies the mapping: the values that were asked for, under the names they
/// were asked for.
///
/// `writable` is every destination column the table offers, so the result can
/// say which ones this import leaves alone.
pub(crate) fn prepare(
assignments: &[Assignment],
source: &Source,
writable: &[String],
) -> Prepared {
let columns = assignments
.iter()
.map(|(_, column)| column.clone())
.map(|assignment| assignment.column.clone())
.collect::<Vec<_>>();
let rows = source
.rows
.iter()
.map(|row| {
taken
assignments
.iter()
.map(|(position, _)| row.get(*position).cloned().unwrap_or_default())
.map(|assignment| row.get(assignment.source).cloned().unwrap_or_default())
.collect()
})
.collect();
let ignored = (0..source.width)
.filter(|position| mapping.target(*position).is_none())
.map(|position| position + 1)
.collect();
let omitted = writable
let taken = assignments
.iter()
.filter(|column| !columns.contains(column))
.cloned()
.collect();
.map(|assignment| assignment.source)
.collect::<HashSet<_>>();
Prepared {
ignored: (0..source.width())
.filter(|index| !taken.contains(index))
.map(|index| index + 1)
.collect(),
omitted: writable
.iter()
.filter(|column| !columns.contains(column))
.cloned()
.collect(),
columns,
rows,
ignored,
omitted,
}
}
@@ -294,39 +221,6 @@ pub(crate) fn canonical_csv(prepared: &Prepared) -> String {
csv
}
/// A header and nothing else: the columns the user picked, in the order they
/// arranged them, ready to be filled in elsewhere and brought back as a file
/// with a header.
pub(crate) fn template_csv(columns: &[String]) -> String {
let mut csv = String::new();
write_record(&mut csv, columns);
csv
}
/// The destination a position starts out pointed at when the mapping step first
/// opens.
///
/// Only an exact match counts, and only against a real destination column. That
/// is not name interpretation: a header cell that *is* the column's name is the
/// column's name, which is the case whenever the file came from this system's
/// own template or export. Anything else starts at "Ignore", and every row is
/// in front of the user to confirm or change before a single value moves.
pub(crate) fn suggest_mapping(source: &Source, writable: &[String]) -> Vec<String> {
let mut used = HashSet::new();
(0..source.width)
.map(|position| {
let Some(name) = source.source_name(position) else {
return String::new();
};
if writable.contains(&name) && used.insert(name.clone()) {
name
} else {
String::new()
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -335,101 +229,94 @@ mod tests {
values.iter().map(|value| value.to_string()).collect()
}
fn writable() -> Vec<String> {
strings(&["name", "company_number", "active", "note"])
/// The worked example: six source columns, three of them wanted.
fn example() -> Source {
read_source(
Locale::default(),
"\"1\",\"1b\",\"hl\",\"he\",\"s\",\"aa\"\n\
\"x1\",\"x2\",\"value-a\",\"value-b\",\"value-c\",\"unused\"\n\
\"y1\",\"y2\",\"other-a\",\"other-b\",\"other-c\",\"unused\"\n",
)
.unwrap()
}
/// A data-only file is all rows, and nothing in it is read as a header —
/// even when the first row happens to hold the column names.
#[test]
fn a_data_only_file_keeps_its_first_row() {
let csv = "\"name\",\"num\"\n\"Acme\",\"12345678\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap();
assert!(source.header.is_none());
fn the_first_row_is_the_header_and_the_rest_is_data() {
let source = example();
assert_eq!(source.width(), 6);
assert_eq!(source.rows.len(), 2);
assert_eq!(source.examples(), strings(&["name", "num"]));
assert_eq!(source.name(2), "hl");
assert_eq!(source.example(2), "value-a");
}
/// A file declared to have a header loses its first row, and that row is
/// only ever shown back to the user.
#[test]
fn a_header_file_loses_its_first_row_to_labels() {
let csv = "\"Company\",\"Registration number\"\n\"Acme\",\"12345678\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
assert_eq!(source.rows.len(), 1);
assert_eq!(source.source_name(0).as_deref(), Some("Company"));
assert_eq!(source.examples(), strings(&["Acme", "12345678"]));
}
/// A header with no rows under it is not an import, and says so rather than
/// A header with nothing under it is not an import, and says so rather than
/// preparing an empty one.
#[test]
fn a_header_with_nothing_under_it_is_refused() {
let csv = "\"Company\",\"Registration number\"\n";
assert!(read_source(Locale::default(), csv, SourceMode::Header).is_err());
assert!(read_source(Locale::default(), "\"a\",\"b\"\n").is_err());
}
/// Positions only mean something if every row has the same ones.
#[test]
fn rows_of_different_widths_are_refused() {
let csv = "\"Acme\",\"12345678\"\n\"Example\"\n";
let error = read_source(Locale::default(), csv, SourceMode::Data)
fn rows_of_a_different_width_than_the_header_are_refused() {
let error = read_source(Locale::default(), "\"a\",\"b\"\n\"only-one\"\n")
.expect_err("a short row shifts every value after it");
assert!(error.contains('2') && error.contains('1'), "{error}");
}
/// The heart of it: the user's choices decide where values go, source names
/// are irrelevant, and an ignored position takes its value nowhere.
/// The whole point, end to end: three destinations take three positions,
/// the other three source columns go nowhere.
#[test]
fn values_land_where_the_user_pointed_them() {
let csv = "\"whatever\",\"old field 7\",\"enabled value\",\"legacy\"\n\
\"Acme\",\"12345678\",\"true\",\"drop me\"\n\
\"Example\",\"87654321\",\"false\",\"drop me too\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
let mapping = read_mapping(
fn only_the_mapped_destinations_are_prepared() {
let source = example();
let writable = strings(&["a", "b", "c", "note"]);
let assignments = read_mapping(
Locale::default(),
&strings(&["name", "company_number", "active", ""]),
source.width,
&writable(),
&[
("a".to_string(), Some(2)),
("b".to_string(), Some(3)),
("c".to_string(), Some(4)),
("note".to_string(), None),
],
&source,
)
.unwrap();
let prepared = prepare(&mapping, &source, &writable());
let prepared = prepare(&assignments, &source, &writable);
assert_eq!(
prepared.columns,
strings(&["name", "company_number", "active"])
);
assert_eq!(prepared.rows[0], strings(&["Acme", "12345678", "true"]));
assert_eq!(prepared.ignored, vec![4]);
assert_eq!(prepared.columns, strings(&["a", "b", "c"]));
assert_eq!(prepared.ignored, vec![1, 2, 6]);
assert_eq!(prepared.omitted, strings(&["note"]));
assert_eq!(
canonical_csv(&prepared),
"\"name\",\"company_number\",\"active\"\n\
\"Acme\",\"12345678\",\"true\"\n\
\"Example\",\"87654321\",\"false\"\n"
"\"a\",\"b\",\"c\"\n\
\"value-a\",\"value-b\",\"value-c\"\n\
\"other-a\",\"other-b\",\"other-c\"\n"
);
}
/// Out-of-order and sparse mappings are the normal case, not an edge one:
/// the header follows the source's positions, because the values do.
/// Destinations may take their values in any order, and out of order is the
/// normal case — the prepared header follows the destination rows, and each
/// row's values follow it.
#[test]
fn the_prepared_header_follows_the_source_order() {
let csv = "\"Acme\",\"unused legacy value\",\"true\",\"12345678\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap();
let mapping = read_mapping(
fn a_destination_may_take_any_position() {
let source = example();
let assignments = read_mapping(
Locale::default(),
&strings(&["name", "", "active", "company_number"]),
source.width,
&writable(),
&[
("a".to_string(), Some(5)),
("b".to_string(), None),
("c".to_string(), Some(0)),
],
&source,
)
.unwrap();
let prepared = prepare(&mapping, &source, &writable());
let prepared = prepare(&assignments, &source, &strings(&["a", "b", "c"]));
assert_eq!(
canonical_csv(&prepared),
"\"name\",\"active\",\"company_number\"\n\"Acme\",\"true\",\"12345678\"\n"
"\"a\",\"c\"\n\"unused\",\"x1\"\n\"unused\",\"y1\"\n"
);
assert_eq!(prepared.ignored, vec![2]);
assert_eq!(prepared.omitted, strings(&["b"]));
}
/// What is downloaded and what is imported are the same file. The import
@@ -437,129 +324,79 @@ mod tests {
/// that the two cannot drift apart.
#[test]
fn the_downloaded_csv_parses_back_to_the_rows_that_get_imported() {
let csv = "\"Acme, s.r.o.\",\"said \"\"yes\"\"\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap();
let mapping = read_mapping(
let source = read_source(
Locale::default(),
&strings(&["name", "note"]),
source.width,
&writable(),
"\"name\",\"note\"\n\"Acme, s.r.o.\",\"said \"\"yes\"\"\"\n",
)
.unwrap();
let prepared = prepare(&mapping, &source, &writable());
let assignments = read_mapping(
Locale::default(),
&[("name".to_string(), Some(0)), ("note".to_string(), Some(1))],
&source,
)
.unwrap();
let prepared = prepare(&assignments, &source, &strings(&["name", "note"]));
let reparsed = parse_csv(Locale::default(), &canonical_csv(&prepared)).unwrap();
assert_eq!(reparsed[0], prepared.columns);
assert_eq!(reparsed[1..], prepared.rows[..]);
}
/// One destination cannot be filled from two positions: the second would
/// silently win.
/// A file may name two columns the same. Nothing here cares: the mapping is
/// positions, and the two are 1 and 2.
#[test]
fn a_destination_cannot_be_chosen_twice() {
let error = read_mapping(
fn duplicate_source_names_are_not_ambiguous() {
let source = read_source(
Locale::default(),
&strings(&["name", "name"]),
2,
&writable(),
)
.expect_err("two positions cannot both be `name`");
assert!(error.contains("name"), "{error}");
}
/// A destination that is not a writable column of this table is refused,
/// whatever the form posted — the table is asked again on every step.
#[test]
fn a_destination_the_table_does_not_offer_is_refused() {
let error = read_mapping(
Locale::default(),
&strings(&["name", "id"]),
2,
&writable(),
)
.expect_err("`id` is the server's");
assert!(error.contains("id"), "{error}");
}
/// A mapping built against a different file is refused rather than applied
/// to whatever positions happen to line up.
#[test]
fn a_mapping_that_does_not_cover_the_file_is_refused() {
assert!(read_mapping(Locale::default(), &strings(&["name"]), 3, &writable()).is_err());
}
/// Ignoring everything is not an import.
#[test]
fn a_mapping_that_writes_nothing_is_refused() {
assert!(read_mapping(Locale::default(), &strings(&["", ""]), 2, &writable()).is_err());
}
/// A file this system generated the template for comes back with its own
/// column names, so every row starts out pointed at the right place — still
/// shown, still confirmed, never applied on its own.
#[test]
fn an_exact_header_starts_the_mapping_off() {
let csv = "\"name\",\"active\"\n\"Acme\",\"true\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
assert_eq!(suggest_mapping(&source, &writable()), strings(&["name", "active"]));
}
/// Anything that is not exactly a column name starts at "Ignore". There is
/// no folding, no trimming and no similarity: ` name` and `Name` are not
/// the column `name`, and "Company" is not a guess anyone should make.
#[test]
fn nothing_but_an_exact_name_is_suggested() {
let csv = "\"Company\",\" name\",\"NAME\",\"note\"\n\"Acme\",\"x\",\"y\",\"z\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
assert_eq!(
suggest_mapping(&source, &writable()),
strings(&["", "", "", "note"])
);
}
/// A header naming one column twice suggests it once: the second would be
/// a duplicate the mapping step refuses anyway.
#[test]
fn a_repeated_header_is_only_suggested_once() {
let csv = "\"name\",\"name\"\n\"Acme\",\"Other\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
assert_eq!(suggest_mapping(&source, &writable()), strings(&["name", ""]));
}
/// The generated template is a header and nothing else, in the order it was
/// arranged in.
#[test]
fn a_template_is_the_chosen_columns_in_the_chosen_order() {
assert_eq!(
template_csv(&strings(&["active", "name", "company_number"])),
"\"active\",\"name\",\"company_number\"\n"
);
}
/// And it is a file this page can read straight back: the header mode plus
/// the exact-name suggestion close the loop.
#[test]
fn a_generated_template_comes_back_ready_to_import() {
let mut csv = template_csv(&strings(&["name", "active"]));
csv.push_str("\"Acme\",\"true\"\n");
let source = read_source(Locale::default(), &csv, SourceMode::Header).unwrap();
let mapping = read_mapping(
Locale::default(),
&suggest_mapping(&source, &writable()),
source.width,
&writable(),
"\"name\",\"name\",\"status\"\n\"first\",\"second\",\"live\"\n",
)
.unwrap();
let prepared = prepare(&mapping, &source, &writable());
assert_eq!(prepared.columns, strings(&["name", "active"]));
assert_eq!(prepared.rows, vec![strings(&["Acme", "true"])]);
let assignments = read_mapping(
Locale::default(),
&[("a".to_string(), Some(1)), ("b".to_string(), Some(0))],
&source,
)
.unwrap();
let prepared = prepare(&assignments, &source, &strings(&["a", "b"]));
assert_eq!(prepared.rows, vec![strings(&["second", "first"])]);
}
/// One source column feeding two destinations is refused: the page stops
/// offering a position once it is taken, so arriving here means something
/// went wrong rather than that the user meant it.
#[test]
fn a_source_mode_round_trips_and_an_unknown_one_is_refused() {
for mode in [SourceMode::Header, SourceMode::Data, SourceMode::Template] {
assert_eq!(SourceMode::parse(Locale::default(), mode.as_str()), Ok(mode));
}
assert!(SourceMode::parse(Locale::default(), "guess").is_err());
fn a_source_position_cannot_be_used_twice() {
let source = example();
let error = read_mapping(
Locale::default(),
&[("a".to_string(), Some(2)), ("b".to_string(), Some(2))],
&source,
)
.expect_err("position 3 cannot fill both");
assert!(error.contains("hl") && error.contains('3'), "{error}");
}
/// A position the file does not have is refused rather than read as blank.
#[test]
fn a_position_past_the_end_of_the_file_is_refused() {
let source = example();
let error = read_mapping(Locale::default(), &[("a".to_string(), Some(9))], &source)
.expect_err("the file has six positions");
assert!(error.contains("10") && error.contains('6'), "{error}");
}
/// Mapping nothing is not an import.
#[test]
fn a_mapping_that_writes_nothing_is_refused() {
let source = example();
assert!(
read_mapping(
Locale::default(),
&[("a".to_string(), None), ("b".to_string(), None)],
&source
)
.is_err()
);
}
}

View File

@@ -1,54 +1,37 @@
use crate::{i18n::Locale, tr};
use super::prepare::SourceMode;
/// Every field the import form carries, at every step.
///
/// The whole preparation is one form posted back and forth: the page keeps no
/// server-side session, so each step re-renders the fields the next one needs
/// and the user can go back without losing what they chose.
/// server-side draft, so each step re-states the answers the next one needs and
/// the user can go back without losing what they chose.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct ImportForm {
#[serde(default)]
pub profile_name: String,
/// One table. An import writes into one table, and a file for two tables is
/// two prepared imports rather than one file with a second header row.
/// two prepared imports.
#[serde(default)]
pub table_name: String,
/// `header`, `data` or `template` — see [`SourceMode`]. Kept as text so an
/// unrecognised value is a message rather than a form rejection.
#[serde(default)]
pub source_mode: String,
/// The uploaded file, header row and all.
#[serde(default)]
pub csv_data: String,
/// The destination chosen for each source position, in position order, one
/// entry per position; empty means "ignore". Posted once per `<select>`,
/// which only `axum_extra`'s `Form` decodes into a `Vec`.
/// One entry per writable destination column, in the order the mapping step
/// renders them: the column's stable identity, not its name. See
/// [`DestinationKey`](super::destination::DestinationKey).
///
/// Posted once per row, which only `axum_extra`'s `Form` decodes into a
/// `Vec`.
#[serde(default)]
pub mapping: Vec<String>,
/// Every destination column in the order the template step has them
/// arranged, chosen or not, so the arrangement survives a re-render.
pub destination: Vec<String>,
/// The source position chosen for the destination at the same index, from
/// 1; empty means "do not import". The two vectors are filled in document
/// order, so they line up row for row.
#[serde(default)]
pub template_order: Vec<String>,
/// The columns ticked in the template step, which the checkboxes post in
/// the arranged order — so this *is* the generated header.
#[serde(default)]
pub template_columns: Vec<String>,
/// `up` or `down` when a move button posted, with `index` naming the row.
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub index: Option<usize>,
pub source_position: Vec<String>,
}
impl ImportForm {
pub(crate) fn mode(&self, locale: Locale) -> Result<SourceMode, String> {
if self.source_mode.is_empty() {
return Ok(SourceMode::default());
}
SourceMode::parse(locale, &self.source_mode)
}
/// The scope and table this import writes into, checked for being answered
/// at all. Whether they are the user's to write into is the catalog's
/// answer, and then the backend's.
@@ -61,7 +44,7 @@ impl ImportForm {
if table.is_empty() {
return Err(tr!(locale, "import-err-tables-required"));
}
if self.mode(locale)?.reads_a_file() && self.csv_data.trim().is_empty() {
if self.csv_data.trim().is_empty() {
return Err(tr!(locale, "import-err-csv-required"));
}
Ok((profile.to_string(), table.to_string()))
@@ -72,81 +55,80 @@ impl ImportForm {
self.table_name == name
}
/// Whether the radio for `mode` is the one selected, for re-rendering the
/// choice the user made.
pub(crate) fn is_mode(&self, mode: &str) -> bool {
if self.source_mode.is_empty() {
return mode == SourceMode::default().as_str();
}
self.source_mode == mode
/// The mapping as posted: the destination key of each row, and the source
/// position it was given.
///
/// A row whose position is blank, unparseable or zero is "do not import".
/// Positions are one-based on the page and zero-based here, since what they
/// index is a row of the file.
pub(crate) fn rows(&self) -> Vec<(&str, Option<usize>)> {
self.destination
.iter()
.enumerate()
.map(|(index, key)| {
let position = self
.source_position
.get(index)
.and_then(|raw| raw.trim().parse::<usize>().ok())
.and_then(|position| position.checked_sub(1));
(key.as_str(), position)
})
.collect()
}
}
/// Which of the preparation's steps the page is showing.
///
/// One page, three stops: say what the source is, say where each position goes,
/// then look at the result before anything is written.
/// One page, three stops: say where the data is going and hand over the file,
/// say where each destination column's value comes from, then look at the
/// result before anything is written.
pub(crate) enum Step {
/// Choose the destination table and describe the source.
Source,
/// Point each source position at a destination column.
Mapping(MappingStep),
/// Pick and arrange destination columns, and take away the header.
Template(TemplateStep),
/// The prepared import, as it will be sent.
Preview(PreviewStep),
}
pub(crate) struct MappingStep {
pub table_name: String,
/// The destinations to offer, in the order the table declares them.
pub columns: Vec<String>,
/// One row per writable destination column. A destination cannot be filled
/// twice because it appears exactly once.
pub rows: Vec<MappingRow>,
/// The file's columns, as they are offered in every picker.
pub sources: Vec<SourceOption>,
pub source_rows: usize,
/// Whether the source stated a header, so the table can show that column at
/// all — and say that it decides nothing.
pub has_source_names: bool,
}
/// One source position, and what the user has pointed it at.
/// One destination column, asking where its value comes from.
pub(crate) struct MappingRow {
/// Numbered from 1, the way the source is read by a person.
pub position: usize,
/// What the source called this position, when it said. Orientation only.
pub source_name: Option<String>,
/// The first data row's value here — usually the fastest way to recognise
/// which column this is.
/// The stable identity, for the hidden field.
pub key: String,
/// The name the table shows, and the name the prepared header will use.
pub name: String,
pub required: bool,
/// The chosen source position as the form carries it — one-based, empty for
/// "do not import".
pub chosen: String,
/// The first data row's value at the chosen position, so the row shows what
/// it is actually going to import.
pub example: String,
/// The chosen destination, empty for "ignore".
pub target: String,
}
impl MappingRow {
/// Whether this position is pointed at `column`, for re-rendering the
/// picker with the user's own answer selected.
pub(crate) fn targets(&self, column: &str) -> bool {
self.target == column
/// Whether this row takes `option`'s position, for re-rendering the picker
/// with the user's own answer selected.
pub(crate) fn takes(&self, option: &SourceOption) -> bool {
self.chosen == option.position.to_string()
}
}
pub(crate) struct TemplateStep {
pub table_name: String,
pub rows: Vec<TemplateRow>,
/// The generated header as it stands, empty when nothing is ticked.
pub header: String,
pub chosen: usize,
}
pub(crate) struct TemplateRow {
/// Position in the arrangement, from 0, for the move buttons.
pub index: usize,
pub name: String,
pub chosen: bool,
/// Declared `NOT NULL`, so leaving it out of the template is likely to
/// produce rows the server refuses.
pub required: bool,
pub first: bool,
pub last: bool,
/// One column of the uploaded file, as the pickers offer it.
pub(crate) struct SourceOption {
/// One-based, which is how the page counts and how the form posts.
pub position: usize,
/// `hl — position 3`, or just the position when the file left the name
/// blank. Built in Rust so it is translated once.
pub label: String,
pub example: String,
}
pub(crate) struct PreviewStep {
@@ -156,8 +138,8 @@ pub(crate) struct PreviewStep {
/// Rows the preview does not show, so a large file says how large.
pub hidden_rows: usize,
pub source_rows: usize,
/// Source positions written nowhere, labelled with the source's own name
/// when it gave one.
/// The file's columns no destination takes, labelled the way the pickers
/// labelled them.
pub ignored: Vec<String>,
/// Destination columns this import does not write.
pub omitted: Vec<String>,
@@ -180,35 +162,84 @@ pub(crate) struct ImportPageState {
mod tests {
use super::*;
/// A template needs no file, so the form does not insist on one — and the
/// other two modes do.
#[test]
fn only_a_file_import_needs_a_file() {
let form = ImportForm {
fn a_target_needs_a_scope_a_table_and_a_file() {
let complete = ImportForm {
profile_name: "acme".to_string(),
table_name: "customers".to_string(),
source_mode: "template".to_string(),
csv_data: "\"a\"\n\"1\"\n".to_string(),
..ImportForm::default()
};
assert_eq!(
form.target(Locale::default()).unwrap(),
complete.target(Locale::default()).unwrap(),
("acme".to_string(), "customers".to_string())
);
let form = ImportForm {
source_mode: "data".to_string(),
..form
};
assert!(form.target(Locale::default()).is_err());
for missing in [
ImportForm {
profile_name: String::new(),
..complete.clone()
},
ImportForm {
table_name: String::new(),
..complete.clone()
},
ImportForm {
csv_data: " ".to_string(),
..complete.clone()
},
] {
assert!(missing.target(Locale::default()).is_err());
}
}
/// The default is a file with a header, and the radio renders as such
/// before anything has been posted.
/// The two posted vectors line up row for row, and the page's one-based
/// positions become the zero-based indexes a row is read with.
#[test]
fn the_source_mode_defaults_to_a_header_file() {
let form = ImportForm::default();
assert_eq!(form.mode(Locale::default()).unwrap(), SourceMode::Header);
assert!(form.is_mode("header"));
assert!(!form.is_mode("data"));
fn the_posted_rows_pair_up_and_lose_their_offset() {
let form = ImportForm {
destination: vec![
"id:42".to_string(),
"id:57".to_string(),
"system:deleted".to_string(),
],
source_position: vec!["3".to_string(), String::new(), "1".to_string()],
..ImportForm::default()
};
assert_eq!(
form.rows(),
vec![
("id:42", Some(2)),
("id:57", None),
("system:deleted", Some(0)),
]
);
}
/// Anything that is not a position is "do not import" rather than a guess.
/// Zero is not a position either: the page counts from 1.
#[test]
fn an_unusable_position_means_do_not_import() {
let form = ImportForm {
destination: vec!["id:1".to_string(), "id:2".to_string(), "id:3".to_string()],
source_position: vec!["0".to_string(), "x".to_string(), "-1".to_string()],
..ImportForm::default()
};
assert!(form.rows().iter().all(|(_, position)| position.is_none()));
}
/// A row with no position posted at all is still a row, so the pairing
/// cannot slide by one.
#[test]
fn a_missing_position_does_not_shift_the_rows() {
let form = ImportForm {
destination: vec!["id:1".to_string(), "id:2".to_string()],
source_position: vec!["2".to_string()],
..ImportForm::default()
};
assert_eq!(form.rows(), vec![("id:1", Some(1)), ("id:2", None)]);
}
}

View File

@@ -74,7 +74,7 @@ mod tests {
use crate::pages::import_export::common::loader::{Catalog, Profile};
use super::super::state::{
ImportForm, MappingRow, MappingStep, PreviewStep, TemplateRow, TemplateStep,
ImportForm, MappingRow, MappingStep, PreviewStep, SourceOption,
};
fn strings(values: &[&str]) -> Vec<String> {
@@ -99,127 +99,113 @@ mod tests {
form: ImportForm {
profile_name: "acme".to_string(),
table_name: "customers".to_string(),
source_mode: "header".to_string(),
csv_data: "\"Company\"\n\"Acme\"\n".to_string(),
mapping: strings(&["name"]),
..ImportForm::default()
csv_data: "\"hl\",\"he\"\n\"value-a\",\"value-b\"\n".to_string(),
destination: strings(&["id:42", "id:57"]),
source_position: strings(&["1", ""]),
},
step,
}
}
/// The first step asks where the data goes and how the source is laid out,
/// and asks the second question outright — the page never decides for
/// itself whether the first row is a header.
fn mapping() -> Step {
Step::Mapping(MappingStep {
table_name: "customers".to_string(),
rows: vec![
MappingRow {
key: "id:42".to_string(),
name: "a".to_string(),
required: true,
chosen: "1".to_string(),
example: "value-a".to_string(),
},
MappingRow {
key: "id:57".to_string(),
name: "b".to_string(),
required: false,
chosen: String::new(),
example: String::new(),
},
],
sources: vec![
SourceOption {
position: 1,
label: "hl \u{2014} position 1".to_string(),
example: "value-a".to_string(),
},
SourceOption {
position: 2,
label: "he \u{2014} position 2".to_string(),
example: "value-b".to_string(),
},
],
source_rows: 1,
})
}
/// The first step asks for a destination and a file, and nothing else —
/// there is one shape of input, so there is nothing to choose about it.
#[test]
fn the_first_step_asks_how_the_source_is_laid_out() {
fn the_first_step_asks_for_a_table_and_a_file() {
let html = render_page(&page(Step::Source));
for mode in ["header", "data", "template"] {
assert!(
html.contains(&format!(r#"name="source_mode" value="{mode}""#)),
"{html}"
);
}
assert!(html.contains(r#"name="table_name""#), "{html}");
assert!(html.contains(r#"name="csv_data""#), "{html}");
assert!(!html.contains("source_mode"), "{html}");
}
/// The mapping step is one picker per source position, and every picker
/// offers Ignore alongside the table's own columns. The source's own name
/// is shown next to it and is not what any option is built from.
/// The mapping is read destination-first: one row per column of the table,
/// each asking where its value comes from. A destination cannot be filled
/// twice because it appears exactly once.
#[test]
fn the_mapping_step_offers_a_destination_for_every_position() {
let html = render_step(&page(Step::Mapping(MappingStep {
table_name: "customers".to_string(),
columns: strings(&["name", "company_number"]),
rows: vec![
MappingRow {
position: 1,
source_name: Some("Company".to_string()),
example: "Acme".to_string(),
target: "name".to_string(),
},
MappingRow {
position: 2,
source_name: Some("Internal note".to_string()),
example: "old customer".to_string(),
target: String::new(),
},
],
source_rows: 2,
has_source_names: true,
})));
fn the_mapping_step_asks_every_destination_column_once() {
let html = render_step(&page(mapping()));
assert_eq!(html.matches(r#"name="mapping""#).count(), 2);
assert!(html.contains(r#"<option value="name" selected>"#), "{html}");
assert!(html.contains("Internal note"), "{html}");
assert!(html.contains("old customer"), "{html}");
// The destination and the source it came from are carried forward, so
// going back does not lose the file.
assert!(html.contains(r#"name="csv_data""#), "{html}");
assert_eq!(html.matches(r#"name="source_position""#).count(), 2);
assert_eq!(html.matches(r#"name="destination""#).count(), 2);
assert!(html.contains("<code>a</code>"), "{html}");
assert!(html.contains("<code>b</code>"), "{html}");
// The file's columns are offered by name and position together.
assert!(html.contains("hl \u{2014} position 1"), "{html}");
assert!(html.contains(r#"<option value="1" data-example="value-a" selected>"#), "{html}");
// Nothing is mapped unless the user said so.
assert!(html.contains(">Do not import</option>"), "{html}");
}
/// The template step is a chooser with an arrangement, and it shows the
/// header it would generate rather than describing it.
/// The destination travels as its stable identity, never as its name, so a
/// rename between the mapping and the import moves with the column.
#[test]
fn the_template_step_shows_the_header_it_generates() {
let html = render_step(&page(Step::Template(TemplateStep {
table_name: "customers".to_string(),
rows: vec![
TemplateRow {
index: 0,
name: "name".to_string(),
chosen: true,
required: true,
first: true,
last: false,
},
TemplateRow {
index: 1,
name: "note".to_string(),
chosen: false,
required: false,
first: false,
last: true,
},
],
header: "\"name\"\n".to_string(),
chosen: 1,
})));
fn a_destination_is_carried_as_its_identity() {
let html = render_step(&page(mapping()));
assert!(html.contains("&#34;name&#34;"), "{html}");
assert_eq!(html.matches(r#"name="template_order""#).count(), 2);
assert!(html.contains(r#"formaction="/admin/import/template.csv""#), "{html}");
// The ends of the arrangement have nowhere to move to.
assert_eq!(html.matches("disabled").count(), 2);
assert!(html.contains(r#"name="destination" value="id:42""#), "{html}");
assert!(!html.contains(r#"name="destination" value="a""#), "{html}");
}
/// The preview shows the values under the columns they will actually be
/// written to, says what is being left out, and offers both exits.
/// The preview shows the values under the columns they will be written to,
/// says what is being left out, and offers both exits.
#[test]
fn the_preview_shows_the_prepared_import_and_both_ways_out() {
let html = render_step(&page(Step::Preview(PreviewStep {
table_name: "customers".to_string(),
columns: strings(&["name", "active"]),
rows: vec![strings(&["Acme", "true"])],
columns: strings(&["a", "b", "c"]),
rows: vec![strings(&["value-a", "value-b", "value-c"])],
hidden_rows: 3,
source_rows: 4,
ignored: strings(&["2 (Internal note)"]),
ignored: strings(&["1 \u{2014} position 1", "aa \u{2014} position 6"]),
omitted: strings(&["note"]),
missing_required: strings(&["company_number"]),
csv: "\"name\",\"active\"\n\"Acme\",\"true\"\n".to_string(),
missing_required: strings(&["number"]),
csv: "\"a\",\"b\",\"c\"\n".to_string(),
})));
assert!(html.contains("<th>name</th>"), "{html}");
assert!(html.contains("<td>Acme</td>"), "{html}");
assert!(html.contains("2 (Internal note)"), "{html}");
assert!(html.contains("company_number"), "{html}");
// Import, and download the very same file.
assert!(html.contains("<th>a</th>"), "{html}");
assert!(html.contains("<td>value-a</td>"), "{html}");
assert!(html.contains("aa \u{2014} position 6"), "{html}");
assert!(html.contains("number"), "{html}");
assert!(html.contains(r#"hx-post="/admin/import""#), "{html}");
assert!(html.contains("&#34;name&#34;,&#34;active&#34;"), "{html}");
assert!(html.contains("&#34;a&#34;,&#34;b&#34;,&#34;c&#34;"), "{html}");
// The mapping travels with it, so the download and the import prepare
// the identical file.
assert!(html.contains(r#"name="mapping" value="name""#), "{html}");
assert!(html.contains(r#"name="destination" value="id:42""#), "{html}");
assert!(html.contains(r#"name="source_position" value="1""#), "{html}");
}
}