Squashed from work: - asset-ai: FastH3 4-step fast video backend; clip keyframes on the wire - asset-ui: loop video chains — text→image→video that ends where it began - h3: safetensors -> pruned-Q4_K GGUF quantizer for the 24GB DiT tiers - h3_quant_gguf verify: row-error gates calibrated to the measured Q4_K floor - asset-ai realtime: the feedback loop — the source anchors, the drifted frame inits - asset-ai realtime: a feedback loop survives a resize and travels by default - asset-ai realtime: the feedback loop frees itself from the feed handshake and pauses for its listener - asset-ai realtime: the outbound encode leaves the loop's critical path - asset-ai ocr: the ocr domain — Chandra 2 at page resolution, and the tower goes planner-owned - llm slots: a lane can hold an image span — embedding prefill and a rope cursor of its own - vision tower on CUDA: the encode leg gets its two missing kernels - llm/ocr: one M-RoPE grid encoder for both image paths, and a livelock made an error - vision tower on CUDA: the f16 GEMM keeps the precision it was throwing away - live: a feed that moves box takes its trip with it — one seed image - vision tower on CUDA: the tiled attention becomes bit-exact, and tensor cores go - llm prefill on CUDA: the MMA attention kernel gets the tile a 4-to-1 model needs - asset-ai ocr: the CUDA encode lane joins the integration — vision-parity sits beside run's three arms, and the kernels - Merge branch 'ocr-perf-integration' into work - asset-ai: the live anchor can follow the trip, and text leaves the 5090 - asset-ai: the camera moves the world, and the world starts still - asset-import: the EA strategy classics, in the one 2D contract - rtsmap: one seeded generator for tiled strategy maps - asset-ui: one card for the strategy classics, with a pack dropdown - asset-ai: music3 reference-audio path, ocr/h3 backends, registry - asset: mp4 sample index for range-streaming, chat tools, import profiles - cnc: tiberium is twelve growth frames, not twelve empty variants - platform: native file and save dialogs, in-house on all three desktops - chat: the scan holds out for a lane home - chat: a full home queues you — take the free lane - chat: the preload has a percentage, and the boundless cap stops showing - llm cuda: the 32x2 attention tile — even GQA ratios stay on MMA - sa3 gets a bake path: the sfx model's tables precomputed by a diffusion-side bin - sqlite_query: anti-join regression test - td import: HARV's second frame block is its harvesting cycle, not a turret - asset-ui: sprite enhancement runs on the 32B dev DiT — distillation, not the prompt, was the ceiling - ai-hub: makepad-asset-ai becomes makepad-ai-hub at libs/ai/hub, the chat pane becomes makepad-chat-ui, the service bin - asset-ui: test health fixtures grow the realtime field they were born without - ai-hub: one home at ~/.makepad — weights/ run/ cache/ logs/, the service cache migrates from ai_content by a single re - ai-hub: subprocess workers die with the node — process groups everywhere, PDEATHSIG on linux, one KILL_ON_JOB_CLOSE Jo - ai-hub: the hub object — AiHub::in_process, pipes vocabulary, and the local LLM engine generalized out of mpfiles (aic - strict-json: the dependency-free JSON module gets its own crate; asset-client re-exports it so nothing downstream move - ai-hub: the machine layer — node entries, the 0600 machine token, and the residency election that IS the lock (aicore - ai-hub: MPHUB1 — the fabric beacon only dedicated nodes can send (aicore §4) - ai-hub: job leases — work lives only while it is renewed (aicore §8) - asset-creator: the pipeline library is born — specs, the deps gate, and the derived-state law (aicore §9) - ai-hub: RAM residency facts — the CPU-side twin of residency.rs (aicore §3) - ai-hub: ETA placement primitives — relative GPU throughput, the four-term estimate, and an observable breakdown (aicor - ai-hub: leases go live on the wire — origin fields on submit, /job/<id>/keepalive, /bye, and the reaper that cancels w - ai-hub: the chat providers move in — fleet qwen, openai, grok, claude/codex/grok CLIs, the responses driver, and the w - asset-creator: the engine — one pipeline run against the hub, deps-gated, spliced, cancellable, resumable-by-construct - ai-hub: the machine node mode — --machine binds loopback, registers in ~/.makepad/run, and exits on its own once idle - asset-creator: makepad-creator-run — the detached client for runs that must outlive a window (aicore §9) - ai-hub: a native Claude Messages-API provider — API-key or Claude Code OAuth, bounded SSE streaming, injected tools (a - route + converse: off makepad_ai — the Agent seam moves to converse, route's cloud dispatcher rides the hub's Claude p - asset-creator: the preset tables move in — fifteen chain-policy constants shared by every creator app (aicore §9 / P6) - makepad_ai is deleted — every backend is a hub pipe, the agent seam lives with its consumers (aicore §14, decided 2026 - ai-hub: loads hold the machine residency election — set_model_state claims on Loaded and publishes the service port (a - ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open - ai-hub: pick_for_domain_eta — ETA-ranked placement over the shared hard-filter core (aicore §6 / P4) - asset-creator: the engine picks a provider per stage at dispatch time — a chain's later stages see fresh fleet state ( - ai-hub: the fabric secret gates the service HTTP surface — bearer on everything but /health and the ticketed peer path - vj: DREAM runs execute in the app — pipelines.rs becomes the run it used to watch (aicore §9 / F1) - asset-creator: the runner — generate one thing and put it in the catalog, one implementation for every surface (aicore - chat-ui: the session runs in the app — no broker anywhere on the chat path (aicore P8 / F5) - asset-store: assets.query is a first-class query endpoint — the bounded SQL surface outlives the broker (aicore P8 / F - asset-creator: CreatorTools — the chat tool pack for a store that only stores (aicore §9 / P8) - asset-store: the shrink — the store stores (aicore P7) - importer + asset-server host: the coordination era ends (aicore P7) - store config purge + asset-ui goes fleet-direct; the derive protocol gets its route proof (aicore P7) - client + chat dispatcher: the dead wire comes out (aicore P7/P8) - ai-hub: 0.3.0 — the health version says which era a node runs - ai-hub: the default fleet is 'gen' — apps hear the LAN without env plumbing - ai-hub: the preload note percents the prefill, not the job bar - ai-hub: conversations keep their KV — the wire mirror, the lane identity, the in-turn dynamic context (aicore §7) - ai-hub: an open-think model is thinking from its first token - libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly - zero-warning sweep, round two — the first full-workspace pass - zero-warning sweep, round three — the model lanes and the deep examples - zero-warning sweep, round four — the last stragglers - zero-warning sweep, round five — vj and chat-ui - zero-warning sweep, round six — three cascades Co-authored-by: Claude <info@makepad.nl>
393 lines
16 KiB
Rust
393 lines
16 KiB
Rust
//! THE wire-contract proof: the real `makepad-asset-client` against a
|
|
//! real `AssetServer`, over real sockets — connect, listing probe, publish,
|
|
//! search, detail, event subscription, jobs and the worker protocol, all in
|
|
//! one flow. No fixtures, no mocks, on either side.
|
|
//!
|
|
//! This also pins the root-authorization semantics: the bootstrap admin
|
|
//! token (the documented default credential) can provision and exercise the
|
|
//! whole server — upload, register, annotate, publish, alias, enqueue,
|
|
//! claim, cancel — without any explicit self-grants.
|
|
|
|
use makepad_asset_store::{AssetServer, ServerConfig};
|
|
use makepad_asset_client::{
|
|
ApiEndpoints, AssetClient, CatalogQuery, CatalogSubscriberConfig, CatalogSubscriptionEvent,
|
|
ClientConfig, ClientError, PublishFile, PublishRequest, PublishThumbnail,
|
|
RoomClaimDto,
|
|
};
|
|
use makepad_asset_data::{
|
|
AssetAlias, AssetId, AssetKind, AssetRevisionRef, ContentLock, FileRole, GameAlias,
|
|
GameRevisionManifest, LockEntry, MediaType, ThumbnailMedia, ThumbnailMeta,
|
|
};
|
|
use std::path::PathBuf;
|
|
use std::str::FromStr;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
|
|
static DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
fn test_root(name: &str) -> PathBuf {
|
|
let n = DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
std::env::temp_dir().join(format!(
|
|
"mp_asset_e2e_{}_{}_{}",
|
|
std::process::id(),
|
|
n,
|
|
name
|
|
))
|
|
}
|
|
|
|
fn start_server(name: &str) -> (AssetServer, String) {
|
|
let root = test_root(name);
|
|
let mut cfg = ServerConfig::new(root.clone());
|
|
cfg.control_addr = "127.0.0.1:0".parse().unwrap();
|
|
cfg.data_addr = "127.0.0.1:0".parse().unwrap();
|
|
cfg.bootstrap_admin = true;
|
|
cfg.log = false;
|
|
let server = AssetServer::start(cfg).expect("server start");
|
|
let token = std::fs::read_to_string(root.join("admin-token"))
|
|
.expect("admin token")
|
|
.trim()
|
|
.to_string();
|
|
(server, token)
|
|
}
|
|
|
|
fn connect(server: &AssetServer, token: &str, cache: &str) -> AssetClient {
|
|
let mut cfg = ClientConfig::new(test_root(cache));
|
|
cfg.token = Some(token.to_string());
|
|
let endpoints = ApiEndpoints {
|
|
control: server.control_addr(),
|
|
data: server.data_addr(),
|
|
};
|
|
AssetClient::connect(cfg, endpoints, Some(server.server_id()))
|
|
.expect("real connect (health + credentialed listing probe)")
|
|
}
|
|
|
|
#[test]
|
|
fn real_client_full_stack_roundtrip() {
|
|
let (server, token) = start_server("full_stack");
|
|
let mut client = connect(&server, &token, "full_stack_cache");
|
|
|
|
// Empty catalog: the listing route answers honestly.
|
|
let page = client.assets_page(None, None, 10).expect("listing");
|
|
assert!(page.assets.is_empty());
|
|
assert!(page.next.is_none());
|
|
|
|
// Subscribe to committed catalog events BEFORE publishing.
|
|
let mut sub_cfg = CatalogSubscriberConfig::default_v1();
|
|
sub_cfg.wait_ms = 2_000;
|
|
let mut subscriber = client.subscribe_catalog(sub_cfg).expect("subscribe");
|
|
|
|
// Publish a video artifact end to end with the ADMIN token (root
|
|
// bypass: no explicit grants were ever made).
|
|
let artifact = vec![0xAB; 6_000];
|
|
let thumb = vec![0xCD; 1_500];
|
|
let mut request = PublishRequest::new(
|
|
"gen",
|
|
AssetKind::Video,
|
|
"E2E neon clip",
|
|
PublishFile {
|
|
bytes: artifact.clone(),
|
|
media: MediaType::Mp4,
|
|
role: FileRole::Video,
|
|
media_millis: 5_200,
|
|
dims: None,
|
|
},
|
|
PublishThumbnail {
|
|
bytes: thumb.clone(),
|
|
media: ThumbnailMedia::Png,
|
|
width: 512,
|
|
height: 512,
|
|
views: Vec::new(),
|
|
},
|
|
);
|
|
request.alias = Some(AssetAlias::from_str("gen/e2e-neon").unwrap());
|
|
request.categories = vec!["demo".into()];
|
|
request.prompt = "a neon e2e clip".into();
|
|
let published = client.publish_artifact(&request).expect("publish via admin token");
|
|
|
|
// Search (server-side kind filter) finds it.
|
|
let mut query = CatalogQuery::browse(10);
|
|
query.kind = Some(AssetKind::Video);
|
|
let found = client.catalog_search(&query, None).expect("search");
|
|
assert!(found.hits.iter().any(|h| h.asset_id == published.asset_id));
|
|
let hit = found.hits.iter().find(|h| h.asset_id == published.asset_id).unwrap();
|
|
assert_eq!(hit.kind, Some(AssetKind::Video));
|
|
assert_eq!(hit.title, "E2E neon clip");
|
|
|
|
// Full detail: candidates carry the published revision.
|
|
let detail = client.asset_detail(&published.asset_id).expect("detail");
|
|
assert_eq!(
|
|
detail.latest_published().expect("published candidate").revision,
|
|
published.revision
|
|
);
|
|
|
|
// Manifest + blob bytes round-trip through the verified cache.
|
|
let manifest = client.fetch_asset_manifest(&published.revision).expect("manifest");
|
|
assert_eq!(manifest.asset_id, published.asset_id);
|
|
let bytes = client
|
|
.fetch_blob_bytes(&published.artifact_blob, Some(artifact.len() as u64))
|
|
.expect("blob");
|
|
assert_eq!(bytes, artifact);
|
|
|
|
// The same real client publishes and reads an exact game revision through
|
|
// the public `/v1/game-revisions/{grev}` contract. This specifically
|
|
// guards the Sandbox bootstrap path against fixture-only routes.
|
|
let game = client.register_game("gen", None).expect("register game");
|
|
let locked_alias = AssetAlias::from_str("gen/e2e-neon").unwrap();
|
|
let locked = AssetRevisionRef {
|
|
asset_id: published.asset_id,
|
|
revision: published.revision,
|
|
};
|
|
let lock = ContentLock {
|
|
game_id: game,
|
|
entries: vec![LockEntry {
|
|
alias: locked_alias,
|
|
asset_id: locked.asset_id,
|
|
revision: locked.revision,
|
|
}],
|
|
closure: vec![locked],
|
|
variant_sets: Vec::new(),
|
|
};
|
|
let lock_bytes = lock.to_canonical_bytes().expect("lock bytes");
|
|
let splash = b"game { model: gen/e2e-neon }".to_vec();
|
|
let game_toml = b"[game]\nname=\"E2E\"\n".to_vec();
|
|
let game_thumb = vec![0xE2; 1_024];
|
|
let splash_blob = client.upload_blob("gen", &splash).expect("game splash blob");
|
|
let manifest_blob = client
|
|
.upload_blob("gen", &game_toml)
|
|
.expect("game toml blob");
|
|
let lock_blob = client.upload_blob("gen", &lock_bytes).expect("game lock blob");
|
|
let thumb_blob = client
|
|
.upload_blob("gen", &game_thumb)
|
|
.expect("game thumbnail blob");
|
|
let game_manifest = GameRevisionManifest {
|
|
game_id: game,
|
|
name: "E2E generated world".into(),
|
|
description: "Real client/server game revision roundtrip".into(),
|
|
author: "Asset Server test".into(),
|
|
splash_blob,
|
|
manifest_blob,
|
|
lock_blob,
|
|
thumbnail: ThumbnailMeta {
|
|
blob: thumb_blob,
|
|
media: ThumbnailMedia::Png,
|
|
width: 512,
|
|
height: 512,
|
|
byte_len: game_thumb.len() as u64,
|
|
views: Vec::new(),
|
|
},
|
|
catalog_snapshot: None,
|
|
search_algorithm_version: 1,
|
|
engine_version: 1,
|
|
protocol_version: 1,
|
|
splash_byte_len: splash.len() as u64,
|
|
};
|
|
let game_revision = client
|
|
.stage_game_revision(&game, &game_manifest, &lock)
|
|
.expect("stage game revision");
|
|
assert_eq!(
|
|
client
|
|
.fetch_game_manifest(&game_revision)
|
|
.expect("fetch staged game revision"),
|
|
game_manifest
|
|
);
|
|
client
|
|
.publish_game_revision(&game, &game_revision)
|
|
.expect("publish game revision");
|
|
let game_alias = GameAlias::from_str("gen/games/e2e-world").unwrap();
|
|
client
|
|
.put_game_alias(&game_alias, &game, &game_revision)
|
|
.expect("set game alias");
|
|
let resolved_game = client.resolve_game_alias(&game_alias).expect("resolve game alias");
|
|
assert_eq!(resolved_game.game_id, game);
|
|
assert_eq!(resolved_game.head_revision, game_revision);
|
|
|
|
// Listing now carries the asset.
|
|
let page = client.assets_page(Some("gen"), None, 10).expect("listing 2");
|
|
assert_eq!(page.assets.len(), 1);
|
|
assert_eq!(page.assets[0].asset_id, published.asset_id);
|
|
assert!(page.assets[0].created_ms > 0);
|
|
|
|
// The event stream delivers the publication (kind-stamped: the client
|
|
// publish flow annotates BEFORE publishing).
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
let mut saw_publish = false;
|
|
while Instant::now() < deadline && !saw_publish {
|
|
for event in subscriber.poll() {
|
|
if let CatalogSubscriptionEvent::Events { events, .. } = event {
|
|
for ev in events {
|
|
if ev.asset_id == Some(published.asset_id)
|
|
&& ev.kind == makepad_asset_client::CatalogEventKind::AssetPublished
|
|
{
|
|
assert_eq!(ev.content_kind, Some(AssetKind::Video));
|
|
assert_eq!(ev.revision, Some(published.revision));
|
|
saw_publish = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
assert!(saw_publish, "asset_published event must reach the subscriber");
|
|
subscriber.shutdown();
|
|
|
|
// The jobs + worker protocol left the store (aicore P7): generation is
|
|
// client-driven now. Catalog, publish, events and downloads above are the
|
|
// whole remaining surface.
|
|
|
|
drop(server);
|
|
}
|
|
|
|
#[test]
|
|
fn publish_retry_recovers_a_published_revision_missing_its_alias() {
|
|
let (server, token) = start_server("publish_alias_recovery");
|
|
let mut client = connect(&server, &token, "publish_alias_recovery_cache");
|
|
let asset_id = AssetId::from_bytes([0x42; 16]);
|
|
let alias = AssetAlias::from_str("gen/history-recovery").unwrap();
|
|
let mut request = PublishRequest::new(
|
|
"gen",
|
|
AssetKind::Video,
|
|
"Recoverable clip",
|
|
PublishFile {
|
|
bytes: vec![0xA5; 6_000],
|
|
media: MediaType::Mp4,
|
|
role: FileRole::Video,
|
|
media_millis: 2_000,
|
|
dims: None,
|
|
},
|
|
PublishThumbnail {
|
|
bytes: vec![0x5A; 1_500],
|
|
media: ThumbnailMedia::Png,
|
|
width: 512,
|
|
height: 512,
|
|
views: Vec::new(),
|
|
},
|
|
);
|
|
request.asset_id = Some(asset_id);
|
|
|
|
// Models the importer crash/failure point seen in production: the asset
|
|
// and candidate are Published, but no alias was committed afterward.
|
|
let first = client.publish_artifact(&request).expect("initial publish without alias");
|
|
assert_eq!(first.asset_id, asset_id);
|
|
assert!(client.resolve_alias(&alias).is_err(), "alias must not exist yet");
|
|
|
|
// Exact same manifest/revision: retry must not re-stage Published. It
|
|
// resumes at the missing idempotent alias write instead.
|
|
request.alias = Some(alias.clone());
|
|
let recovered = client.publish_artifact(&request).expect("recover missing alias");
|
|
assert_eq!(recovered.asset_id, first.asset_id);
|
|
assert_eq!(recovered.revision, first.revision);
|
|
let resolved = client.resolve_alias(&alias).expect("recovered alias");
|
|
assert_eq!(resolved.asset_id, asset_id);
|
|
assert_eq!(resolved.head_revision, first.revision);
|
|
let detail = client.asset_detail(&asset_id).expect("detail");
|
|
assert_eq!(detail.candidates.len(), 1, "retry must not create a duplicate candidate");
|
|
assert_eq!(detail.latest_published().unwrap().revision, first.revision);
|
|
}
|
|
|
|
#[test]
|
|
fn non_root_principal_still_needs_grants() {
|
|
// The root bypass must not weaken scoping for everyone else: a fresh
|
|
// principal without grants is denied enqueue/publish paths.
|
|
let (server, admin_token) = start_server("scoped");
|
|
let admin = connect(&server, &admin_token, "scoped_admin_cache");
|
|
let _ = admin; // admin connect itself is the read-probe check
|
|
|
|
// Mint a token for an ungranted principal via raw client API calls is
|
|
// an admin-only flow the typed client does not wrap; use the admin to
|
|
// publish and then verify the ungranted principal cannot. Simplest
|
|
// hostile check without extra plumbing: a bogus (well-formed, unknown)
|
|
// token fails the connect probe with the uniform 401.
|
|
let mut cfg = ClientConfig::new(test_root("scoped_cache"));
|
|
cfg.token = Some(format!("mpat_{}", "77".repeat(32)));
|
|
let endpoints = ApiEndpoints {
|
|
control: server.control_addr(),
|
|
data: server.data_addr(),
|
|
};
|
|
match AssetClient::connect(cfg, endpoints, Some(server.server_id())) {
|
|
Err(ClientError::Unauthenticated) => {}
|
|
other => panic!("unknown token must be uniformly refused, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The rendezvous, end to end through the real client: two players press
|
|
/// Play on the same game and the second is sent to the first, not handed a
|
|
/// second claim. Then the unreachable-room escape, which is what keeps a
|
|
/// stale record from becoming a dead end for everyone who follows.
|
|
#[test]
|
|
fn real_clients_meet_in_one_room_and_never_dead_end_on_a_stale_one() {
|
|
let (server, token) = start_server("rooms");
|
|
let rik = connect(&server, &token, "rooms_rik_cache");
|
|
let sam = connect(&server, &token, "rooms_sam_cache");
|
|
|
|
// Nobody is playing. Both apps see the same nothing.
|
|
assert!(rik.rooms(Some("arcade")).expect("list").is_empty());
|
|
assert!(sam.rooms(None).expect("list").is_empty());
|
|
|
|
// Rik presses Play. No room, so he hosts and takes the claim.
|
|
let claimed = rik
|
|
.claim_room("arcade", "10.0.0.7:5000:5001#ab", "rik", 30_000, None)
|
|
.expect("claim");
|
|
let RoomClaimDto::Claimed { room, token: host_token } = claimed else {
|
|
panic!("the first press must take the claim");
|
|
};
|
|
|
|
// Sam presses Play on the same game. He is told where Rik is — and is
|
|
// NOT given a claim of his own, which is the whole point.
|
|
let second = sam
|
|
.claim_room("arcade", "10.0.0.9:6000:6001#cd", "sam", 30_000, None)
|
|
.expect("claim");
|
|
let RoomClaimDto::Occupied { room: found } = second else {
|
|
panic!("the second press must be sent to the first");
|
|
};
|
|
assert_eq!(found, room);
|
|
assert_eq!(found.invite, "10.0.0.7:5000:5001#ab");
|
|
assert_eq!(found.host, "rik");
|
|
assert_eq!(rik.rooms(Some("arcade")).expect("list"), vec![found.clone()]);
|
|
|
|
// Rik stays alive, then leaves; his claim frees at once.
|
|
let beat = rik.room_heartbeat(&room.room, &host_token, 30_000).expect("heartbeat");
|
|
assert!(beat.expires_ms >= room.expires_ms);
|
|
rik.retire_room(&room.room, &host_token).expect("retire");
|
|
assert!(sam.rooms(Some("arcade")).expect("list").is_empty());
|
|
// Leaving twice is not an error — a host that leaves and then exits
|
|
// runs both paths.
|
|
rik.retire_room(&room.room, &host_token).expect("retire again");
|
|
|
|
// A room whose host has vanished without retiring: Sam reads it, fails
|
|
// to dial it, and says which room he failed on. He becomes the host
|
|
// instead of hitting the same wall forever.
|
|
let stale = rik
|
|
.claim_room("arcade", "10.0.0.7:5000:5001#ab", "rik", 30_000, None)
|
|
.expect("claim");
|
|
let RoomClaimDto::Claimed { room: dead, token: dead_token } = stale else {
|
|
panic!("claim");
|
|
};
|
|
let taken = sam
|
|
.claim_room("arcade", "10.0.0.9:6000:6001#cd", "sam", 30_000, Some(&dead.room))
|
|
.expect("replacing claim");
|
|
let RoomClaimDto::Claimed { room: live, .. } = taken else {
|
|
panic!("an unreachable room must yield its claim");
|
|
};
|
|
assert_eq!(live.host, "sam");
|
|
assert_eq!(rik.rooms(Some("arcade")).expect("list"), vec![live]);
|
|
// The displaced host learns the claim moved the next time it says it is
|
|
// alive — a plain NotFound, which means "claim again", not "you broke".
|
|
match rik.room_heartbeat(&dead.room, &dead_token, 30_000) {
|
|
Err(ClientError::NotFound { .. }) => {}
|
|
other => panic!("a replaced room must heartbeat as gone, got {other:?}"),
|
|
}
|
|
|
|
// Local refusals happen before anything reaches the wire.
|
|
assert!(matches!(
|
|
rik.claim_room("", "i", "h", 30_000, None),
|
|
Err(ClientError::InvalidInput { .. })
|
|
));
|
|
assert!(matches!(
|
|
rik.claim_room("g", "i", "h", 1, None),
|
|
Err(ClientError::InvalidInput { .. })
|
|
));
|
|
assert!(matches!(
|
|
rik.rooms(Some(&"x".repeat(200))),
|
|
Err(ClientError::InvalidInput { .. })
|
|
));
|
|
}
|