@ceralive/modem-control 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +31 -0
- package/src/backend/at-lease.test.ts +106 -0
- package/src/backend/at-lease.ts +158 -0
- package/src/backend/cell-info.test.ts +154 -0
- package/src/backend/cell-info.ts +160 -0
- package/src/backend/constants.ts +35 -0
- package/src/backend/device-classifier.test.ts +168 -0
- package/src/backend/device-classifier.ts +240 -0
- package/src/backend/enrichment.ts +96 -0
- package/src/backend/features.test.ts +158 -0
- package/src/backend/features.ts +179 -0
- package/src/backend/identity-ladder.test.ts +117 -0
- package/src/backend/identity-ladder.ts +221 -0
- package/src/backend/identity-registry.test.ts +89 -0
- package/src/backend/identity-registry.ts +151 -0
- package/src/backend/index.ts +221 -0
- package/src/backend/lifecycle-interlock.ts +38 -0
- package/src/backend/managed-objects.ts +108 -0
- package/src/backend/mapping.ts +160 -0
- package/src/backend/mm-backend.ts +191 -0
- package/src/backend/mm-mutations.ts +228 -0
- package/src/backend/modem-actor.test.ts +95 -0
- package/src/backend/modem-actor.ts +112 -0
- package/src/backend/nm-auto-apn.ts +161 -0
- package/src/backend/nm-gsm-fields.ts +122 -0
- package/src/backend/nmcli-nm-port.ts +228 -0
- package/src/backend/nmcli-runner.ts +52 -0
- package/src/backend/observer.ts +297 -0
- package/src/backend/power-contract.test.ts +40 -0
- package/src/backend/power-contract.ts +83 -0
- package/src/backend/recovery-attribution.test.ts +102 -0
- package/src/backend/recovery-attribution.ts +86 -0
- package/src/backend/recovery-budget.test.ts +64 -0
- package/src/backend/recovery-budget.ts +84 -0
- package/src/backend/recovery-ladder.test.ts +257 -0
- package/src/backend/recovery-ladder.ts +249 -0
- package/src/backend/router-ethernet.test.ts +71 -0
- package/src/backend/router-ethernet.ts +90 -0
- package/src/backend/row-store.ts +105 -0
- package/src/backend/signal-setup.ts +112 -0
- package/src/backend/sim-unlock.ts +193 -0
- package/src/backend/transition-preconditions.ts +149 -0
- package/src/backend/usage/accounting.test.ts +147 -0
- package/src/backend/usage/accounting.ts +123 -0
- package/src/backend/usage/billing-cycle.test.ts +62 -0
- package/src/backend/usage/billing-cycle.ts +45 -0
- package/src/backend/usage/boot-id.ts +18 -0
- package/src/backend/usage/index.ts +37 -0
- package/src/backend/usage/proc-net-dev.test.ts +56 -0
- package/src/backend/usage/proc-net-dev.ts +77 -0
- package/src/backend/usage/sampler.test.ts +219 -0
- package/src/backend/usage/sampler.ts +228 -0
- package/src/backend/usage/store.test.ts +148 -0
- package/src/backend/usage/store.ts +177 -0
- package/src/backend/usb-enumerator.test.ts +87 -0
- package/src/backend/usb-enumerator.ts +181 -0
- package/src/backend/usb-mode-transition.test.ts +323 -0
- package/src/backend/usb-mode-transition.ts +253 -0
- package/src/domain/brand.ts +29 -0
- package/src/domain/errors.ts +77 -0
- package/src/domain/guards.test.ts +218 -0
- package/src/domain/guards.ts +144 -0
- package/src/domain/identity.test.ts +83 -0
- package/src/domain/identity.ts +165 -0
- package/src/domain/index.ts +12 -0
- package/src/domain/policy.ts +137 -0
- package/src/domain/snapshot.test.ts +266 -0
- package/src/domain/snapshot.ts +120 -0
- package/src/domain/state.ts +189 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +15 -0
- package/src/ports/README.md +61 -0
- package/src/ports/forbidden-surface.test.ts +80 -0
- package/src/ports/index.ts +16 -0
- package/src/ports/modem-manager.ts +72 -0
- package/src/ports/network-manager.ts +87 -0
- package/src/ports/observation.ts +48 -0
- package/src/ports/ops.ts +60 -0
- package/src/ports/ops.type-test.ts +39 -0
- package/src/ports/receipts.test.ts +153 -0
- package/src/ports/receipts.ts +43 -0
- package/src/ports/reconcile.test.ts +152 -0
- package/src/ports/reconcile.ts +338 -0
- package/src/ports/router.ts +29 -0
- package/src/redact.test.ts +82 -0
- package/src/redact.ts +73 -0
- package/src/transport/README.md +65 -0
- package/src/transport/codec.test.ts +118 -0
- package/src/transport/codec.ts +240 -0
- package/src/transport/conformance-python.test.ts +152 -0
- package/src/transport/conformance-same-lib.test.ts +115 -0
- package/src/transport/dbus-native-lib.d.ts +19 -0
- package/src/transport/dbus-native.ts +85 -0
- package/src/transport/errors.ts +74 -0
- package/src/transport/index.ts +30 -0
- package/src/transport/no-library-leak.test.ts +50 -0
- package/src/transport/reliability.test.ts +173 -0
- package/src/transport/signature.ts +110 -0
- package/src/transport/test-support/fake-service.ts +143 -0
- package/src/transport/test-support/independent-producer.py +110 -0
- package/src/transport/test-support/private-bus.ts +66 -0
- package/src/transport/transport.ts +439 -0
- package/src/transport/types.ts +118 -0
- package/src/usb-mode/catalog-schema.test.ts +181 -0
- package/src/usb-mode/catalog-schema.ts +113 -0
- package/src/usb-mode/catalog.ts +60 -0
- package/src/usb-mode/certified-catalog.json +67 -0
- package/src/usb-mode/index.ts +27 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// COMPILE-TIME negative tests for port-tagged op disjointness.
|
|
2
|
+
//
|
|
3
|
+
// This file is type-checked by `tsc --noEmit` (it is inside the workspace
|
|
4
|
+
// `include`) but is NOT a runtime test — it is never imported and every binding is
|
|
5
|
+
// a type assertion. Each `@ts-expect-error` asserts the type system REJECTS a
|
|
6
|
+
// mis-tagged op; if disjointness ever regresses (e.g. an `NmOp` gains a radio
|
|
7
|
+
// `kind`), the directive becomes UNUSED and `tsc` fails the build. That failure IS
|
|
8
|
+
// the ownership matrix enforced at the type level.
|
|
9
|
+
|
|
10
|
+
import { connectionId, deviceIfname } from './network-manager';
|
|
11
|
+
import type { MmOp, NmOp, PortTaggedOp } from './ops';
|
|
12
|
+
|
|
13
|
+
const radioOp: MmOp = { kind: 'setRadioModes', preference: { preferenceOrdered: ['5gnr'] } };
|
|
14
|
+
const slotOp: MmOp = { kind: 'setPrimarySimSlot', slotIndex: 2 };
|
|
15
|
+
const activateOp: NmOp = {
|
|
16
|
+
kind: 'activate',
|
|
17
|
+
connectionId: connectionId('11111111-1111-1111-1111-111111111111'),
|
|
18
|
+
deviceIfname: deviceIfname('wwan0'),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Positive controls — correctly-tagged ops MUST type-check.
|
|
22
|
+
const goodMm: PortTaggedOp = { port: 'mm', op: radioOp };
|
|
23
|
+
const goodNm: PortTaggedOp = { port: 'nm', op: activateOp };
|
|
24
|
+
|
|
25
|
+
// @ts-expect-error a radio op tagged for the NM port must NOT compile.
|
|
26
|
+
const badRadioOnNm: PortTaggedOp = { port: 'nm', op: radioOp };
|
|
27
|
+
// @ts-expect-error a SIM op tagged for the NM port must NOT compile.
|
|
28
|
+
const badSlotOnNm: PortTaggedOp = { port: 'nm', op: slotOp };
|
|
29
|
+
// @ts-expect-error an activate op tagged for the MM port must NOT compile.
|
|
30
|
+
const badActivateOnMm: PortTaggedOp = { port: 'mm', op: activateOp };
|
|
31
|
+
|
|
32
|
+
// Exported so the bindings are "used" and lint stays quiet; never imported at runtime.
|
|
33
|
+
export const PORT_TAGGED_OP_TYPE_CASES: readonly PortTaggedOp[] = [
|
|
34
|
+
goodMm,
|
|
35
|
+
goodNm,
|
|
36
|
+
badRadioOnNm,
|
|
37
|
+
badSlotOnNm,
|
|
38
|
+
badActivateOnMm,
|
|
39
|
+
];
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Receipt taxonomy — every status reachable, "prefer 5G on 4G-only" is surfaced as
|
|
2
|
+
// `unsupported` (never silently downgraded), and every receipt carries a reason.
|
|
3
|
+
|
|
4
|
+
import { expect, test } from 'bun:test';
|
|
5
|
+
import {
|
|
6
|
+
type DesiredCellularPolicy,
|
|
7
|
+
defaultCellularPolicy,
|
|
8
|
+
imeiEquipmentId,
|
|
9
|
+
type PolicyBindingKey,
|
|
10
|
+
policyBindingKey,
|
|
11
|
+
runtimePath,
|
|
12
|
+
} from '../domain';
|
|
13
|
+
import { connectionId, deviceIfname } from './network-manager';
|
|
14
|
+
import type { PolicyDimension, Receipt } from './receipts';
|
|
15
|
+
import { type AppliedCellularState, type ModemCapabilities, planReconcile } from './reconcile';
|
|
16
|
+
|
|
17
|
+
const BOUND_TO: PolicyBindingKey = policyBindingKey({
|
|
18
|
+
equipmentId: imeiEquipmentId('490154203237518'),
|
|
19
|
+
runtimePath: runtimePath('/org/freedesktop/ModemManager1/Modem/0'),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const FOUR_G_ONLY: ModemCapabilities = {
|
|
23
|
+
supportedRats: new Set(['lte', 'umts', 'gsm']),
|
|
24
|
+
simSlotCount: 1,
|
|
25
|
+
supportsAutoApn: true,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const FIVE_G_DUAL_SLOT: ModemCapabilities = {
|
|
29
|
+
supportedRats: new Set(['5gnr', 'lte', 'umts', 'gsm']),
|
|
30
|
+
simSlotCount: 2,
|
|
31
|
+
supportsAutoApn: true,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const ADDRESSABLE = {
|
|
35
|
+
connectionId: connectionId('conn-uuid'),
|
|
36
|
+
deviceIfname: deviceIfname('wwan0'),
|
|
37
|
+
appliedApn: 'auto',
|
|
38
|
+
appliedRoaming: false,
|
|
39
|
+
appliedRadioPreference: ['5gnr', 'lte', 'umts', 'gsm'],
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
function receiptFor(receipts: readonly Receipt[], dimension: PolicyDimension): Receipt {
|
|
43
|
+
const found = receipts.find((entry) => entry.dimension === dimension);
|
|
44
|
+
if (found === undefined) {
|
|
45
|
+
throw new Error(`no receipt for dimension ${dimension}`);
|
|
46
|
+
}
|
|
47
|
+
return found;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('prefer 5G on a 4G-only modem is unsupported with a reason, never silently applied', () => {
|
|
51
|
+
const policy = defaultCellularPolicy(BOUND_TO);
|
|
52
|
+
const applied: AppliedCellularState = { nmActivation: 'disconnected', hasProfile: false };
|
|
53
|
+
const { ops, receipts } = planReconcile(applied, policy, FOUR_G_ONLY);
|
|
54
|
+
|
|
55
|
+
const radio = receiptFor(receipts, 'radio');
|
|
56
|
+
expect(radio.status).toBe('unsupported');
|
|
57
|
+
expect(radio.reason).toContain('5gnr');
|
|
58
|
+
expect(radio.reason.length).toBeGreaterThan(0);
|
|
59
|
+
expect(ops.filter((op) => op.port === 'mm' && op.op.kind === 'setRadioModes')).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('all four receipt statuses are reachable', () => {
|
|
63
|
+
const policy = defaultCellularPolicy(BOUND_TO);
|
|
64
|
+
|
|
65
|
+
const applied: AppliedCellularState = {
|
|
66
|
+
nmActivation: 'activated',
|
|
67
|
+
hasProfile: true,
|
|
68
|
+
...ADDRESSABLE,
|
|
69
|
+
};
|
|
70
|
+
expect(
|
|
71
|
+
receiptFor(planReconcile(applied, policy, FIVE_G_DUAL_SLOT).receipts, 'enabled').status,
|
|
72
|
+
).toBe('applied');
|
|
73
|
+
|
|
74
|
+
const pending: AppliedCellularState = {
|
|
75
|
+
nmActivation: 'disconnected',
|
|
76
|
+
hasProfile: true,
|
|
77
|
+
...ADDRESSABLE,
|
|
78
|
+
};
|
|
79
|
+
expect(
|
|
80
|
+
receiptFor(planReconcile(pending, policy, FIVE_G_DUAL_SLOT).receipts, 'enabled').status,
|
|
81
|
+
).toBe('pending');
|
|
82
|
+
|
|
83
|
+
const unsupported: AppliedCellularState = { nmActivation: 'disconnected', hasProfile: false };
|
|
84
|
+
expect(receiptFor(planReconcile(unsupported, policy, FOUR_G_ONLY).receipts, 'radio').status).toBe(
|
|
85
|
+
'unsupported',
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const failed: AppliedCellularState = {
|
|
89
|
+
nmActivation: 'failed',
|
|
90
|
+
hasProfile: true,
|
|
91
|
+
activationFailureReason: 'no-signal',
|
|
92
|
+
...ADDRESSABLE,
|
|
93
|
+
};
|
|
94
|
+
const failedReceipt = receiptFor(
|
|
95
|
+
planReconcile(failed, policy, FIVE_G_DUAL_SLOT).receipts,
|
|
96
|
+
'enabled',
|
|
97
|
+
);
|
|
98
|
+
expect(failedReceipt.status).toBe('failed');
|
|
99
|
+
expect(failedReceipt.reason).toContain('no-signal');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('every dimension yields a receipt and every receipt has a non-empty reason', () => {
|
|
103
|
+
const policy = defaultCellularPolicy(BOUND_TO);
|
|
104
|
+
const { receipts } = planReconcile(
|
|
105
|
+
{ nmActivation: 'disconnected', hasProfile: false },
|
|
106
|
+
policy,
|
|
107
|
+
FOUR_G_ONLY,
|
|
108
|
+
);
|
|
109
|
+
for (const entry of receipts) {
|
|
110
|
+
expect(entry.reason.length).toBeGreaterThan(0);
|
|
111
|
+
}
|
|
112
|
+
const expectedDimensions: PolicyDimension[] = [
|
|
113
|
+
'connection',
|
|
114
|
+
'enabled',
|
|
115
|
+
'radio',
|
|
116
|
+
'recovery',
|
|
117
|
+
'roaming',
|
|
118
|
+
'simSlot',
|
|
119
|
+
'usage',
|
|
120
|
+
];
|
|
121
|
+
expect(receipts.map((entry) => entry.dimension).sort()).toEqual(expectedDimensions.sort());
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('primary SIM slot selection is unsupported on a single-slot modem', () => {
|
|
125
|
+
const policy: DesiredCellularPolicy = { ...defaultCellularPolicy(BOUND_TO), simSlot: 2 };
|
|
126
|
+
const { receipts } = planReconcile(
|
|
127
|
+
{ nmActivation: 'disconnected', hasProfile: false },
|
|
128
|
+
policy,
|
|
129
|
+
FOUR_G_ONLY,
|
|
130
|
+
);
|
|
131
|
+
expect(receiptFor(receipts, 'simSlot').status).toBe('unsupported');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('an out-of-range primary SIM slot is a failed receipt', () => {
|
|
135
|
+
const policy: DesiredCellularPolicy = { ...defaultCellularPolicy(BOUND_TO), simSlot: 5 };
|
|
136
|
+
const { receipts } = planReconcile(
|
|
137
|
+
{ nmActivation: 'disconnected', hasProfile: false },
|
|
138
|
+
policy,
|
|
139
|
+
FIVE_G_DUAL_SLOT,
|
|
140
|
+
);
|
|
141
|
+
expect(receiptFor(receipts, 'simSlot').status).toBe('failed');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('Auto-APN is unsupported when the stack cannot auto-configure', () => {
|
|
145
|
+
const policy = defaultCellularPolicy(BOUND_TO);
|
|
146
|
+
const caps: ModemCapabilities = { ...FIVE_G_DUAL_SLOT, supportsAutoApn: false };
|
|
147
|
+
const { receipts } = planReconcile(
|
|
148
|
+
{ nmActivation: 'disconnected', hasProfile: false },
|
|
149
|
+
policy,
|
|
150
|
+
caps,
|
|
151
|
+
);
|
|
152
|
+
expect(receiptFor(receipts, 'connection').status).toBe('unsupported');
|
|
153
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Reconcile receipts — the HONEST outcome of trying to apply one policy dimension.
|
|
2
|
+
//
|
|
3
|
+
// Every attempt to converge a dimension yields exactly one receipt with a status
|
|
4
|
+
// AND a reason. Nothing is ever silently dropped: "prefer 5G" on a 4G-only modem is
|
|
5
|
+
// reported as `unsupported` with a reason, never quietly downgraded to "5G off".
|
|
6
|
+
|
|
7
|
+
/** The policy dimension a receipt is about. */
|
|
8
|
+
export type PolicyDimension =
|
|
9
|
+
| 'enabled'
|
|
10
|
+
| 'connection'
|
|
11
|
+
| 'roaming'
|
|
12
|
+
| 'radio'
|
|
13
|
+
| 'simSlot'
|
|
14
|
+
| 'recovery'
|
|
15
|
+
| 'usage';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Receipt status taxonomy:
|
|
19
|
+
* - applied — the desired state is in effect (converged, or already was).
|
|
20
|
+
* - pending — an op was issued; a terminal state is not yet observed.
|
|
21
|
+
* - unsupported — the hardware / stack cannot honor this desire (surfaced, not silent).
|
|
22
|
+
* - failed — an op was attempted and errored.
|
|
23
|
+
*/
|
|
24
|
+
export type ReceiptStatus = 'applied' | 'pending' | 'unsupported' | 'failed';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One reconcile receipt. `reason` is ALWAYS populated — a receipt with no reason
|
|
28
|
+
* would be a silent outcome, which the contract forbids.
|
|
29
|
+
*/
|
|
30
|
+
export interface Receipt {
|
|
31
|
+
readonly dimension: PolicyDimension;
|
|
32
|
+
readonly status: ReceiptStatus;
|
|
33
|
+
readonly reason: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Build a receipt. Kept as a helper so `reason` can never be forgotten. */
|
|
37
|
+
export function receipt(
|
|
38
|
+
dimension: PolicyDimension,
|
|
39
|
+
status: ReceiptStatus,
|
|
40
|
+
reason: string,
|
|
41
|
+
): Receipt {
|
|
42
|
+
return { dimension, status, reason };
|
|
43
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Idempotent re-apply — a minimal in-memory stack executes the planner's
|
|
2
|
+
// port-tagged ops; re-applying the same policy performs ZERO new side effects and
|
|
3
|
+
// returns the same receipts. NOT the A2.3 fake D-Bus harness — just enough to prove
|
|
4
|
+
// the pure planner converges to a fixpoint.
|
|
5
|
+
|
|
6
|
+
import { expect, test } from 'bun:test';
|
|
7
|
+
import {
|
|
8
|
+
type DesiredCellularPolicy,
|
|
9
|
+
defaultCellularPolicy,
|
|
10
|
+
imeiEquipmentId,
|
|
11
|
+
type PolicyBindingKey,
|
|
12
|
+
policyBindingKey,
|
|
13
|
+
runtimePath,
|
|
14
|
+
} from '../domain';
|
|
15
|
+
import { connectionId, deviceIfname } from './network-manager';
|
|
16
|
+
import type { PortTaggedOp } from './ops';
|
|
17
|
+
import type { Receipt } from './receipts';
|
|
18
|
+
import { type AppliedCellularState, type ModemCapabilities, planReconcile } from './reconcile';
|
|
19
|
+
|
|
20
|
+
const BOUND_TO: PolicyBindingKey = policyBindingKey({
|
|
21
|
+
equipmentId: imeiEquipmentId('490154203237518'),
|
|
22
|
+
runtimePath: runtimePath('/org/freedesktop/ModemManager1/Modem/0'),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const FIVE_G_DUAL_SLOT: ModemCapabilities = {
|
|
26
|
+
supportedRats: new Set(['5gnr', 'lte', 'umts', 'gsm']),
|
|
27
|
+
simSlotCount: 2,
|
|
28
|
+
supportsAutoApn: true,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const FOUR_G_ONLY: ModemCapabilities = {
|
|
32
|
+
supportedRats: new Set(['lte', 'umts', 'gsm']),
|
|
33
|
+
simSlotCount: 2,
|
|
34
|
+
supportsAutoApn: true,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
class InMemoryStack {
|
|
38
|
+
private state: AppliedCellularState;
|
|
39
|
+
readonly executed: PortTaggedOp[] = [];
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
initial: AppliedCellularState,
|
|
43
|
+
private readonly capabilities: ModemCapabilities,
|
|
44
|
+
) {
|
|
45
|
+
this.state = initial;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private execute(op: PortTaggedOp): void {
|
|
49
|
+
this.executed.push(op);
|
|
50
|
+
if (op.port === 'mm') {
|
|
51
|
+
this.state =
|
|
52
|
+
op.op.kind === 'setRadioModes'
|
|
53
|
+
? { ...this.state, appliedRadioPreference: op.op.preference.preferenceOrdered }
|
|
54
|
+
: { ...this.state, activePrimarySlot: op.op.slotIndex };
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
switch (op.op.kind) {
|
|
58
|
+
case 'createGsmProfile':
|
|
59
|
+
this.state = {
|
|
60
|
+
...this.state,
|
|
61
|
+
hasProfile: true,
|
|
62
|
+
connectionId: connectionId('conn-generated'),
|
|
63
|
+
appliedApn: op.op.profile.autoConfig ? 'auto' : op.op.profile.apn,
|
|
64
|
+
appliedRoaming: !op.op.profile.homeOnly,
|
|
65
|
+
};
|
|
66
|
+
break;
|
|
67
|
+
case 'updateGsmProfile': {
|
|
68
|
+
const { patch } = op.op;
|
|
69
|
+
const nextApn = patch.autoConfig ? 'auto' : patch.apn;
|
|
70
|
+
this.state = {
|
|
71
|
+
...this.state,
|
|
72
|
+
...(nextApn !== undefined ? { appliedApn: nextApn } : {}),
|
|
73
|
+
...(patch.homeOnly !== undefined ? { appliedRoaming: !patch.homeOnly } : {}),
|
|
74
|
+
};
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case 'activate':
|
|
78
|
+
this.state = { ...this.state, nmActivation: 'activated' };
|
|
79
|
+
break;
|
|
80
|
+
case 'deactivate':
|
|
81
|
+
this.state = { ...this.state, nmActivation: 'disconnected' };
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
apply(desired: DesiredCellularPolicy): readonly Receipt[] {
|
|
87
|
+
for (let iteration = 0; iteration < 20; iteration += 1) {
|
|
88
|
+
const plan = planReconcile(this.state, desired, this.capabilities);
|
|
89
|
+
if (plan.ops.length === 0) {
|
|
90
|
+
return plan.receipts;
|
|
91
|
+
}
|
|
92
|
+
for (const op of plan.ops) {
|
|
93
|
+
this.execute(op);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
throw new Error('reconcile did not converge within the iteration budget');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function baseState(): AppliedCellularState {
|
|
101
|
+
return { nmActivation: 'disconnected', hasProfile: false, deviceIfname: deviceIfname('wwan0') };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test('a fresh policy converges, then re-apply does nothing and returns the same receipts', () => {
|
|
105
|
+
const stack = new InMemoryStack(baseState(), FIVE_G_DUAL_SLOT);
|
|
106
|
+
const policy = defaultCellularPolicy(BOUND_TO);
|
|
107
|
+
|
|
108
|
+
const first = stack.apply(policy);
|
|
109
|
+
const sideEffectsAfterFirst = stack.executed.length;
|
|
110
|
+
expect(sideEffectsAfterFirst).toBeGreaterThan(0);
|
|
111
|
+
|
|
112
|
+
const second = stack.apply(policy);
|
|
113
|
+
expect(stack.executed.length).toBe(sideEffectsAfterFirst);
|
|
114
|
+
expect(second).toEqual(first);
|
|
115
|
+
|
|
116
|
+
for (const entry of second) {
|
|
117
|
+
expect(entry.status).toBe('applied');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('each op is executed exactly once across convergence (no double side-effects)', () => {
|
|
122
|
+
const stack = new InMemoryStack(baseState(), FIVE_G_DUAL_SLOT);
|
|
123
|
+
const policy = defaultCellularPolicy(BOUND_TO);
|
|
124
|
+
stack.apply(policy);
|
|
125
|
+
|
|
126
|
+
const kinds = stack.executed.map((op) => op.op.kind).sort();
|
|
127
|
+
expect(kinds).toEqual(['activate', 'createGsmProfile', 'setRadioModes']);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('an unsupported dimension is never applied as an op, on the first apply or any re-apply', () => {
|
|
131
|
+
const stack = new InMemoryStack(baseState(), FOUR_G_ONLY);
|
|
132
|
+
const policy = defaultCellularPolicy(BOUND_TO);
|
|
133
|
+
|
|
134
|
+
const first = stack.apply(policy);
|
|
135
|
+
stack.apply(policy);
|
|
136
|
+
|
|
137
|
+
expect(stack.executed.some((op) => op.port === 'mm' && op.op.kind === 'setRadioModes')).toBe(
|
|
138
|
+
false,
|
|
139
|
+
);
|
|
140
|
+
expect(first.find((entry) => entry.dimension === 'radio')?.status).toBe('unsupported');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('deactivation is idempotent — disabling an already-inactive connection is a no-op', () => {
|
|
144
|
+
const stack = new InMemoryStack(baseState(), FIVE_G_DUAL_SLOT);
|
|
145
|
+
const policy: DesiredCellularPolicy = { ...defaultCellularPolicy(BOUND_TO), enabled: false };
|
|
146
|
+
|
|
147
|
+
stack.apply(policy);
|
|
148
|
+
const sideEffects = stack.executed.length;
|
|
149
|
+
stack.apply(policy);
|
|
150
|
+
expect(stack.executed.length).toBe(sideEffects);
|
|
151
|
+
expect(stack.executed.some((op) => op.port === 'nm' && op.op.kind === 'activate')).toBe(false);
|
|
152
|
+
});
|