ATTEDIT prompts at the command line; -ATTEDIT; tags in any case (fixes #273, #274) (#280)

ATTEDIT / EATTEDIT typed at the command line ran the JSON form and failed
with "`handle` is required". It now asks "Select block reference:" and then
"Enter value for <TAG> <current>:" for each attribute (Enter keeps the
value); the answers are applied together as one undo step.

-ATTEDIT is a command of its own (Modify > Object > Attribute > Global):
one at a time (Value: Change/Replace, Next) or global string replacement,
over the attributes chosen by block name, tag and value specifications.
Session::start now prefers an id that keeps its leading dash, so -ATTEDIT
is not taken for ATTEDIT; dashed aliases (-INSERT...) resolve as before.

INSERT `attribs` and ATTEDIT `values` match attribute tags in any case and
report the keys no attribute has in `unknownTags`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 22:55:33 +02:00 committed by GitHub
parent 5b286119d2
commit 7a876c74c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 571 additions and 23 deletions

View File

@ -11,6 +11,8 @@ use super::machines::{SelOutcome, SelectPhase, number};
use super::*;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod attedit;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("block", "Make...", run_block)
@ -33,10 +35,8 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["att", "-attdef"])
.params("{tag, prompt?, default?, at, height?, invisible?}")
.interactive(|_| Ok(Box::new(AttdefM::default()))),
CommandSpec::new("attedit", "Single...", run_attedit)
.menu(&["Modify", "Object", "Attribute", "Single..."])
.alias(&["ate", "eattedit"])
.params("{handle, values: {TAG: value}}"),
attedit::attedit_spec(),
attedit::dash_attedit_spec(),
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
CommandSpec::new("purge", "Purge", run_purge).alias(&["pu", "-purge"]).params("{} (unused blocks, layers, linetypes, styles)"),
@ -164,7 +164,7 @@ pub(crate) fn insert_with(
if let EntityKind::Text(tt) = k {
t = tt;
}
t.value = values.get(&ad.tag).and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| ad.text.value.clone());
t.value = attedit::value_for(values, &ad.tag).unwrap_or_else(|| ad.text.value.clone());
ins.attribs.push(Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new() });
}
}
@ -200,7 +200,7 @@ fn run_insert(s: &mut Session, p: &Value) -> Result<Value> {
if bool_or(p, "explode", false) {
return s.execute("explode", &json!({ "handles": [h.hex()] }));
}
Ok(json!({ "handle": h.hex() }))
Ok(attedit::with_unknown_tags(s, h, &vals, json!({ "handle": h.hex() })))
}
fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, height: f64, invisible: bool) -> EntityKind {
@ -236,23 +236,6 @@ fn run_attdef(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
}
fn run_attedit(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("attedit", "`handle` is required"))?;
let vals = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
let mut n = 0;
s.doc_mut()?.modify_entity(h, |e| {
if let EntityKind::Insert(i) = &mut e.kind {
for a in &mut i.attribs {
if let Some(v) = vals.get(&a.tag).and_then(Value::as_str) {
a.text.value = v.to_string();
n += 1;
}
}
}
})?;
Ok(json!({ "changed": n }))
}
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
let src = s.doc()?;

View File

@ -0,0 +1,453 @@
//! ATTEDIT / EATTEDIT: one block reference's attribute values, asked for one after another.
//! -ATTEDIT: the attributes picked by block name, tag and value specifications, edited one at a
//! time or by replacing a string in all of them. Attribute tags match in any case, as in AutoCAD.
use cadcraft_doc::{EntityKind, Handle};
use serde_json::{Map, Value, json};
use crate::cmd::curves::KW;
use crate::cmd::machines::{SelOutcome, SelectPhase};
use crate::cmd::qselect::wildcard;
use crate::cmd::{CommandSpec, bad, str_param, targets};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
/// Longest attribute value a string replacement may produce (bytes).
const MAX_VALUE: usize = 65_536;
pub(super) fn attedit_spec() -> CommandSpec {
CommandSpec::new("attedit", "Single...", run)
.menu(&["Modify", "Object", "Attribute", "Single..."])
.alias(&["ate", "eattedit"])
.params("{handle, values: {TAG: value} (tags in any case)} → {changed, unknownTags?}")
.interactive(|_| Ok(Box::new(AtteditM::default())))
}
pub(super) fn dash_attedit_spec() -> CommandSpec {
CommandSpec::new("-attedit", "Global", run_global)
.menu(&["Modify", "Object", "Attribute", "Global"])
.alias(&["-ate", "atte"])
.params("{find, replace?, block?, tag?, value? (wildcard specifications, default *), handles? (default: every block reference in the current space)} → {changed}")
.interactive(|_| Ok(Box::new(DashAtteditM::default())))
}
/// The value `values` gives for `tag`, matching the tag in any case. Numbers and booleans count
/// as their text.
pub(super) fn value_for(values: &Map<String, Value>, tag: &str) -> Option<String> {
let v = values.get(tag).or_else(|| values.iter().find(|(k, _)| k.eq_ignore_ascii_case(tag)).map(|(_, v)| v))?;
match v {
Value::String(t) => Some(t.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
/// `r` with `unknownTags`: the keys of `values` that block reference `h` has no attribute for
/// (left out when there are none).
pub(super) fn with_unknown_tags(s: &Session, h: Handle, values: &Map<String, Value>, mut r: Value) -> Value {
let tags: Vec<&str> = match s.doc().ok().and_then(|d| d.entity(h)).map(|e| &e.kind) {
Some(EntityKind::Insert(i)) => i.attribs.iter().map(|a| a.tag.as_str()).collect(),
_ => Vec::new(),
};
let unknown: Vec<&String> = values.keys().filter(|k| !tags.iter().any(|t| t.eq_ignore_ascii_case(k))).collect();
if !unknown.is_empty()
&& let Some(o) = r.as_object_mut()
{
o.insert("unknownTags".into(), json!(unknown));
}
r
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("attedit", "`handle` is required"))?;
let vals = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
let mut n = 0;
s.doc_mut()?.modify_entity(h, |e| {
if let EntityKind::Insert(i) = &mut e.kind {
for a in &mut i.attribs {
if let Some(v) = value_for(&vals, &a.tag) {
a.text.value = v;
n += 1;
}
}
}
})?;
Ok(with_unknown_tags(s, h, &vals, json!({ "changed": n })))
}
/// A block name, tag or value specification: comma-separated wildcard patterns, any case. For
/// values, `\` matches an empty value.
fn spec_matches(spec: &str, text: &str) -> bool {
spec.split(',').any(|p| if p.trim() == "\\" { text.is_empty() } else { wildcard(p.trim(), text) })
}
/// Specifications choosing attributes: block name, tag, value.
#[derive(Clone, Debug)]
struct Specs {
block: String,
tag: String,
value: String,
}
impl Default for Specs {
fn default() -> Self {
Specs { block: "*".into(), tag: "*".into(), value: "*".into() }
}
}
/// The attributes of block references `hs` that match `specs`, as (block reference, attribute
/// index). Invisible attributes count only with `invisible` (or while ATTMODE shows them).
fn matching(s: &Session, hs: &[Handle], specs: &Specs, invisible: bool) -> Result<Vec<(Handle, usize)>> {
let d = s.doc()?;
let invisible = invisible || d.header.i64("ATTMODE", 1) == 2;
let mut out = Vec::new();
for h in hs {
let Some(EntityKind::Insert(ins)) = d.entity(*h).map(|e| &e.kind) else { continue };
if !spec_matches(&specs.block, &ins.block) {
continue;
}
for (k, a) in ins.attribs.iter().enumerate() {
if !a.constant && (invisible || !a.invisible) && spec_matches(&specs.tag, &a.tag) && spec_matches(&specs.value, &a.text.value) {
out.push((*h, k));
}
}
}
Ok(out)
}
/// Every object in the current space.
fn all_in_space(s: &Session) -> Result<Vec<Handle>> {
let d = s.doc()?;
Ok(d.space(&s.space()).map(|st| st.iter().map(|e| e.handle).collect()).unwrap_or_default())
}
/// Change attribute `k` of block reference `h`; returns whether its value changed.
fn edit(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&str) -> Option<String>) -> Result<bool> {
let mut changed = false;
s.doc_mut()?.modify_entity(h, |e| {
if let EntityKind::Insert(ins) = &mut e.kind
&& let Some(a) = ins.attribs.get_mut(k)
&& let Some(v) = f(&a.text.value).filter(|v| *v != a.text.value)
{
a.text.value = v;
changed = true;
}
})?;
Ok(changed)
}
/// Replace every `find` in `value` with `with`, unless the result would be too long.
fn replaced(value: &str, find: &str, with: &str) -> Option<String> {
if find.is_empty() || !value.contains(find) {
return None;
}
let n = value.matches(find).count();
let len = value.len().checked_sub(n.checked_mul(find.len())?)?.checked_add(n.checked_mul(with.len())?)?;
(len <= MAX_VALUE).then(|| value.replace(find, with))
}
/// Replace `find` with `with` in the given attributes; returns how many changed.
fn replace_all(s: &mut Session, items: &[(Handle, usize)], find: &str, with: &str) -> Result<usize> {
let mut n = 0;
for it in items {
if edit(s, *it, |v| replaced(v, find, with))? {
n += 1;
}
}
Ok(n)
}
fn run_global(s: &mut Session, p: &Value) -> Result<Value> {
let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad("-attedit", "`find` (a non-empty string) is required"))?;
let with = str_param(p, "replace").unwrap_or("");
let spec = |k| str_param(p, k).unwrap_or("*").to_string();
let specs = Specs { block: spec("block"), tag: spec("tag"), value: spec("value") };
let hs = if p.get("handles").is_some() || p.get("handle").is_some() { targets(s, p)? } else { all_in_space(s)? };
let items = matching(s, &hs, &specs, true)?;
let (find, with) = (find.to_string(), with.to_string());
Ok(json!({ "changed": replace_all(s, &items, &find, &with)? }))
}
// ---------------- ATTEDIT / EATTEDIT ----------------
#[derive(Default)]
struct AtteditM {
block: Option<Handle>,
/// Index, tag and current value of each editable attribute of the picked block reference.
attribs: Vec<(usize, String, String)>,
/// The values answered so far, in attribute order.
values: Vec<String>,
}
impl AtteditM {
fn pick(&mut self, s: &Session, hs: &[Handle]) -> Result<()> {
let d = s.doc()?;
let mut msg = "Select a block reference.";
for h in hs {
let Some(EntityKind::Insert(ins)) = d.entity(*h).map(|e| &e.kind) else { continue };
let atts: Vec<(usize, String, String)> =
ins.attribs.iter().enumerate().filter(|(_, a)| !a.constant).map(|(k, a)| (k, a.tag.clone(), a.text.value.clone())).collect();
if atts.is_empty() {
msg = "That block reference has no editable attributes.";
continue;
}
self.block = Some(*h);
self.attribs = atts;
return Ok(());
}
Err(EngineError::Other(msg.into()))
}
}
impl Interactive for AtteditM {
fn name(&self) -> &'static str {
"ATTEDIT"
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.attribs.get(self.values.len()) {
Some((_, tag, cur)) if self.block.is_some() => Prompt::new(format!("Enter value for {tag}"), Accept::TEXT).default(cur.clone()),
_ => Prompt::new("Select block reference", Accept::SELECT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let Some(h) = self.block else {
return match i {
Input::Pick(hs) => self.pick(s, &hs).map(|_| Step::Continue),
Input::Enter => Ok(Step::Done),
Input::Text(_) | Input::Keyword(_) => Err(EngineError::Other("Select a block reference.".into())),
_ => Ok(Step::Continue),
};
};
let cur = self.attribs.get(self.values.len()).map(|a| a.2.clone()).unwrap_or_default();
self.values.push(match i {
Input::Text(t) => t,
Input::Enter => cur,
_ => return Ok(Step::Continue),
});
if self.values.len() < self.attribs.len() {
return Ok(Step::Continue);
}
// All answered: apply them together (one undo step).
for ((k, _, _), v) in self.attribs.iter().zip(&self.values) {
edit(s, (h, *k), |_| Some(v.clone()))?;
}
Ok(Step::Done)
}
}
// ---------------- -ATTEDIT ----------------
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Stage {
#[default]
OneAtATime,
VisibleOnly,
BlockSpec,
TagSpec,
ValueSpec,
Select,
/// One at a time: what to do with the current attribute.
Option,
ValueKind,
NewValue,
Find,
Replace,
}
#[derive(Default)]
struct DashAtteditM {
stage: Stage,
one_at_a_time: bool,
visible_only: bool,
specs: Specs,
sel: SelectPhase,
items: Vec<(Handle, usize)>,
/// The attribute being edited one at a time (index into `items`).
cur: usize,
find: String,
}
fn invalid_keyword() -> EngineError {
EngineError::Other("Invalid option keyword.".into())
}
impl DashAtteditM {
fn yes(i: &Input) -> Result<bool> {
match i {
Input::Keyword(k) => Ok(k == "Yes"),
Input::Enter => Ok(true),
_ => Err(invalid_keyword()),
}
}
/// The attributes are chosen: start editing them.
fn chosen(&mut self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
self.items = matching(s, hs, &self.specs, !self.visible_only && !self.one_at_a_time)?;
s.set_selection(Vec::new());
let n = self.items.len();
s.echo(format!("{n} attribute{} selected.", if n == 1 { "" } else { "s" }));
if n == 0 {
return Ok(Step::Done);
}
if self.one_at_a_time {
self.cur = 0;
self.show_current(s);
self.stage = Stage::Option;
} else {
self.stage = Stage::Find;
}
Ok(Step::Continue)
}
fn show_current(&self, s: &mut Session) {
let Some((h, k)) = self.items.get(self.cur).copied() else { return };
if let Ok(d) = s.doc()
&& let Some(EntityKind::Insert(ins)) = d.entity(h).map(|e| &e.kind)
&& let Some(a) = ins.attribs.get(k)
{
let line = format!("{} = {}", a.tag, a.text.value);
s.echo(line);
}
}
}
impl Interactive for DashAtteditM {
fn name(&self) -> &'static str {
"-ATTEDIT"
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.stage {
Stage::OneAtATime => Prompt::new("Edit attributes one at a time?", KW).kw(&["Yes", "No"]).default("Y"),
Stage::VisibleOnly => Prompt::new("Edit only attributes visible on screen?", KW).kw(&["Yes", "No"]).default("Y"),
Stage::BlockSpec => Prompt::new("Enter block name specification", Accept::TEXT).default("*"),
Stage::TagSpec => Prompt::new("Enter attribute tag specification", Accept::TEXT).default("*"),
Stage::ValueSpec => Prompt::new("Enter attribute value specification", Accept::TEXT).default("*"),
Stage::Select => Prompt::new("Select attributes", Accept::SELECT),
Stage::Option => Prompt::new("Enter an option", KW).kw(&["Value", "Next"]).default("N"),
Stage::ValueKind => Prompt::new("Enter type of value modification", KW).kw(&["Change", "Replace"]).default("R"),
Stage::NewValue => Prompt::new("Enter new attribute value", Accept::TEXT),
Stage::Find => Prompt::new("Enter string to change", Accept::TEXT),
Stage::Replace => Prompt::new("Enter new string", Accept::TEXT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
// Free-text answers; Enter gives "" (the specifications then take their default "*").
let text = match &i {
Input::Text(t) => Some(t.clone()),
Input::Enter => Some(String::new()),
_ => None,
};
match self.stage {
Stage::OneAtATime => {
self.one_at_a_time = Self::yes(&i)?;
self.stage = if self.one_at_a_time {
Stage::BlockSpec
} else {
s.echo("Performing global editing of attribute values.");
Stage::VisibleOnly
};
}
Stage::VisibleOnly => {
self.visible_only = Self::yes(&i)?;
self.stage = Stage::BlockSpec;
}
Stage::BlockSpec | Stage::TagSpec | Stage::ValueSpec => {
let Some(t) = text else { return Ok(Step::Continue) };
let t = if t.trim().is_empty() { "*".to_string() } else { t.trim().to_string() };
match self.stage {
Stage::BlockSpec => {
self.specs.block = t;
self.stage = Stage::TagSpec;
}
Stage::TagSpec => {
self.specs.tag = t;
self.stage = Stage::ValueSpec;
}
_ => {
self.specs.value = t;
if !self.one_at_a_time && !self.visible_only {
let hs = all_in_space(s)?;
return self.chosen(s, &hs);
}
self.stage = Stage::Select;
}
}
}
Stage::Select => {
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => self.chosen(s, &[]),
SelOutcome::Done(hs) => self.chosen(s, &hs),
};
}
Stage::Option => match &i {
Input::Keyword(k) if k == "Value" => self.stage = Stage::ValueKind,
Input::Keyword(_) | Input::Enter => {
self.cur += 1;
if self.cur >= self.items.len() {
return Ok(Step::Done);
}
self.show_current(s);
}
Input::Text(t) => {
let tl = t.trim().to_ascii_lowercase();
let later = ["Position", "Height", "Angle", "Style", "Layer", "Color"];
return match later.iter().find(|k| !tl.is_empty() && k.to_ascii_lowercase().starts_with(&tl)) {
Some(k) => Err(EngineError::Other(format!("{k} is not available yet."))),
None => Err(invalid_keyword()),
};
}
_ => {}
},
Stage::ValueKind => match &i {
Input::Keyword(k) if k == "Change" => self.stage = Stage::Find,
Input::Keyword(_) | Input::Enter => self.stage = Stage::NewValue,
_ => return Err(invalid_keyword()),
},
Stage::NewValue => {
let Some(v) = text else { return Ok(Step::Continue) };
if let Some(it) = self.items.get(self.cur).copied() {
edit(s, it, |_| Some(v))?;
}
self.show_current(s);
self.stage = Stage::Option;
}
Stage::Find => match text {
Some(t) if !t.is_empty() => {
self.find = t;
self.stage = Stage::Replace;
}
Some(_) => return Err(EngineError::Other("Requires the string to change.".into())),
None => {}
},
Stage::Replace => {
let Some(with) = text else { return Ok(Step::Continue) };
let find = std::mem::take(&mut self.find);
if self.one_at_a_time {
let items: Vec<(Handle, usize)> = self.items.get(self.cur).copied().into_iter().collect();
replace_all(s, &items, &find, &with)?;
self.show_current(s);
self.stage = Stage::Option;
} else {
let n = replace_all(s, &self.items, &find, &with)?;
s.echo(format!("{n} attribute{} changed.", if n == 1 { "" } else { "s" }));
return Ok(Step::Done);
}
}
}
Ok(Step::Continue)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replacement_is_bounded_and_specs_take_lists() {
assert_eq!(replaced("a-b-c", "-", "+").as_deref(), Some("a+b+c"));
assert_eq!(replaced("abc", "x", "y"), None);
assert_eq!(replaced("aaaa", "a", &"x".repeat(MAX_VALUE)), None);
assert!(spec_matches("door,WIN*", "window"));
assert!(spec_matches("\\", ""));
assert!(!spec_matches("\\", "x"));
}
}

View File

@ -0,0 +1,111 @@
//! ATTEDIT / -ATTEDIT typed at the command line, and attribute tags matched in any case.
use cadcraft_doc::{EntityKind, Handle};
use cadcraft_engine::Session;
use serde_json::{Value, json};
/// Block B: a line from 0,0 to 10,0 with attributes T (default "one") and U (default "u").
fn with_block() -> Session {
let mut s = Session::new();
s.execute("attdef", &json!({"tag": "T", "default": "one", "at": [0, 1]})).unwrap();
s.execute("attdef", &json!({"tag": "U", "default": "u", "at": [0, 2]})).unwrap();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
s.execute("block", &json!({"name": "B", "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap();
s
}
fn insert(s: &mut Session, x: f64, attribs: Value) -> Value {
s.execute("insert", &json!({"name": "B", "at": [x, 0], "attribs": attribs})).unwrap()
}
fn handle(r: &Value) -> Handle {
Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()
}
fn value(s: &Session, h: Handle, tag: &str) -> String {
let EntityKind::Insert(i) = &s.doc().unwrap().entity(h).unwrap().kind else { panic!("not an insert") };
i.attribs.iter().find(|a| a.tag == tag).unwrap().text.value.clone()
}
fn type_lines(s: &mut Session, lines: &[&str]) {
for l in lines {
s.cmdline(l).unwrap();
}
}
#[test]
fn attedit_asks_for_a_block_then_each_value() {
let mut s = with_block();
let h = handle(&insert(&mut s, 100.0, json!({})));
s.cmdline("attedit").unwrap();
assert!(s.prompt_text().contains("Select block reference"), "{}", s.prompt_text());
s.cmdline("105,0").unwrap();
assert!(s.prompt_text().contains("Enter value for T <one>"), "{}", s.prompt_text());
// A value with spaces; Enter keeps U as it was.
type_lines(&mut s, &["new value", ""]);
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(value(&s, h, "T"), "new value");
assert_eq!(value(&s, h, "U"), "u");
// One undo step.
s.undo().unwrap();
assert_eq!(value(&s, h, "T"), "one");
// EATTEDIT is the same command; a pick on nothing editable re-prompts.
s.execute("line", &json!({"points": [[0, 50], [10, 50]]})).unwrap();
type_lines(&mut s, &["eattedit", "5,50"]);
assert!(s.log.iter().any(|l| l.contains("Select a block reference.")), "{:?}", s.log);
type_lines(&mut s, &["105,0", "x", "y"]);
assert!(s.running.is_none());
assert_eq!((value(&s, h, "T"), value(&s, h, "U")), ("x".into(), "y".into()));
}
#[test]
fn dash_attedit_global_replaces_a_string_in_matching_attributes() {
let mut s = with_block();
let a = handle(&insert(&mut s, 100.0, json!({"T": "A-1", "U": "A-9"})));
let b = handle(&insert(&mut s, 200.0, json!({"T": "A-2"})));
// Not one at a time, not only visible ones: every reference of B, tag T, any value.
s.script("-attedit\nn\nn\nb\nt\n\nA-\nX-\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!((value(&s, a, "T"), value(&s, b, "T")), ("X-1".into(), "X-2".into()));
assert_eq!(value(&s, a, "U"), "A-9", "tag specification filters");
assert!(s.log.iter().any(|l| l == "2 attributes changed."), "{:?}", s.log);
// The JSON form does the same.
let r = s.execute("-attedit", &json!({"tag": "u", "find": "9", "replace": "8"})).unwrap();
assert_eq!(r["changed"], 1);
assert_eq!(value(&s, a, "U"), "A-8");
}
#[test]
fn dash_attedit_one_at_a_time_edits_the_selected_attributes() {
let mut s = with_block();
let h = handle(&insert(&mut s, 100.0, json!({"T": "abc"})));
type_lines(&mut s, &["-attedit", "y", "", "T", "", "105,0", ""]);
assert!(s.prompt_text().contains("[Value/Next] <N>"), "{}", s.prompt_text());
// Unsupported options say so and keep the prompt.
s.cmdline("p").unwrap();
assert!(s.log.iter().any(|l| l.contains("Position is not available yet")), "{:?}", s.log);
type_lines(&mut s, &["v", "c", "b", "B", "v", "r", "replaced value", "n"]);
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(value(&s, h, "T"), "replaced value");
assert_eq!(value(&s, h, "U"), "u");
}
#[test]
fn attribute_tags_match_in_any_case_and_unknown_tags_are_reported() {
let mut s = with_block();
let r = insert(&mut s, 100.0, json!({"t": "two", "nope": "x"}));
let h = handle(&r);
assert_eq!(value(&s, h, "T"), "two");
assert_eq!(r["unknownTags"], json!(["nope"]));
let r = s.execute("attedit", &json!({"handle": h.hex(), "values": {"t": "three", "u": 4, "W": "w"}})).unwrap();
assert_eq!(r["changed"], 2);
assert_eq!(r["unknownTags"], json!(["W"]));
assert_eq!((value(&s, h, "T"), value(&s, h, "U")), ("three".into(), "4".into()));
let r = s.execute("attedit", &json!({"handle": h.hex(), "values": {"T": "four"}})).unwrap();
assert_eq!(r, json!({"changed": 1}));
}

View File

@ -289,6 +289,7 @@
Geometry Геометрія
Get Variable Отримати змінну
Get View Отримати вид
Global Глобально
Global width Загальна ширина
Grab bag Список
Gradient Градієнт

Can't render this file because it has a wrong number of fields in line 7.