feat(#829): per-channel region-scope store (Epic #815 C1)

ChannelRegionScopeStore persists a channel-index -> region-name map per device (first 10 hex of the device key, matching the other channel stores). set/clear/scopeFor, null = unscoped. Tests cover round-trip, per-device and per-channel isolation, persistence, and no-device-key guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/844-per-radio-notify
diffwireauto 1 month ago committed by Benjamin Wiechel
parent 2ff7c71aa4
commit d83cbfae0c

@ -0,0 +1,39 @@
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
/// Per-channel region scope, persisted per device (Feature #812, Epic #815).
///
/// Stores the region name a channel's outgoing messages should ride, keyed by
/// the first 10 hex chars of the connected device's public key (the same
/// scoping the other channel stores use). The name is the `#`-stripped region
/// name from discovery; null/absent means the channel is unscoped.
class ChannelRegionScopeStore {
static const String _keyPrefix = 'channel_region_scope_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String _key(int channelIndex) => '$_keyPrefix${publicKeyHex}_$channelIndex';
/// The region name scoping [channelIndex], or null when unscoped.
Future<String?> scopeFor(int channelIndex) async {
if (publicKeyHex.isEmpty) return null;
return PrefsManager.instance.getString(_key(channelIndex));
}
/// Scope [channelIndex] to [regionName].
Future<void> setScope(int channelIndex, String regionName) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('No device key set; cannot save channel region scope.');
return;
}
await PrefsManager.instance.setString(_key(channelIndex), regionName);
}
/// Remove the region scope on [channelIndex] (back to unscoped).
Future<void> clearScope(int channelIndex) async {
if (publicKeyHex.isEmpty) return;
await PrefsManager.instance.remove(_key(channelIndex));
}
}

@ -0,0 +1,60 @@
// Epic #815 T C1: per-channel region-scope store.
// Device-key scoped (first 10 hex chars of the device public key), channel -> region name.
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/storage/channel_region_scope_store.dart';
import 'package:meshcore_open/storage/prefs_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _devA = 'aabbccddee0011223344';
const _devB = 'ffeeddccbb0011223344';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
PrefsManager.reset();
await PrefsManager.initialize();
});
ChannelRegionScopeStore storeFor(String deviceKeyHex) {
final s = ChannelRegionScopeStore();
s.setPublicKeyHex = deviceKeyHex;
return s;
}
test('set / get / clear round-trip', () async {
final store = storeFor(_devA);
expect(await store.scopeFor(0), isNull);
await store.setScope(0, 'oki');
expect(await store.scopeFor(0), 'oki');
await store.clearScope(0);
expect(await store.scopeFor(0), isNull);
});
test('scopes are isolated per device key', () async {
await storeFor(_devA).setScope(2, 'oki');
expect(await storeFor(_devB).scopeFor(2), isNull);
expect(await storeFor(_devA).scopeFor(2), 'oki');
});
test('scopes are isolated per channel index', () async {
final store = storeFor(_devA);
await store.setScope(1, 'oki');
expect(await store.scopeFor(3), isNull);
expect(await store.scopeFor(1), 'oki');
});
test('survives a fresh store instance (persisted to prefs)', () async {
await storeFor(_devA).setScope(5, 'test');
final reopened = storeFor(_devA);
expect(await reopened.scopeFor(5), 'test');
});
test('with no device key set, reads null and writes are refused', () async {
final store = ChannelRegionScopeStore();
await store.setScope(0, 'oki');
expect(await store.scopeFor(0), isNull);
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.