Skip to content

Commit

Permalink
feat!: a number of improvements
Browse files Browse the repository at this point in the history
- Add doc comments
- Add `Experiment.defaultVariant`
- `Experiment.active` only returns true when user is part of experiment.
- Add `inactiveStringValue` to `ExperimentConfig` constructor
- Make `LocalExperimentAdapter` parameter named (`resolveUserSeed`)
- Make `FirebaseExperimentAdapter` parameters named
- Add `FirebaseExperimentAdapter.remoteConfig`
  • Loading branch information
blaugold committed Jan 16, 2023
1 parent 8d2dc10 commit 57f6453
Show file tree
Hide file tree
Showing 18 changed files with 289 additions and 154 deletions.
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -32,3 +32,4 @@ build/
/firebase/pubspec.lock
.flutter-plugins
.flutter-plugins-dependencies
pubspec_overrides.yaml
3 changes: 3 additions & 0 deletions .vscode/settings.json
@@ -0,0 +1,3 @@
{
"dart.lineLength": 120
}
3 changes: 2 additions & 1 deletion analysis_options.yaml
@@ -1,4 +1,4 @@
include: package:flutter_lints/flutter.yaml
include: package:lints/recommended.yaml

linter:
rules:
Expand All @@ -10,3 +10,4 @@ linter:
prefer_single_quotes: true
sort_pub_dependencies: true
unnecessary_lambdas: true
use_super_parameters: true
2 changes: 0 additions & 2 deletions core/lib/ab_testing_core.dart
@@ -1,5 +1,3 @@
library core;

export 'src/adapter.dart';
export 'src/config.dart';
export 'src/experiment.dart';
Expand Down
51 changes: 43 additions & 8 deletions core/lib/src/adapter.dart
@@ -1,12 +1,24 @@
import 'package:ab_testing_core/src/experiment.dart';

/// Provides the backing values for experiments and serves as a registry for
/// experiments.
///
/// An adapter must be initialized before it can be used by calling [init].
abstract class ExperimentAdapter {
/// The experiments that are registered with this adapter.
final List<AdaptedExperiment> experiments = [];

/// Initializes this adapter.
Future<void> init();

/// Returns whether this adapter has a value for the experiment with the given
/// [id].
bool has(String id);

/// Returns the value for the experiment with the given [id].
T? get<T>(String id);

/// Registers and returns a new boolean experiment.
Experiment<bool> boolean({
required String id,
bool defaultVariant = false,
Expand All @@ -24,42 +36,47 @@ abstract class ExperimentAdapter {
));
}

Experiment<int> numeric<T>({
/// Registers and returns a new numeric experiment.
Experiment<int> numeric({
required String id,
int defaultVariant = 0,
List<int>? variants,
Map<int, int>? weightedVariants,
double sampleSize = 1,
bool active = true,
}) {
_checkVariantsArguments(variants, weightedVariants);
return _add(AdaptedExperiment<int>(
this,
id,
active,
defaultVariant,
weightedVariants ?? variants?.asMap().map((_, value) => MapEntry(value, 1)),
weightedVariants ?? variants!.defaultWeightedVariants,
sampleSize,
));
}

Experiment<String> text<T>({
/// Registers and returns a new text experiment.
Experiment<String> text({
required String id,
String defaultVariant = '',
List<String>? variants,
Map<String, int>? weightedVariants,
double sampleSize = 1,
bool active = true,
}) {
_checkVariantsArguments(variants, weightedVariants);
return _add(AdaptedExperiment<String>(
this,
id,
active,
defaultVariant,
weightedVariants ?? variants?.asMap().map((_, value) => MapEntry(value, 1)),
weightedVariants ?? variants!.defaultWeightedVariants,
sampleSize,
));
}

/// Registers and returns a new [Enum] based experiment.
Experiment<T> enumerated<T extends Enum>({
required String id,
required T defaultVariant,
Expand All @@ -68,25 +85,43 @@ abstract class ExperimentAdapter {
double sampleSize = 1,
bool active = true,
}) {
assert(variants != null || weightedVariants != null, 'Either variants or weightedVariants must be provided');
_checkVariantsArguments(variants, weightedVariants);
return _add(EnumeratedExperiment<T>(
this,
id,
active,
defaultVariant,
weightedVariants ?? variants?.asMap().map((_, variant) => MapEntry(variant, 1)),
weightedVariants ?? variants!.defaultWeightedVariants,
sampleSize,
));
}

void _checkVariantsArguments(List<Object?>? variants, Map<Object?, int>? weightedVariants) {
assert(
variants != null || weightedVariants != null,
'Either variants or weightedVariants must be provided.',
);
}

Experiment<T> _add<T>(AdaptedExperiment<T> experiment) {
assert(!experiments.any((lookup) => lookup.id == experiment.id),
'Another Experiment with id ${experiment.id} already defined');
assert(
!experiments.any((lookup) => lookup.id == experiment.id),
'Another Experiment with id ${experiment.id} is already defined.',
);
experiments.add(experiment);
return experiment;
}
}

/// An [ExperimentAdapter] that might be able to fetch new values.
abstract class UpdatableExperimentAdapter extends ExperimentAdapter {
/// Updates the values for all experiments, if appropriate.
///
/// If [force] is true, the values will be updated regardless of whether they
/// are stale.
Future<void> update({bool force = false});
}

extension _DefaultWeightedVariants<T> on List<T> {
Map<T, int> get defaultWeightedVariants => asMap().map((_, variant) => MapEntry(variant, 1));
}
36 changes: 32 additions & 4 deletions core/lib/src/config.dart
@@ -1,35 +1,63 @@
import 'package:ab_testing_core/src/adapter.dart';
import 'package:ab_testing_core/src/experiment.dart';

/// A logger that is used to log experiment information.
abstract class ExperimentLogger {
void log(String message);
void logExperiments(List<Experiment> experiments);
}

/// Base class for an application's experiment configuration.
class ExperimentConfig {
final List<ExperimentAdapter> _adapters;
final ExperimentLogger? _logger;
final String? _inactiveStringValue;

ExperimentConfig(this._adapters, [this._logger]);
ExperimentConfig(
this._adapters, {
ExperimentLogger? logger,
String? inactiveStringValue,
}) : _logger = logger,
_inactiveStringValue = inactiveStringValue;

List<Experiment> get _allExperiments => _adapters.expand((adapter) => adapter.experiments).toList();
/// All active experiments.
List<Experiment> get experiments => _allExperiments.where((value) => value.active).toList();

List<Experiment> get _allExperiments => _adapters.expand((adapter) => adapter.experiments).toList();

/// Initializes all adapters.
Future<void> init() async {
await Future.wait(_adapters.map((adapter) => adapter.init()));
update();
_logger?.logExperiments(experiments);
}

/// Updates all updatable adapters.
Future<void> update({bool force = false}) async {
final adapters = _adapters.whereType<UpdatableExperimentAdapter>();
if (adapters.isNotEmpty) {
await Future.wait(adapters.map((adapter) => adapter.update(force: force)));
if (force){
if (force) {
_logger?.logExperiments(experiments);
}
}
}

Map<String, String> asMap() => {for (var item in _allExperiments) item.id: item.stringValue};
/// Returns a mapping of all experiments from their id to their string
/// value.
///
/// If [inactiveStringValue] is set, all inactive experiments will be mapped
/// to this value.
Map<String, String> asMap() => {
for (final experiment in _allExperiments) //
experiment.id: _experimentMapValue(experiment)
};

String _experimentMapValue(Experiment experiment) {
final inactiveStringValue = _inactiveStringValue;
if (inactiveStringValue == null) {
return experiment.stringValue;
}
return experiment.active ? experiment.stringValue : inactiveStringValue;
}
}
87 changes: 68 additions & 19 deletions core/lib/src/experiment.dart
@@ -1,34 +1,79 @@
import 'package:ab_testing_core/src/adapter.dart';
import 'package:ab_testing_core/ab_testing_core.dart';

/// An A/B test experiment, that defines whether the user is part of the
/// experiment and what variant has been assigned to them.
abstract class Experiment<T> {
/// The ID of this experiment.
String get id;

/// The default value for users that are not part of this experiment.
T get defaultVariant;

/// The value of this experiment.
T get value;

/// A string representation of this experiment's [value].
String get stringValue;

/// Whether this experiment is active.
///
/// Inactive experiments will always return [defaultVariant] for [value].
bool get active;
}

/// An [Experiment] that is backed by an [ExperimentAdapter].
class AdaptedExperiment<T> implements Experiment<T> {
final ExperimentAdapter _adapter;

@override
final String id;

@override
final bool active;
final T defaultVariant;
final Map<T, int>? weightedVariants;

/// All of the variants of this experiment and their weights.
///
/// The weights might be used to determine the likelihood of a variant being
/// chosen for a user.
///
/// [LocalExperimentAdapter] uses the weights but not all [ExperimentAdapter]s
/// do.
final Map<T, int> weightedVariants;

/// The sample size of this experiment.
///
/// The sample size is the percentage of users that should be part of this
/// experiment.
///
/// [LocalExperimentAdapter] uses the sample size but not all
/// [ExperimentAdapter]s do.
final double sampleSize;

final bool _active;

/// Creates a new [AdaptedExperiment].
AdaptedExperiment(
this._adapter,
this.id,
this.active,
bool active,
this.defaultVariant,
this.weightedVariants,
this.sampleSize,
);
) : _active = active {
if (weightedVariants.isEmpty) {
throw ArgumentError.value(
weightedVariants,
'weightedVariants',
'must not be empty',
);
}
}

@override
T get value => _active ? _adapter.get<T>(id) ?? defaultVariant : defaultVariant;

@override
T get value => active ? _adapter.get<T>(id) ?? defaultVariant : defaultVariant;
bool get active => _active ? _adapter.get<T>(id) != null : false;

@override
String get stringValue => value.toString();
Expand All @@ -37,30 +82,32 @@ class AdaptedExperiment<T> implements Experiment<T> {
String toString() => '$runtimeType(id: $id, value: $value)';
}

/// An [AdaptedExperiment] that uses [Enum] values to represent its [variants].
class EnumeratedExperiment<T extends Enum> extends AdaptedExperiment<T> {
EnumeratedExperiment(
ExperimentAdapter adapter,
String id,
bool active,
T defaultVariant,
Map<T, int>? weightedVariants,
double sampleSize,
) : assert(weightedVariants != null),
super(adapter, id, active, defaultVariant, weightedVariants, sampleSize);
super.adapter,
super.id,
super.active,
super.defaultValue,
super.weightedVariants,
super.sampleSize,
);

/// All of the variants of this experiment.
List<T> get variants => weightedVariants.keys.toList();

@override
T get value {
if (!active) {
if (!_active) {
return defaultVariant;
}
try {
return variants.byName(_adapter.get<String>(id)!);
} catch (_) {
var enumName = _adapter.get<String>(id);
if (enumName == null) {
return defaultVariant;
}
return variants.byName(enumName);
}

List<T> get variants => weightedVariants!.keys.toList();
@override
String get stringValue => value.name;
}
Expand All @@ -76,5 +123,7 @@ class FakeExperiment<T> implements Experiment<T> {
@override
String get id => 'fake';
@override
T get defaultVariant => value;
@override
String get stringValue => value.toString();
}

0 comments on commit 57f6453

Please sign in to comment.