use std::sync::mpsc::{Receiver, Sender};
use eframe::egui;
use nord_format::cbin::{Cbin, Generation, Header};
use nord_format::formats::{ne5, ns2, ns3, ns4};
use nord_format::{Entity, Live, OrganPreset, PianoPreset, Program, Settings, Song, Synth};
use nord_usb::{Location, ObjectClass};
use crate::log::Log;
#[derive(Clone)]
pub enum Origin {
File(String),
Device {
class: ObjectClass,
at: Location,
},
Fresh,
Rescued {
at: Location,
},
}
impl Origin {
pub fn label(&self) -> String {
match self {
Origin::File(name) => format!("Opened from {name}"),
Origin::Device { class, at } => {
format!("Copied from {}", crate::strings::place(*class, *at))
}
Origin::Fresh => "New, not saved anywhere yet".into(),
Origin::Rescued { at } => format!("Rescued from {}", crate::strings::shown(*at)),
}
}
pub fn slot(&self) -> Option<(ObjectClass, Location)> {
match self {
Origin::Device { class, at } => Some((*class, *at)),
_ => None,
}
}
}
#[derive(Clone)]
pub enum VerifyState {
Ok,
Differs {
at: usize,
},
Failed(String),
NotApplicable(&'static str),
}
impl VerifyState {
pub fn badge(&self) -> &'static str {
match self {
VerifyState::Ok => "ok",
VerifyState::Differs { .. } => "differs",
VerifyState::Failed(_) => "failed",
VerifyState::NotApplicable(_) => "n/a",
}
}
pub fn detail(&self) -> String {
match self {
VerifyState::Ok => "re-encoded byte-for-byte".into(),
VerifyState::Differs { at } => format!("first difference at byte {at:#06x}"),
VerifyState::Failed(why) => why.clone(),
VerifyState::NotApplicable(why) => (*why).to_string(),
}
}
pub fn color(&self, visuals: &egui::Visuals) -> egui::Color32 {
match self {
VerifyState::Ok => crate::app::good(visuals),
VerifyState::Differs { .. } | VerifyState::Failed(_) => crate::app::bad(visuals),
VerifyState::NotApplicable(_) => visuals.weak_text_color(),
}
}
}
#[derive(Clone)]
pub struct Container {
pub header: Header,
pub body_len: u64,
pub checksum_ok: bool,
pub checksum_label: &'static str,
pub checksum: String,
}
impl Container {
fn read(bytes: &[u8]) -> Option<Container> {
let info = nord_format::cbin::inspect(&mut std::io::Cursor::new(bytes)).ok()?;
let (checksum_label, checksum) = match info.header.generation {
Generation::V0 => {
let tail = bytes.get(bytes.len().checked_sub(2)?..)?;
let crc = u16::from_le_bytes(tail.try_into().ok()?);
("crc16:", format!("{crc:#06x}"))
}
Generation::V1 => {
let crc = u32::from_le_bytes(bytes.get(0x18..0x1c)?.try_into().ok()?);
("crc32:", format!("{crc:#010x}"))
}
};
Some(Container {
header: info.header,
body_len: info.body_len,
checksum_ok: info.checksum_ok,
checksum_label,
checksum,
})
}
pub fn tag(&self) -> String {
String::from_utf8_lossy(&self.header.tag).into_owned()
}
}
pub struct LocalEntity {
pub id: u64,
pub name: String,
pub origin: Origin,
pub bytes: Vec<u8>,
pub entity: Option<Entity>,
pub parse_error: Option<String>,
pub container: Option<Container>,
pub verify: VerifyState,
pub dirty: bool,
pub pending: bool,
pub kept: bool,
}
impl LocalEntity {
fn new(id: u64, name: String, origin: Origin, bytes: Vec<u8>) -> LocalEntity {
let container = Container::read(&bytes);
let (entity, parse_error) =
match nord_format::from_stream(&mut std::io::Cursor::new(&bytes)) {
Ok(entity) => (Some(entity), None),
Err(e) => (None, Some(e.to_string())),
};
let verify = match &entity {
Some(entity) => verify(entity, &bytes),
None => VerifyState::NotApplicable("the file did not decode"),
};
LocalEntity {
id,
name,
origin,
bytes,
entity,
parse_error,
container,
verify,
dirty: false,
pending: false,
kept: true,
}
}
pub fn tag(&self) -> String {
match (&self.entity, &self.container) {
(Some(entity), _) => entity.identity().format.to_string(),
(None, Some(container)) => container.tag(),
(None, None) => "?".into(),
}
}
pub fn raw_body(&self) -> Option<Vec<u8>> {
nord_usb::envelope::unwrap(&self.bytes)
.ok()
.map(|read| read.body.0)
}
}
pub fn precious(entity: &LocalEntity) -> bool {
entity.dirty || entity.pending
}
fn export_filename(name: &str, bytes: &[u8]) -> String {
let stem = match filename_stem(name) {
s if s.is_empty() => "unnamed".to_string(),
s => s,
};
match carries_tag(&stem) {
true => stem,
false => format!("{stem}.{}", format_tag(bytes)),
}
}
fn filename_stem(label: &str) -> String {
let mut owed = false;
let mut out = String::with_capacity(label.len());
for c in label.chars() {
match c {
'-' => owed = !out.is_empty(),
_ if c.is_whitespace() => owed = !out.is_empty(),
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => owed = !out.is_empty(),
_ if c.is_control() => {}
_ => {
if std::mem::take(&mut owed) {
out.push('-');
}
out.push(c);
}
}
}
out.trim_matches(['.', '-']).to_string()
}
fn carries_tag(name: &str) -> bool {
name.rsplit_once('.').is_some_and(|(stem, tag)| {
!stem.trim().is_empty()
&& (2..=5).contains(&tag.len())
&& tag.chars().all(|c| c.is_ascii_alphanumeric())
&& tag.chars().any(|c| c.is_ascii_alphabetic())
})
}
fn format_tag(bytes: &[u8]) -> String {
bytes
.get(8..12)
.filter(|tag| tag.iter().all(|b| b.is_ascii_alphanumeric()))
.map(|tag| String::from_utf8_lossy(tag).into_owned())
.unwrap_or_else(|| "bin".to_string())
}
fn verify(entity: &Entity, bytes: &[u8]) -> VerifyState {
if matches!(entity, Entity::Bundle(_)) {
return VerifyState::NotApplicable("a bundle is an archive; it does not re-encode");
}
let out = match nord_format::to_bytes(entity) {
Ok(out) => out,
Err(e) => return VerifyState::Failed(e.to_string()),
};
match out.iter().zip(bytes).position(|(a, b)| a != b) {
Some(at) => VerifyState::Differs { at },
None if out.len() == bytes.len() => VerifyState::Ok,
None => VerifyState::Differs {
at: out.len().min(bytes.len()),
},
}
}
pub struct Family {
pub label: &'static str,
pub kinds: &'static [Fresh],
}
macro_rules! zeroed {
($body:ty, $len:expr, $format:expr, $versions:expr, $wrap:expr) => {{
let body = <$body>::try_from([0u8; $len]).map_err(|e| format!("{e}"))?;
let version = *$versions.last().ok_or("the format knows no version")?;
$wrap(Cbin {
header: Header::new($format, (0, 0), version),
body,
})
}};
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Fresh {
Program,
Live,
SetList,
Settings,
Stage2Program,
Stage3Program,
Stage3Synth,
Stage4Program,
Stage4Organ,
Stage4Piano,
Stage4Synth,
}
impl Fresh {
pub const ALL: [Fresh; 11] = [
Fresh::Program,
Fresh::Live,
Fresh::SetList,
Fresh::Settings,
Fresh::Stage2Program,
Fresh::Stage3Program,
Fresh::Stage3Synth,
Fresh::Stage4Program,
Fresh::Stage4Organ,
Fresh::Stage4Piano,
Fresh::Stage4Synth,
];
pub const FAMILIES: [Family; 4] = [
Family {
label: "Electro 5",
kinds: &[Fresh::Program, Fresh::Live, Fresh::SetList, Fresh::Settings],
},
Family {
label: "Stage 2",
kinds: &[Fresh::Stage2Program],
},
Family {
label: "Stage 3",
kinds: &[Fresh::Stage3Program, Fresh::Stage3Synth],
},
Family {
label: "Stage 4",
kinds: &[
Fresh::Stage4Program,
Fresh::Stage4Organ,
Fresh::Stage4Piano,
Fresh::Stage4Synth,
],
},
];
pub fn label(self) -> &'static str {
match self {
Fresh::Program | Fresh::Stage2Program | Fresh::Stage3Program | Fresh::Stage4Program => {
"Program"
}
Fresh::Live => "Live slot",
Fresh::SetList => "Set list",
Fresh::Settings => "Settings",
Fresh::Stage3Synth | Fresh::Stage4Synth => "Synth preset",
Fresh::Stage4Organ => "Organ preset",
Fresh::Stage4Piano => "Piano preset",
}
}
pub fn tag(self) -> &'static str {
match self {
Fresh::Program => ne5::program::FORMAT,
Fresh::Live => ne5::live::FORMAT,
Fresh::SetList => ne5::song::FORMAT,
Fresh::Settings => ne5::settings::FORMAT,
Fresh::Stage2Program => ns2::program::FORMAT,
Fresh::Stage3Program => ns3::program::FORMAT,
Fresh::Stage3Synth => ns3::synth::FORMAT,
Fresh::Stage4Program => ns4::program::FORMAT,
Fresh::Stage4Organ => ns4::organ_preset::FORMAT,
Fresh::Stage4Piano => ns4::piano_preset::FORMAT,
Fresh::Stage4Synth => ns4::synth::FORMAT,
}
}
pub fn zeroed(self) -> bool {
!matches!(
self,
Fresh::Program | Fresh::Live | Fresh::SetList | Fresh::Settings
)
}
pub fn note(self) -> Option<&'static str> {
self.zeroed().then_some(
"Every control at zero. The file decodes and re-saves byte for byte, but it \
is not a factory program — nothing here knows what one would hold.",
)
}
fn bytes(self) -> Result<Vec<u8>, String> {
let at = |slot: u16| -> Result<ne5::program::Location, String> {
(0, slot).try_into().map_err(|e| format!("{e}"))
};
let entity = match self {
Fresh::Program => Entity::Program(Program::Electro5(ne5::program::new(at(0)?))),
Fresh::Live => Entity::Live(Live::Electro5(ne5::live::new(
(0, 0).try_into().map_err(|e| format!("{e}"))?,
))),
Fresh::SetList => Entity::Song(Song::Electro5(ne5::song::new(
(0, 0).try_into().map_err(|e| format!("{e}"))?,
ne5::song::DEFAULT_VERSION,
[at(0)?, at(1)?, at(2)?, at(3)?],
))),
Fresh::Settings => Entity::Settings(Settings::Electro5(ne5::settings::new())),
Fresh::Stage2Program => zeroed!(
ns2::Program,
ns2::program::BODY_LEN,
ns2::program::FORMAT,
ns2::program::KNOWN_VERSIONS,
|f| Entity::Program(Program::Stage2(f))
),
Fresh::Stage3Program => zeroed!(
ns3::Program,
ns3::program::BODY_LEN,
ns3::program::FORMAT,
ns3::program::KNOWN_VERSIONS,
|f| Entity::Program(Program::Stage3(f))
),
Fresh::Stage3Synth => zeroed!(
ns3::SynthPreset,
ns3::synth::BODY_LEN,
ns3::synth::FORMAT,
ns3::synth::KNOWN_VERSIONS,
|f| Entity::Synth(Synth::Stage3(f))
),
Fresh::Stage4Program => zeroed!(
ns4::Program,
ns4::program::BODY_LEN,
ns4::program::FORMAT,
ns4::program::KNOWN_VERSIONS,
|f| Entity::Program(Program::Stage4(f))
),
Fresh::Stage4Organ => zeroed!(
ns4::organ_preset::OrganPreset,
ns4::organ_preset::BODY_LEN,
ns4::organ_preset::FORMAT,
ns4::organ_preset::KNOWN_VERSIONS,
|f| Entity::OrganPreset(OrganPreset::Stage4(f))
),
Fresh::Stage4Piano => zeroed!(
ns4::piano_preset::PianoPreset,
ns4::piano_preset::BODY_LEN,
ns4::piano_preset::FORMAT,
ns4::piano_preset::KNOWN_VERSIONS,
|f| Entity::PianoPreset(PianoPreset::Stage4(f))
),
Fresh::Stage4Synth => zeroed!(
ns4::synth::SynthPreset,
ns4::synth::BODY_LEN,
ns4::synth::FORMAT,
ns4::synth::KNOWN_VERSIONS,
|f| Entity::Synth(Synth::Stage4(f))
),
};
nord_format::to_bytes(&entity).map_err(|e| e.to_string())
}
}
pub struct Saved {
pub id: u64,
pub name: String,
pub origin: Origin,
pub bytes: Vec<u8>,
}
enum Incoming {
Opened { name: String, bytes: Vec<u8> },
Note(String),
Failed(String),
}
pub struct Workspace {
entities: Vec<LocalEntity>,
selected: Option<u64>,
next_id: u64,
revision: u64,
ctx: egui::Context,
tx: Sender<Incoming>,
rx: Receiver<Incoming>,
}
impl Workspace {
pub fn new(ctx: egui::Context) -> Workspace {
let (tx, rx) = std::sync::mpsc::channel();
Workspace {
entities: Vec::new(),
selected: None,
next_id: 1,
revision: 0,
ctx,
tx,
rx,
}
}
pub fn selected(&self) -> Option<&LocalEntity> {
let id = self.selected?;
self.entities.iter().find(|e| e.id == id)
}
pub fn select(&mut self, id: Option<u64>) {
self.selected = id;
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn entities(&self) -> &[LocalEntity] {
&self.entities
}
pub fn listed(&self) -> impl Iterator<Item = &LocalEntity> {
self.entities.iter().filter(|e| e.kept)
}
pub fn get(&self, id: u64) -> Option<&LocalEntity> {
self.entities.iter().find(|e| e.id == id)
}
pub fn is_view(&self, id: u64) -> bool {
self.get(id).is_some_and(|entity| !entity.kept)
}
pub fn view(&mut self, name: String, origin: Origin, bytes: Vec<u8>, log: &mut Log) -> u64 {
let id = self.ingest(name, origin, bytes, log);
let Some(entity) = self.entities.iter_mut().find(|e| e.id == id) else {
return id;
};
entity.kept = false;
let where_ = match entity.origin.slot() {
Some((class, at)) => crate::strings::place(class, at),
None => "the instrument".to_string(),
};
log.say(format!(
"Viewing {where_} — “{}” is not kept on this computer.",
entity.name
));
id
}
pub fn view_of(&self, class: ObjectClass, at: Location) -> Option<u64> {
self.entities
.iter()
.find(|e| !e.kept && e.origin.slot() == Some((class, at)))
.map(|e| e.id)
}
pub fn keep(&mut self, id: u64, log: &mut Log) {
let Some(entity) = self.entities.iter_mut().find(|e| e.id == id) else {
return;
};
if std::mem::replace(&mut entity.kept, true) {
return;
}
let name = entity.name.clone();
self.revision += 1;
log.say(format!("“{name}” is on this computer."));
}
pub fn close_views(&mut self, open: impl Fn(u64) -> bool, log: &mut Log) {
let mut rescued = Vec::new();
let before = self.entities.len();
self.entities.retain_mut(|entity| {
if entity.kept || open(entity.id) {
return true;
}
if !precious(entity) {
return false;
}
entity.kept = true;
rescued.push(entity.name.clone());
true
});
for name in &rescued {
log.say(format!(
"“{name}” is kept on this computer — it has changes the instrument does \
not."
));
}
if self.entities.len() == before && rescued.is_empty() {
return;
}
if self.selected.is_some_and(|id| self.get(id).is_none()) {
self.selected = self.entities.last().map(|e| e.id);
}
self.revision += 1;
}
pub fn mark_pending(&mut self, id: u64, pending: bool) {
if let Some(entity) = self.entities.iter_mut().find(|e| e.id == id) {
if entity.pending != pending {
entity.pending = pending;
self.revision += 1;
}
}
}
pub fn pending(&self) -> Vec<&LocalEntity> {
self.entities.iter().filter(|e| e.pending).collect()
}
pub fn rename(&mut self, id: u64, name: String) {
if let Some(entity) = self.entities.iter_mut().find(|e| e.id == id) {
entity.name = name;
self.revision += 1;
}
}
pub fn ingest(&mut self, name: String, origin: Origin, bytes: Vec<u8>, log: &mut Log) -> u64 {
let id = self.next_id;
self.next_id += 1;
let entity = LocalEntity::new(id, name, origin, bytes);
match (&entity.parse_error, &entity.verify) {
(Some(e), _) => {
log.error(format!("{}: {e}", entity.name));
log.trouble(format!(
"“{}” is not a file this app understands.",
entity.name
));
}
(None, VerifyState::Ok) => {
log.info(format!(
"{}: {} ({} bytes), verified",
entity.name,
entity.tag(),
entity.bytes.len(),
));
log.say(format!("“{}” is on this computer.", entity.name));
}
(None, state) => {
log.warn(format!(
"{}: {} — verify {}: {}",
entity.name,
entity.tag(),
state.badge(),
state.detail(),
));
log.say(format!(
"“{}” opened, but it does not re-save byte for byte.",
entity.name
));
}
}
self.entities.push(entity);
self.selected = Some(id);
self.revision += 1;
id
}
pub fn poll(&mut self, log: &mut Log) {
while let Ok(message) = self.rx.try_recv() {
match message {
Incoming::Opened { name, bytes } => {
self.ingest(name.clone(), Origin::File(name), bytes, log);
}
Incoming::Note(text) => log.say(text),
Incoming::Failed(text) => log.trouble(text),
}
}
}
pub fn open_dialog(&self) {
let tx = self.tx.clone();
let ctx = self.ctx.clone();
spawn(async move {
let picked = rfd::AsyncFileDialog::new()
.set_title("Open Nord files")
.pick_files()
.await;
for handle in picked.unwrap_or_default() {
let bytes = handle.read().await;
let _ = tx.send(Incoming::Opened {
name: handle.file_name(),
bytes,
});
}
ctx.request_repaint();
});
}
pub fn export_name(&self, id: u64) -> Option<String> {
let entity = self.get(id)?;
Some(export_filename(&entity.name, &entity.bytes))
}
pub fn export(&self, id: u64) {
let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
return;
};
let name = match self.export_name(id) {
Some(name) => name,
None => return,
};
let bytes = entity.bytes.clone();
let tx = self.tx.clone();
let ctx = self.ctx.clone();
spawn(async move {
let _ = tx.send(save(name, bytes).await);
ctx.request_repaint();
});
}
pub fn restore_bytes(&mut self, id: u64, bytes: Vec<u8>, log: &mut Log) {
let Some(entity) = self.entities.iter_mut().find(|e| e.id == id) else {
return;
};
if entity.bytes == bytes {
return;
}
*entity = LocalEntity {
kept: entity.kept,
..LocalEntity::new(id, entity.name.clone(), entity.origin.clone(), bytes)
};
self.revision += 1;
log.say(format!("“{}” is back as it was opened.", entity.name));
}
pub fn replace_bytes(&mut self, id: u64, bytes: Vec<u8>, log: &mut Log) {
let Some(entity) = self.entities.iter_mut().find(|e| e.id == id) else {
return;
};
let replaced = LocalEntity::new(id, entity.name.clone(), entity.origin.clone(), bytes);
let verify = replaced.verify.clone();
*entity = LocalEntity {
dirty: true,
pending: entity.pending,
kept: entity.kept,
..replaced
};
self.revision += 1;
if let VerifyState::Ok = verify {
return;
}
log.warn(format!(
"after editing, verify {}: {}",
verify.badge(),
verify.detail()
));
}
pub fn duplicate(&mut self, id: u64, log: &mut Log) -> Option<u64> {
let source = self.entities.iter().find(|e| e.id == id)?;
let name = format!("{} copy", source.name);
let (origin, bytes) = (source.origin.clone(), source.bytes.clone());
Some(self.ingest(name, origin, bytes, log))
}
pub fn remove(&mut self, id: u64, log: &mut Log) {
let Some(at) = self.entities.iter().position(|e| e.id == id) else {
return;
};
let gone = self.entities.remove(at);
self.revision += 1;
if self.selected == Some(id) {
self.selected = self.entities.last().map(|e| e.id);
}
log.say(format!("Removed “{}” from this computer.", gone.name));
}
pub fn next_id(&self) -> u64 {
self.next_id
}
pub fn restore(&mut self, saved: Vec<Saved>, next_id: Option<u64>, log: &mut Log) {
for Saved {
id,
name,
origin,
bytes,
} in saved
{
let entity = LocalEntity::new(id, name, origin, bytes);
if let Some(e) = &entity.parse_error {
log.warn(format!("{}: {e}", entity.name));
}
self.next_id = self.next_id.max(id + 1);
self.entities.push(entity);
}
if let Some(next) = next_id {
self.next_id = self.next_id.max(next);
}
self.selected = self.entities.last().map(|e| e.id);
self.revision += 1;
}
pub fn create(&mut self, kind: Fresh, log: &mut Log) -> Option<u64> {
match kind.bytes() {
Ok(bytes) => {
let name = format!("untitled.{}", kind.tag());
Some(self.ingest(name, Origin::Fresh, bytes, log))
}
Err(e) => {
log.error(format!("new {}: {e}", kind.label()));
log.trouble(format!("Could not make a new {}.", kind.label()));
None
}
}
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn save(name: String, bytes: Vec<u8>) -> Incoming {
let Some(handle) = rfd::AsyncFileDialog::new()
.set_file_name(&name)
.save_file()
.await
else {
return Incoming::Note(format!("{name}: save cancelled"));
};
match handle.write(&bytes).await {
Ok(()) => Incoming::Note(format!(
"wrote {} ({} bytes)",
handle.file_name(),
bytes.len(),
)),
Err(e) => Incoming::Failed(format!("{name}: {e}")),
}
}
#[cfg(target_arch = "wasm32")]
async fn save(name: String, bytes: Vec<u8>) -> Incoming {
match download(&name, &bytes) {
Ok(()) => Incoming::Note(format!("downloaded {name} ({} bytes)", bytes.len())),
Err(e) => Incoming::Failed(format!("{name}: {e:?}")),
}
}
#[cfg(target_arch = "wasm32")]
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
let document = web_sys::window()
.and_then(|w| w.document())
.ok_or_else(|| JsValue::from_str("no document"))?;
let parts = js_sys::Array::new();
parts.push(&js_sys::Uint8Array::from(bytes).into());
let options = web_sys::BlobPropertyBag::new();
options.set_type("application/octet-stream");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options)?;
let url = web_sys::Url::create_object_url_with_blob(&blob)?;
let anchor: web_sys::HtmlAnchorElement = document.create_element("a")?.unchecked_into();
anchor.set_href(&url);
anchor.set_download(name);
anchor.click();
web_sys::Url::revoke_object_url(&url)?;
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn spawn<F: std::future::Future<Output = ()> + Send + 'static>(future: F) {
std::thread::spawn(move || nord_usb::block_on(future));
}
#[cfg(target_arch = "wasm32")]
fn spawn<F: std::future::Future<Output = ()> + 'static>(future: F) {
wasm_bindgen_futures::spawn_local(future);
}
#[cfg(test)]
mod tests {
use super::*;
fn ingest(name: &str, bytes: Vec<u8>) -> LocalEntity {
LocalEntity::new(1, name.into(), Origin::Fresh, bytes)
}
#[test]
fn a_fresh_program_decodes_and_verifies() {
let entity = ingest("untitled.ne5p", Fresh::Program.bytes().unwrap());
assert!(entity.parse_error.is_none());
assert_eq!(entity.tag(), "ne5p");
assert!(
matches!(entity.verify, VerifyState::Ok),
"{}",
entity.verify.detail()
);
let container = entity.container.expect("a fresh program is a CBIN file");
assert!(container.checksum_ok);
assert_eq!(container.header.generation, Generation::V1);
assert_eq!(container.body_len, ne5::program::BODY_LEN as u64);
assert_eq!(container.checksum_label, "crc32:");
}
#[test]
fn every_fresh_default_round_trips_under_its_own_tag() {
for kind in Fresh::ALL {
let entity = ingest("untitled", kind.bytes().unwrap());
assert!(entity.parse_error.is_none(), "{:?}", kind);
assert_eq!(entity.tag(), kind.tag(), "{kind:?}");
assert!(matches!(entity.verify, VerifyState::Ok), "{kind:?}");
assert!(
entity.container.expect("a CBIN file").checksum_ok,
"{kind:?}"
);
}
}
#[test]
fn every_kind_sits_in_exactly_one_family() {
let mut seen: Vec<Fresh> = Fresh::FAMILIES
.iter()
.flat_map(|family| family.kinds.iter().copied())
.collect();
assert_eq!(seen.len(), Fresh::ALL.len());
for kind in Fresh::ALL {
let at = seen.iter().position(|held| *held == kind);
seen.remove(at.unwrap_or_else(|| panic!("{kind:?} is in no family")));
}
assert!(seen.is_empty());
}
#[test]
fn a_zeroed_body_says_that_it_is_one() {
assert!(!Fresh::Program.zeroed() && Fresh::Program.note().is_none());
for kind in Fresh::ALL.iter().filter(|kind| kind.zeroed()) {
let note = kind.note().unwrap_or_else(|| panic!("{kind:?}"));
assert!(note.contains("zero"), "{kind:?}: {note}");
}
}
#[test]
fn no_two_kinds_share_a_tag() {
let mut tags: Vec<&str> = Fresh::ALL.iter().map(|kind| kind.tag()).collect();
tags.sort_unstable();
let held = tags.len();
tags.dedup();
assert_eq!(tags.len(), held);
}
#[test]
fn a_view_is_not_on_this_computer_until_it_is_kept() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
let at = Location { bank: 6, slot: 3 };
let device = || Origin::Device {
class: ObjectClass::Program,
at,
};
let viewed = workspace.view(
"Africa-Split.ne5p".into(),
device(),
Fresh::Program.bytes().unwrap(),
&mut log,
);
let copied = workspace.ingest(
"Squabble-B.ne5p".into(),
device(),
Fresh::Program.bytes().unwrap(),
&mut log,
);
assert!(workspace.is_view(viewed) && !workspace.is_view(copied));
let listed: Vec<u64> = workspace.listed().map(|e| e.id).collect();
assert_eq!(listed, vec![copied], "a view is not in the local list");
assert!(workspace.get(viewed).is_some());
assert_eq!(workspace.entities().len(), 2);
let edited = workspace.get(viewed).unwrap().bytes.clone();
workspace.replace_bytes(viewed, [edited, vec![]].concat(), &mut log);
assert!(workspace.is_view(viewed));
workspace.keep(viewed, &mut log);
assert!(!workspace.is_view(viewed));
assert_eq!(workspace.listed().count(), 2);
}
#[test]
fn a_view_goes_when_the_last_tab_on_it_closes() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
let at = Location { bank: 6, slot: 3 };
let viewed = workspace.view(
"Africa-Split.ne5p".into(),
Origin::Device {
class: ObjectClass::Program,
at,
},
Fresh::Program.bytes().unwrap(),
&mut log,
);
let local = workspace.create(Fresh::Program, &mut log).unwrap();
workspace.close_views(|id| id == viewed, &mut log);
assert!(workspace.get(viewed).is_some(), "its tab is still open");
workspace.close_views(|_| false, &mut log);
assert!(workspace.get(viewed).is_none());
assert!(workspace.get(local).is_some(), "kept is kept");
assert_eq!(workspace.selected().map(|e| e.id), Some(local));
}
#[test]
fn a_view_with_changes_in_it_is_kept_rather_than_dropped() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
let at = |slot| Location { bank: 6, slot };
let view = |workspace: &mut Workspace, slot, log: &mut Log| {
workspace.view(
format!("view-{slot}.ne5p"),
Origin::Device {
class: ObjectClass::Program,
at: at(slot),
},
Fresh::Program.bytes().unwrap(),
log,
)
};
let edited = view(&mut workspace, 0, &mut log);
let owed = view(&mut workspace, 1, &mut log);
let untouched = view(&mut workspace, 2, &mut log);
let bytes = workspace.get(edited).unwrap().bytes.clone();
workspace.replace_bytes(edited, [bytes, vec![0]].concat(), &mut log);
workspace.mark_pending(owed, true);
assert!(precious(workspace.get(edited).unwrap()));
assert!(precious(workspace.get(owed).unwrap()));
assert!(!precious(workspace.get(untouched).unwrap()));
workspace.close_views(|_| false, &mut log);
assert!(workspace.get(untouched).is_none(), "the slot still has it");
let listed: Vec<u64> = workspace.listed().map(|e| e.id).collect();
assert_eq!(listed, vec![edited, owed], "and the changes survive");
assert!(!workspace.is_view(edited) && !workspace.is_view(owed));
assert!(workspace.get(owed).unwrap().pending);
assert!(log.status().1.contains("kept on this computer"));
}
#[test]
fn a_slot_has_at_most_one_view() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
let at = Location { bank: 6, slot: 3 };
let elsewhere = Location { bank: 6, slot: 4 };
let class = ObjectClass::Program;
assert_eq!(workspace.view_of(class, at), None);
let id = workspace.view(
"Africa-Split.ne5p".into(),
Origin::Device { class, at },
Fresh::Program.bytes().unwrap(),
&mut log,
);
assert_eq!(workspace.view_of(class, at), Some(id));
assert_eq!(workspace.view_of(class, elsewhere), None);
assert_eq!(workspace.view_of(ObjectClass::SetList, at), None);
workspace.ingest(
"Africa-Split.ne5p".into(),
Origin::Device { class, at },
Fresh::Program.bytes().unwrap(),
&mut log,
);
assert_eq!(workspace.view_of(class, at), Some(id));
workspace.keep(id, &mut log);
assert_eq!(workspace.view_of(class, at), None);
}
#[test]
fn the_raw_body_export_drops_the_container_header() {
let entity = ingest("untitled.ne5p", Fresh::Program.bytes().unwrap());
let body = entity.raw_body().expect("a CBIN file has a body");
assert_eq!(body.len(), ne5::program::BODY_LEN);
assert_eq!(
body.as_slice(),
&entity.bytes[Generation::V1.body_start() as usize..],
);
}
#[test]
fn an_export_sanitises_the_name_and_supplies_the_extension() {
let bytes = Fresh::Program.bytes().unwrap();
let file = |name: &str| export_filename(name, &bytes);
assert_eq!(file("Big strings"), "Big-strings.ne5p");
assert_eq!(file("patch.ne5p"), "patch.ne5p", "a carried tag is kept");
assert_eq!(file("Bass 2.0"), "Bass-2.0.ne5p", "a dot is not a tag");
assert_eq!(file("../../etc/passwd"), "etc-passwd.ne5p");
assert_eq!(file(" "), "unnamed.ne5p");
assert_eq!(
export_filename("Big strings", b"no header"),
"Big-strings.bin",
);
}
#[test]
fn bytes_that_do_not_decode_are_kept_with_their_error() {
let entity = ingest("junk.bin", b"not a nord file at all".to_vec());
assert!(entity.entity.is_none());
assert!(entity.parse_error.is_some());
assert!(entity.container.is_none());
assert!(matches!(entity.verify, VerifyState::NotApplicable(_)));
assert_eq!(entity.tag(), "?");
}
#[test]
fn a_name_survives_being_fetched_opened_edited_and_exported() {
use nord_usb::{Location, ObjectClass};
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
let at = Location { bank: 6, slot: 3 };
let id = workspace.ingest(
"Africa-Split.ne5p".into(),
Origin::Device {
class: ObjectClass::Program,
at,
},
Fresh::Program.bytes().unwrap(),
&mut log,
);
assert_eq!(workspace.get(id).unwrap().name, "Africa-Split.ne5p");
let bytes = workspace.get(id).unwrap().bytes.clone();
let (_, edited) =
crate::fields::apply(&bytes, &[("center_panel.gain".into(), "96".into())]).unwrap();
workspace.replace_bytes(id, edited, &mut log);
assert_eq!(workspace.get(id).unwrap().name, "Africa-Split.ne5p");
assert!(workspace.get(id).unwrap().dirty);
workspace.restore_bytes(id, bytes, &mut log);
assert_eq!(workspace.get(id).unwrap().name, "Africa-Split.ne5p");
assert_eq!(
workspace.export_name(id).as_deref(),
Some("Africa-Split.ne5p")
);
}
#[test]
fn a_restored_asset_keeps_what_it_was() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
workspace.restore(
vec![Saved {
id: 9,
name: "Africa-Split.ne5p".into(),
origin: Origin::Fresh,
bytes: Fresh::Program.bytes().unwrap(),
}],
Some(10),
&mut log,
);
let entity = workspace.get(9).expect("restored under its own id");
assert_eq!(entity.name, "Africa-Split.ne5p");
assert!(matches!(entity.verify, VerifyState::Ok));
assert!(!entity.dirty);
let fresh = workspace.create(Fresh::Live, &mut log).unwrap();
assert!(fresh >= 10);
}
#[test]
fn a_tampered_body_byte_is_reported() {
let mut bytes = Fresh::Program.bytes().unwrap();
let at = Generation::V1.body_start() as usize + 0x30;
bytes[at] ^= 0xff;
let entity = ingest("tampered.ne5p", bytes);
assert!(entity.parse_error.is_some());
assert!(!entity.container.expect("still a CBIN file").checksum_ok);
}
}