use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use reactive_core::drain_tasks;
use renderer_assets::SvgData;
use telar_dynamic::{AssetKey, AssetTransport, DiskCache, HttpTransport, Reply, SvgDecoder};
use ui_core::{AssetCache, AssetLoader, AssetState};
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2 2 H22 V22 H2 Z"/></svg>"#;
struct Server {
port: u16,
hits: Arc<AtomicUsize>,
}
impl Server {
fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
let port = listener.local_addr().unwrap().port();
let hits = Arc::new(AtomicUsize::new(0));
let served = Arc::clone(&hits);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { return };
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let found = String::from_utf8_lossy(&buf).contains("/known.svg");
let response = if found {
served.fetch_add(1, Ordering::Relaxed);
format!(
"HTTP/1.1 200 OK\r\nContent-Type: image/svg+xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{SVG}",
SVG.len()
)
} else {
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.to_string()
};
let _ = stream.write_all(response.as_bytes());
}
});
Self { port, hits }
}
fn template(&self) -> String {
format!("http://127.0.0.1:{}/{{name}}.svg", self.port)
}
}
struct FixedTransport(Vec<u8>);
impl AssetTransport for FixedTransport {
fn load(&self, _key: &AssetKey, reply: Reply) {
reply(Ok(self.0.clone()));
}
}
struct WatchedCache {
inner: DiskCache,
puts: Mutex<Vec<AssetKey>>,
}
impl AssetCache for WatchedCache {
fn get(&self, key: &AssetKey) -> Option<Vec<u8>> {
self.inner.get(key)
}
fn put(&self, key: &AssetKey, bytes: &[u8]) {
self.puts.lock().unwrap().push(key.clone());
self.inner.put(key, bytes);
}
}
fn loader(
transport: Arc<dyn AssetTransport>,
cache: Option<Arc<dyn AssetCache>>,
) -> AssetLoader<Arc<SvgData>> {
AssetLoader::new(transport, cache, Arc::new(SvgDecoder))
}
fn settle(
loader: &AssetLoader<Arc<SvgData>>,
id: &str,
timeout: Duration,
) -> AssetState<Arc<SvgData>> {
let deadline = Instant::now() + timeout;
loop {
drain_tasks();
let state = loader.get(id).get();
if !matches!(state, AssetState::Loading) || Instant::now() > deadline {
return state;
}
std::thread::sleep(Duration::from_millis(5));
}
}
fn name_of(state: &AssetState<Arc<SvgData>>) -> &'static str {
match state {
AssetState::Loading => "Loading",
AssetState::Ready(_) => "Ready",
AssetState::Failed => "Failed",
}
}
fn cache_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("telar-http-assets-{name}"));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn a_fetched_asset_lands_in_its_signal_and_then_in_the_cache() {
let server = Server::start();
let dir = cache_dir("fetched");
let icons = loader(
Arc::new(HttpTransport::new(server.template())),
Some(Arc::new(DiskCache::new(&dir))),
);
assert!(
matches!(icons.get("known").get(), AssetState::Loading),
"the first read must return without waiting for the network"
);
let state = settle(&icons, "known", Duration::from_secs(10));
assert!(
state.is_ready(),
"asset did not resolve: {}",
name_of(&state)
);
assert_eq!(server.hits.load(Ordering::Relaxed), 1);
assert!(
dir.join("svg").join("known").exists(),
"download was not cached under <root>/<kind>/<name>"
);
let offline = loader(
Arc::new(HttpTransport::new(server.template())),
Some(Arc::new(DiskCache::new(&dir))),
);
assert!(
settle(&offline, "known", Duration::from_secs(10)).is_ready(),
"the fetched asset settles into its signal"
);
assert_eq!(
server.hits.load(Ordering::Relaxed),
1,
"the cached asset was downloaded again"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_asset_the_endpoint_does_not_have_settles_on_failed() {
let server = Server::start();
let dir = cache_dir("missing");
let icons = loader(
Arc::new(HttpTransport::new(server.template()).with_retry(2, Duration::from_millis(20))),
Some(Arc::new(DiskCache::new(&dir))),
);
let state = settle(&icons, "absent", Duration::from_secs(10));
assert!(
matches!(state, AssetState::Failed),
"a missing asset must settle rather than spin forever, got {}",
name_of(&state)
);
assert!(
!dir.join("svg").join("absent").exists(),
"a failed fetch must not be cached"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn repeated_reads_share_one_request() {
let server = Server::start();
let dir = cache_dir("shared");
let icons = loader(
Arc::new(HttpTransport::new(server.template())),
Some(Arc::new(DiskCache::new(&dir))),
);
for _ in 0..5 {
let _ = icons.get("known").get();
}
assert!(
settle(&icons, "known", Duration::from_secs(10)).is_ready(),
"every reader of the key settles on the shared result"
);
assert_eq!(server.hits.load(Ordering::Relaxed), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_body_that_does_not_decode_never_reaches_the_cache() {
let dir = cache_dir("undecodable");
let cache = Arc::new(WatchedCache {
inner: DiskCache::new(&dir),
puts: Mutex::new(Vec::new()),
});
let icons = loader(
Arc::new(FixedTransport(b"<html>404 Not Found</html>".to_vec())),
Some(Arc::clone(&cache) as Arc<dyn AssetCache>),
);
let state = settle(&icons, "known", Duration::from_secs(10));
assert!(
matches!(state, AssetState::Failed),
"a body that is not an SVG must fail, got {}",
name_of(&state)
);
assert!(
cache.puts.lock().unwrap().is_empty(),
"a decode failure reached AssetCache::put"
);
assert!(
!dir.join("svg").join("known").exists(),
"a body that does not decode must not be cached"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_body_that_decodes_does_reach_the_cache() {
let dir = cache_dir("decodable");
let icons = loader(
Arc::new(FixedTransport(SVG.as_bytes().to_vec())),
Some(Arc::new(DiskCache::new(&dir))),
);
assert!(
settle(&icons, "known", Duration::from_secs(10)).is_ready(),
"a body that decodes settles ready"
);
assert_eq!(
std::fs::read(dir.join("svg").join("known")).expect("the decoded body was cached"),
SVG.as_bytes()
);
let _ = std::fs::remove_dir_all(&dir);
}