The learned demosaic was an option under Detail, off by default. It is now how a Bayer raw is developed: on by default at full strength on every device — which hardware runs it is the inference engine's choice — and first in the Adjust panel, since it decides what every control below is applied to. Strength (0-100, default 100) replaces Keep grain: grain = 100 - strength, the same luminance-only blend, so moving it is one GPU pass and never a re-run. 0.21.0's sidecars stored grain; it is still read, as the inverse, and never written. With it on for every photograph, the result is now kept on disk (denoise.md §7.1, §12): the network's output as half floats, keyed on a SHA-256 of the file's bytes and the model, oldest first past a 5 GB budget, beside the inference engine's cache. A reopened photograph and an export of one already developed read it back instead of running the network again; a damaged entry is a miss.
479 lines
22 KiB
Rust
479 lines
22 KiB
Rust
//! Localisation keys to display strings.
|
|
//!
|
|
//! The core deals only in [`LocalizedKey`]s — resolving one needs a
|
|
//! localiser, and `core/` must not depend on a localisation library
|
|
//! (ARCH §3.3, NFR-A11Y-1). This is the UI's catalogue.
|
|
//!
|
|
//! **Unknown keys resolve to something readable rather than empty.** A new
|
|
//! operation added to the pipeline appears in the interface immediately, with
|
|
//! a derived label, before anyone writes a translation for it. That is the
|
|
//! behaviour FR-DEV-3c promises: adding an operation needs no UI change.
|
|
|
|
/// TRACES: FR-DEV-5
|
|
/// The history steps this interface records that no descriptor can name.
|
|
///
|
|
/// Constants rather than string literals at the call sites, and [`ALL`] rather
|
|
/// than a list written out again in a test. A key spelled one way where the
|
|
/// step is recorded and another way where it is catalogued resolves through
|
|
/// `derive` to something *plausible* — "Mask Toggled" — so the mistake does
|
|
/// not look like one. Naming each key once removes the opportunity.
|
|
///
|
|
/// A step that moved a parameter or an operation is deliberately not here: it
|
|
/// is named out of the descriptor, through the same `op.` and `param.` entries
|
|
/// the develop panel resolves. That is what lets an operation added as a YAML
|
|
/// declaration appear in the history correctly named with nothing written for
|
|
/// it here (FR-DEV-3c).
|
|
pub mod step {
|
|
use dr_pipeline::LocalizedKey;
|
|
|
|
pub const PASTE: LocalizedKey = LocalizedKey("history.paste");
|
|
/// TRACES: FR-DEV-6
|
|
/// The edit a photograph already carried, brought across on first opening.
|
|
pub const EARLIER_EDIT: LocalizedKey = LocalizedKey("history.earlier_edit");
|
|
pub const FILM: LocalizedKey = LocalizedKey("history.film");
|
|
/// TRACES: FR-DEV-5
|
|
/// The photograph put back to a named snapshot, as one step.
|
|
pub const SNAPSHOT_RESTORED: LocalizedKey = LocalizedKey("history.snapshot_restored");
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// A neutral was picked off the photograph, setting the white balance.
|
|
///
|
|
/// Named for what the photographer did rather than for the parameters it
|
|
/// moved: two of them changed, and "Temperature" beside "Tint" in the list
|
|
/// would describe the mechanism rather than the decision.
|
|
pub const SAMPLED_NEUTRAL: LocalizedKey = LocalizedKey("history.sampled_neutral");
|
|
|
|
pub const RESET_ALL: LocalizedKey = LocalizedKey("history.reset_all");
|
|
pub const RESET_OP: LocalizedKey = LocalizedKey("history.reset_op");
|
|
pub const RESET_PARAM: LocalizedKey = LocalizedKey("history.reset_param");
|
|
pub const RESET_FRAMING: LocalizedKey = LocalizedKey("history.reset_framing");
|
|
|
|
pub const ROTATE: LocalizedKey = LocalizedKey("history.rotate");
|
|
pub const FLIP_H: LocalizedKey = LocalizedKey("history.flip_h");
|
|
pub const FLIP_V: LocalizedKey = LocalizedKey("history.flip_v");
|
|
|
|
pub const MASK_ADDED: LocalizedKey = LocalizedKey("history.mask_added");
|
|
pub const MASK_REMOVED: LocalizedKey = LocalizedKey("history.mask_removed");
|
|
pub const MASK_TOGGLED: LocalizedKey = LocalizedKey("history.mask_toggled");
|
|
pub const MASK_INVERTED: LocalizedKey = LocalizedKey("history.mask_inverted");
|
|
pub const MASK_MOVED: LocalizedKey = LocalizedKey("history.mask_moved");
|
|
pub const MASK_FEATHER: LocalizedKey = LocalizedKey("history.mask_feather");
|
|
pub const MASK_MORPH: LocalizedKey = LocalizedKey("history.mask_morph");
|
|
pub const MASK_OPACITY: LocalizedKey = LocalizedKey("history.mask_opacity");
|
|
pub const MASK_FALLOFF: LocalizedKey = LocalizedKey("history.mask_falloff");
|
|
pub const MASK_MORPHOLOGY: LocalizedKey = LocalizedKey("history.mask_morphology");
|
|
pub const MASK_REFINE: LocalizedKey = LocalizedKey("history.mask_refine");
|
|
/// TRACES: FR-DEV-10
|
|
/// A range mask's band moved — its bounds, its softness, or its hue arc.
|
|
pub const MASK_RANGE: LocalizedKey = LocalizedKey("history.mask_range");
|
|
|
|
/// TRACES: FR-DEV-19b
|
|
/// A stroke, named for what the hand did rather than for the part it
|
|
/// landed in: a photographer takes back the mark they made, and which
|
|
/// selection was holding it is not what they were thinking about.
|
|
pub const MASK_PAINTED: LocalizedKey = LocalizedKey("history.mask_painted");
|
|
pub const MASK_ERASED: LocalizedKey = LocalizedKey("history.mask_erased");
|
|
/// TRACES: FR-DEV-19a
|
|
/// A selection joined to a mask, taken out of it, or joined the other way.
|
|
pub const MASK_PART_ADDED: LocalizedKey = LocalizedKey("history.mask_part_added");
|
|
pub const MASK_PART_REMOVED: LocalizedKey = LocalizedKey("history.mask_part_removed");
|
|
pub const MASK_PART_TOGGLED: LocalizedKey = LocalizedKey("history.mask_part_toggled");
|
|
pub const MASK_JOINED: LocalizedKey = LocalizedKey("history.mask_joined");
|
|
|
|
/// TRACES: FR-DEV-8
|
|
/// A repair's own settings changed — its radius, its source, its opacity.
|
|
pub const SPOT: LocalizedKey = LocalizedKey("history.spot");
|
|
pub const SPOT_PLACED: LocalizedKey = LocalizedKey("history.spot_placed");
|
|
pub const SPOT_MOVED: LocalizedKey = LocalizedKey("history.spot_moved");
|
|
pub const SPOT_REMOVED: LocalizedKey = LocalizedKey("history.spot_removed");
|
|
|
|
/// Every step above, plus the two the core names for itself.
|
|
///
|
|
/// Exists so the catalogue can be checked against the keys that are
|
|
/// actually recorded rather than against a second copy of them. A new step
|
|
/// left out of this list is a step with no test — which is the failure
|
|
/// this whole arrangement is guarding, so it is worth saying plainly:
|
|
/// **add the constant to this slice.**
|
|
///
|
|
/// Test-only, because checking is the whole of what it is for. Resolving a
|
|
/// key at runtime goes through [`super::resolve`] one key at a time and
|
|
/// never needs the roll.
|
|
#[cfg(test)]
|
|
pub const ALL: &[LocalizedKey] = &[
|
|
dr_pipeline::history::OPENED,
|
|
dr_pipeline::history::UNNAMED,
|
|
PASTE,
|
|
EARLIER_EDIT,
|
|
SNAPSHOT_RESTORED,
|
|
FILM,
|
|
SAMPLED_NEUTRAL,
|
|
RESET_ALL,
|
|
RESET_OP,
|
|
RESET_PARAM,
|
|
RESET_FRAMING,
|
|
ROTATE,
|
|
FLIP_H,
|
|
FLIP_V,
|
|
MASK_ADDED,
|
|
MASK_REMOVED,
|
|
MASK_TOGGLED,
|
|
MASK_INVERTED,
|
|
MASK_MOVED,
|
|
MASK_FEATHER,
|
|
MASK_MORPH,
|
|
MASK_OPACITY,
|
|
MASK_FALLOFF,
|
|
MASK_MORPHOLOGY,
|
|
MASK_REFINE,
|
|
MASK_RANGE,
|
|
MASK_PAINTED,
|
|
MASK_ERASED,
|
|
MASK_PART_ADDED,
|
|
MASK_PART_REMOVED,
|
|
MASK_PART_TOGGLED,
|
|
MASK_JOINED,
|
|
SPOT,
|
|
SPOT_PLACED,
|
|
SPOT_MOVED,
|
|
SPOT_REMOVED,
|
|
];
|
|
}
|
|
|
|
/// Resolve a key, deriving a fallback where none is catalogued.
|
|
pub fn resolve(key: &str) -> String {
|
|
catalogued(key).map_or_else(|| derive(key), str::to_string)
|
|
}
|
|
|
|
/// The label this build has actually chosen for `key`, if it has chosen one.
|
|
///
|
|
/// Split out from [`resolve`] so that "is this catalogued?" can be asked, and
|
|
/// it is asked by the test that guards the history rows. Those are read as a
|
|
/// column of short phrases naming decisions, and `derive` is not up to that
|
|
/// job even when it produces real words: it turns `history.mask_toggled` into
|
|
/// "Mask Toggled", which is a description of a field rather than of something
|
|
/// the photographer did — and, being perfectly readable, is a mistake nobody
|
|
/// would look at twice.
|
|
///
|
|
/// Deriving stays right for `op.` and `param.`, where an operation added as a
|
|
/// YAML declaration must show up usable before anyone writes its translation
|
|
/// (FR-DEV-3c). The difference is that those keys name a thing and these name
|
|
/// an act.
|
|
fn catalogued(key: &str) -> Option<&'static str> {
|
|
Some(match key {
|
|
// Operations
|
|
// The attribute names. Short on purpose: these are read as a strip
|
|
// of tabs, where a long word crowds out the next one.
|
|
"attr.tone" => "Light",
|
|
"attr.colour" => "Colour",
|
|
"attr.detail" => "Detail",
|
|
"attr.optics" => "Optics",
|
|
"attr.compose" => "Compose",
|
|
"attr.effect" => "Effects",
|
|
|
|
"op.white_balance" => "White Balance",
|
|
"op.exposure" => "Exposure",
|
|
"op.highlights_shadows" => "Highlights & Shadows",
|
|
"op.blacks_whites" => "Blacks & Whites",
|
|
"op.brilliance" => "Brilliance",
|
|
"op.vibrance" => "Vibrance",
|
|
"op.saturation" => "Saturation",
|
|
"op.colour_mixer" => "Colour Mixer",
|
|
"op.colour_grading" => "Colour Grading",
|
|
// "Sharpening" rather than what `derive` would make of the id. The id
|
|
// says *capture* sharpening to separate it from the output sharpening
|
|
// an export applies (FR-EXP-4), which is a distinction about where in
|
|
// the pipeline it sits; in the develop panel there is only one, and
|
|
// "Capture Sharpen" would name a distinction the photographer cannot
|
|
// see from there.
|
|
"op.capture_sharpen" => "Sharpening",
|
|
"op.dehaze" => "Dehaze",
|
|
"op.framing" => "Crop & Rotate",
|
|
// "Lens Vignetting", not the "Vignetting" `derive` would produce, and
|
|
// the qualifier is doing real work. This operation corrects the corner
|
|
// falloff a lens imposed; the creative vignette that darkens corners on
|
|
// purpose is a different operation, belongs to `Effect` rather than
|
|
// `Optics`, and will want the plain word when it exists. Naming this
|
|
// one for what it corrects means the two can sit in the same panel
|
|
// without either having to be renamed to make room.
|
|
"op.vignetting" => "Lens Vignetting",
|
|
// "Chromatic Aberration", not the "Aberration" `derive` would give.
|
|
// The bare word names a whole family of lens defects — coma, spherical,
|
|
// astigmatism — and this operation corrects exactly one of them. Under
|
|
// it sit sliders called "Red" and "Blue", which only make sense once
|
|
// the heading has said what is being separated.
|
|
"op.aberration" => "Chromatic Aberration",
|
|
// The switch that accepts or declines the measured profile for the
|
|
// lens the file names. "Lens Profile" rather than "Apply Lens Profile":
|
|
// it is a tick box, and a checkbox labelled with a verb reads as a
|
|
// button that does something once rather than a state that is on.
|
|
"op.lens_profile" => "Lens Profile",
|
|
"op.learned_denoise" => "AI Denoise",
|
|
// The view transform (FR-DEV-3j). "Tone Mapping" rather than the
|
|
// "View Transform" `derive` would give: the id names where it sits in
|
|
// the pipeline, and the photographer is choosing how the scene's range
|
|
// is fitted onto the screen.
|
|
"op.view_transform" => "Tone Mapping",
|
|
// The DNG camera profile's tables (D20). "Camera Profile", the DNG
|
|
// specification's own name for what the file carries.
|
|
"op.camera_profile" => "Camera Profile",
|
|
|
|
// Parameters
|
|
// Named for what it does rather than what it is, since a lone
|
|
// parameter is titled by its operation and this one never reaches the
|
|
// panel under its own name — see `rows_filtered`.
|
|
"param.lens_profile.apply" => "Apply",
|
|
"param.learned_denoise.apply" => "Apply",
|
|
// How strongly: 100 % is the network's result, and less puts the
|
|
// removed noise's brightness back as grain.
|
|
"param.learned_denoise.strength" => "Strength",
|
|
"param.camera_profile.apply" => "Use Profile",
|
|
// The LookTable's strength.
|
|
"param.camera_profile.look" => "Look Amount",
|
|
"param.view_transform.contrast" => "Contrast",
|
|
// In stops above middle grey: where the scene reaches display white.
|
|
"param.view_transform.white" => "White Point",
|
|
// The view transform's curve (D21): the DNG SDK's reference
|
|
// rendering, or D19's sigmoid with its highlight shoulder.
|
|
"param.view_transform.curve" => "Curve",
|
|
"param.view_transform.curve.camera_raw" => "DNG Reference",
|
|
"param.view_transform.curve.sigmoid" => "Sigmoid",
|
|
"param.temperature" => "Temperature",
|
|
"param.tint" => "Tint",
|
|
"param.exposure" => "Exposure",
|
|
"param.highlights" => "Highlights",
|
|
"param.shadows" => "Shadows",
|
|
"param.blacks" => "Blacks",
|
|
"param.whites" => "Whites",
|
|
"param.brilliance" => "Brilliance",
|
|
"param.vibrance" => "Vibrance",
|
|
"param.saturation" => "Saturation",
|
|
|
|
// What a faceted parameter adjusts — the mixer's three channels.
|
|
//
|
|
// Spelled out rather than left to `derive`, which would give "Sat" and
|
|
// "Lum" from the keys. These name a run of twelve rows apiece, and an
|
|
// abbreviation at the head of a section is a word the reader has to
|
|
// expand every time they scan past it.
|
|
"param.channel.hue" => "Hue",
|
|
"param.channel.sat" => "Saturation",
|
|
"param.channel.lum" => "Luminance",
|
|
|
|
// The tone curve's four curves, which its points are *subject* to.
|
|
//
|
|
// Catalogued rather than derived because the master curve's key would
|
|
// otherwise read "Rgb": these are the terms of a four-way choice, and
|
|
// one of them miscapitalised is the one the eye goes to. The three
|
|
// colours would derive correctly and are written out beside it anyway,
|
|
// since a list where one entry is translated and three are guessed is
|
|
// the shape a half-finished translation takes.
|
|
"channel.rgb" => "RGB",
|
|
"channel.red" => "Red",
|
|
"channel.green" => "Green",
|
|
"channel.blue" => "Blue",
|
|
|
|
// The hue bands, which a faceted row is *subject* to.
|
|
//
|
|
// Catalogued even where `derive` would produce the same word, because
|
|
// three of them are not the word the key spells: "spring" is spring
|
|
// green, and reading "Spring" beside "Green" in a list of colours says
|
|
// nothing. These are also the only place a swatch's meaning is written
|
|
// down in words, which is what a photographer who cannot separate the
|
|
// squares by eye has to go on.
|
|
"band.red" => "Red",
|
|
"band.orange" => "Orange",
|
|
"band.yellow" => "Yellow",
|
|
"band.chartreuse" => "Yellow-Green",
|
|
"band.green" => "Green",
|
|
"band.spring" => "Blue-Green",
|
|
"band.cyan" => "Cyan",
|
|
"band.azure" => "Azure",
|
|
"band.blue" => "Blue",
|
|
"band.violet" => "Violet",
|
|
"band.magenta" => "Magenta",
|
|
"band.rose" => "Rose",
|
|
|
|
// Framing. "Straighten" rather than "Angle" because that is the task
|
|
// the control performs; the number it reports is still degrees.
|
|
"param.angle" => "Straighten",
|
|
"param.rotation" => "Rotate",
|
|
"param.flip_h" => "Flip Horizontal",
|
|
"param.flip_v" => "Flip Vertical",
|
|
"param.crop_x" => "Crop Left",
|
|
"param.crop_y" => "Crop Top",
|
|
"param.crop_w" => "Crop Width",
|
|
"param.crop_h" => "Crop Height",
|
|
// TRACES: FR-DEV-20
|
|
// The panel labels these "Vertical" and "Horizontal" under the
|
|
// straightening, where the context says what they correct; a history
|
|
// row has no such context, so it says it.
|
|
"param.keystone_v" => "Vertical Perspective",
|
|
"param.keystone_h" => "Horizontal Perspective",
|
|
|
|
// TRACES: FR-DEV-5
|
|
// The steps that are not a parameter moving.
|
|
//
|
|
// Catalogued rather than derived because these are read as a *column*
|
|
// of short phrases, where `derive` would give "Mask Added" and "Reset
|
|
// Op" — the second of which names a function rather than an action,
|
|
// and the first of which reads as a label for a thing rather than for
|
|
// something the photographer did. A history is a list of decisions, so
|
|
// the rows are written as decisions.
|
|
//
|
|
// A step naming an operation or a parameter is *not* here: it resolves
|
|
// through the same `op.` and `param.` entries above that the panel
|
|
// uses, which is what lets an operation added as a YAML declaration
|
|
// appear in the history correctly named with no entry written for it
|
|
// (FR-DEV-3c).
|
|
"history.opened" => "Opened",
|
|
"history.edit" => "Edit",
|
|
"history.paste" => "Paste Settings",
|
|
"history.earlier_edit" => "Earlier Edit",
|
|
"history.snapshot_restored" => "Restore A Snapshot",
|
|
"history.film" => "Film Stock",
|
|
"history.sampled_neutral" => "Sample Neutral",
|
|
|
|
"history.reset_all" => "Reset Everything",
|
|
"history.reset_op" => "Reset Group",
|
|
"history.reset_param" => "Reset Control",
|
|
"history.reset_framing" => "Reset Crop & Rotate",
|
|
|
|
"history.rotate" => "Rotate",
|
|
"history.flip_h" => "Flip Horizontal",
|
|
"history.flip_v" => "Flip Vertical",
|
|
|
|
"history.mask_added" => "Add Mask",
|
|
"history.mask_removed" => "Delete Mask",
|
|
"history.mask_toggled" => "Mask On/Off",
|
|
"history.mask_inverted" => "Invert Mask",
|
|
"history.mask_moved" => "Move Mask",
|
|
"history.mask_feather" => "Mask Feather",
|
|
"history.mask_morph" => "Mask Edge",
|
|
"history.mask_opacity" => "Mask Opacity",
|
|
"history.mask_falloff" => "Mask Falloff",
|
|
"history.mask_morphology" => "Mask Grow/Shrink",
|
|
"history.mask_refine" => "Mask Refine",
|
|
"history.mask_range" => "Mask Range",
|
|
"history.mask_painted" => "Paint Mask",
|
|
"history.mask_erased" => "Erase Mask",
|
|
"history.mask_part_added" => "Add To Mask",
|
|
"history.mask_part_removed" => "Remove From Mask",
|
|
"history.mask_part_toggled" => "Show Or Hide Part Of A Mask",
|
|
"history.mask_joined" => "Change How A Mask Joins",
|
|
"history.spot" => "Adjust Repair",
|
|
"history.spot_placed" => "Add Repair",
|
|
"history.spot_moved" => "Move Repair",
|
|
"history.spot_removed" => "Delete Repair",
|
|
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// Turn `op.some_new_thing` into `Some New Thing`.
|
|
///
|
|
/// A missing translation should look like an untranslated label, not like a
|
|
/// bug — a blank control is far harder to diagnose than an oddly-capitalised
|
|
/// one.
|
|
fn derive(key: &str) -> String {
|
|
let tail = key.rsplit('.').next().unwrap_or(key);
|
|
let mut out = String::with_capacity(tail.len());
|
|
let mut capitalise = true;
|
|
for ch in tail.chars() {
|
|
if ch == '_' || ch == '-' {
|
|
out.push(' ');
|
|
capitalise = true;
|
|
} else if capitalise {
|
|
out.extend(ch.to_uppercase());
|
|
capitalise = false;
|
|
} else {
|
|
out.push(ch);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn every_step_the_interface_records_has_a_name() {
|
|
// A history is only as useful as its rows are distinguishable, and
|
|
// these are the ones no descriptor can name — the resets, the flips,
|
|
// the mask actions. A key missing from the catalogue derives something
|
|
// readable rather than nothing, so the failure this guards is subtler:
|
|
// two different steps deriving the *same* word, or a word that names
|
|
// the function that ran rather than the decision the user made.
|
|
let mut seen: Vec<String> = Vec::new();
|
|
for key in step::ALL.iter().map(|k| k.0) {
|
|
let label = catalogued(key).unwrap_or_else(|| {
|
|
panic!(
|
|
"{key} is not catalogued; it would fall through to `derive` \
|
|
and read as {:?}, which names a field rather than an act",
|
|
derive(key)
|
|
)
|
|
});
|
|
assert!(!label.is_empty(), "{key} resolved to nothing");
|
|
assert!(
|
|
!seen.contains(&label.to_string()),
|
|
"{key} shares the label {label:?} with an earlier step"
|
|
);
|
|
seen.push(label.to_string());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_step_naming_an_operation_borrows_the_panels_label() {
|
|
// What keeps the history honest as the pipeline grows: a step that
|
|
// moved a parameter is named by the same entry the slider beside it
|
|
// is, so the two can never disagree, and an operation nobody has
|
|
// catalogued yet still reads correctly in both places.
|
|
assert_eq!(resolve("op.white_balance"), "White Balance");
|
|
assert_eq!(resolve("op.tone_curve"), "Tone Curve");
|
|
assert_eq!(resolve("param.exposure"), "Exposure");
|
|
}
|
|
|
|
#[test]
|
|
fn catalogued_keys_resolve_to_their_label() {
|
|
assert_eq!(resolve("op.white_balance"), "White Balance");
|
|
assert_eq!(resolve("param.highlights"), "Highlights");
|
|
// Catalogued precisely because `derive` would get it wrong: the id
|
|
// carries a distinction ("capture", as against an export's output
|
|
// sharpening) that belongs in the pipeline and not on a panel.
|
|
assert_eq!(resolve("op.capture_sharpen"), "Sharpening");
|
|
// Its parameters are the opposite case — the derived words are the
|
|
// right words, so they are left uncatalogued and shared with whatever
|
|
// asks for an amount or a radius next.
|
|
assert_eq!(resolve("param.amount"), "Amount");
|
|
assert_eq!(resolve("param.radius"), "Radius");
|
|
assert_eq!(resolve("param.threshold"), "Threshold");
|
|
}
|
|
|
|
#[test]
|
|
fn an_uncatalogued_key_derives_a_readable_label() {
|
|
// The FR-DEV-3c property: a new operation shows up usable before
|
|
// anyone writes its translation.
|
|
assert_eq!(resolve("op.tone_curve"), "Tone Curve");
|
|
assert_eq!(resolve("param.midpoint"), "Midpoint");
|
|
}
|
|
|
|
#[test]
|
|
fn a_key_without_a_prefix_still_resolves() {
|
|
assert_eq!(resolve("clarity"), "Clarity");
|
|
}
|
|
|
|
#[test]
|
|
fn no_key_resolves_to_empty() {
|
|
// An empty label renders as a control with no name, which reads as a
|
|
// rendering bug rather than a missing translation.
|
|
for key in ["", "op.", "x", "op.a_b_c"] {
|
|
let got = resolve(key);
|
|
if key.is_empty() || key == "op." {
|
|
// Degenerate input; only the non-degenerate cases must be
|
|
// non-empty.
|
|
continue;
|
|
}
|
|
assert!(!got.is_empty(), "{key} resolved to nothing");
|
|
}
|
|
}
|
|
}
|