add astra draft

This commit is contained in:
2026-09-17 17:59:34 +02:00
parent 88588dcfcc
commit 41458afc76
38 changed files with 2392 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "PolicyCore",
platforms: [.iOS(.v18), .macOS(.v14)],
products: [.library(name: "PolicyCore", targets: ["PolicyCore"])],
targets: [
.target(name: "PolicyCore"),
.testTarget(name: "PolicyCoreTests", dependencies: ["PolicyCore"])
]
)
@@ -0,0 +1,164 @@
import Foundation
public enum WireCoding {
public static func decoder() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}
public static func encoder() -> JSONEncoder {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.outputFormatting = [.sortedKeys]
return encoder
}
}
public enum PolicyError: Error { case invalidPolicy, unsupportedSnapshot }
public struct Policy: Codable, Equatable, Sendable {
public var timezone: String
public var dailyMinutes: Int
public var bonusMinutes: Int
public var maxBonusMinutes: Int
public var correctAnswers: Int
public var maxOperand: Int
public var allowedStart: Int
public var allowedEnd: Int
public init(timezone: String = "Europe/Berlin", dailyMinutes: Int = 30,
bonusMinutes: Int = 10, maxBonusMinutes: Int = 30,
correctAnswers: Int = 5, maxOperand: Int = 20,
allowedStart: Int = 420, allowedEnd: Int = 1200) {
self.timezone = timezone; self.dailyMinutes = dailyMinutes
self.bonusMinutes = bonusMinutes; self.maxBonusMinutes = maxBonusMinutes
self.correctAnswers = correctAnswers; self.maxOperand = maxOperand
self.allowedStart = allowedStart; self.allowedEnd = allowedEnd
}
public func validate() throws {
guard TimeZone(identifier: timezone) != nil,
(0...240).contains(dailyMinutes), (5...30).contains(bonusMinutes),
(0...60).contains(maxBonusMinutes), (1...20).contains(correctAnswers),
(2...100).contains(maxOperand), (0...1439).contains(allowedStart),
(1...1439).contains(allowedEnd), allowedEnd - allowedStart >= 60 else {
throw PolicyError.invalidPolicy
}
}
public var calendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
// validate() is required before a policy can be armed.
calendar.timeZone = TimeZone(identifier: timezone) ?? TimeZone(secondsFromGMT: 0)!
return calendar
}
public func day(at date: Date) -> String {
let parts = calendar.dateComponents([.year, .month, .day], from: date)
return String(format: "%04d-%02d-%02d", parts.year!, parts.month!, parts.day!)
}
public func isAllowed(at date: Date) -> Bool {
let parts = calendar.dateComponents([.hour, .minute], from: date)
let minute = parts.hour! * 60 + parts.minute!
return minute >= allowedStart && minute < allowedEnd
}
/// Register a ladder once: earning a reward must not restart usage monitoring.
/// The second ladder accommodates an existing balance after a mid-day policy change.
/// Both include the partial final reward at the daily cap, and the normal next-day base.
public func thresholds(startingBonus: Int) -> [Int] {
guard bonusMinutes > 0, maxBonusMinutes >= 0 else { return [] }
var result = Set<Int>()
for start in [0, min(max(startingBonus, 0), maxBonusMinutes)] {
var bonus = start
result.insert(dailyMinutes + bonus)
while bonus < maxBonusMinutes {
bonus = min(bonus + bonusMinutes, maxBonusMinutes)
result.insert(dailyMinutes + bonus)
}
}
return result.filter { $0 > 0 }.sorted()
}
}
public struct Snapshot: Codable, Equatable, Sendable {
public var schemaVersion: Int
public var deviceId: String
public var childId: String
public var childName: String
public var policyRevision: Int
public var policy: Policy
public var day: String
public var earnedMinutes: Int
public var serverTime: Double
public init(schemaVersion: Int = 1, deviceId: String, childId: String,
childName: String, policyRevision: Int, policy: Policy,
day: String, earnedMinutes: Int, serverTime: Double) {
self.schemaVersion = schemaVersion; self.deviceId = deviceId
self.childId = childId; self.childName = childName; self.policyRevision = policyRevision
self.policy = policy; self.day = day; self.earnedMinutes = earnedMinutes
self.serverTime = serverTime
}
public func validate() throws {
try policy.validate()
guard schemaVersion == 1, policyRevision > 0,
(0...policy.maxBonusMinutes).contains(earnedMinutes),
serverTime.isFinite, day == policy.day(at: Date(timeIntervalSince1970: serverTime)) else {
throw PolicyError.unsupportedSnapshot
}
}
public func bonus(at now: Date) -> Int {
day == policy.day(at: now) ? min(max(0, earnedMinutes), policy.maxBonusMinutes) : 0
}
}
public struct Observation: Codable, Equatable, Sendable {
public var day: String = ""
public var reachedMinutes: Int = 0
public init() {}
public mutating func roll(to day: String) {
if self.day != day { self.day = day; reachedMinutes = 0 }
}
public mutating func record(threshold: Int, day: String) {
roll(to: day)
reachedMinutes = max(reachedMinutes, threshold)
}
}
public struct Decision: Equatable, Sendable {
public let shielded: Bool
public let budgetMinutes: Int
public let reason: String
public static func evaluate(_ snapshot: Snapshot, observation: Observation, now: Date) -> Decision {
let policy = snapshot.policy
let budget = policy.dailyMinutes + snapshot.bonus(at: now)
if !policy.isAllowed(at: now) {
return Decision(shielded: true, budgetMinutes: budget, reason: "Outside allowed hours")
}
let reached = observation.day == policy.day(at: now) ? observation.reachedMinutes : 0
let exhausted = reached >= budget
return Decision(shielded: exhausted, budgetMinutes: budget,
reason: exhausted ? "Allowance reached" : "Allowance available")
}
}
public struct DeviceStatus: Codable, Sendable {
public var policyRevision: Int
public var state: String
public var shielded: Bool
public var detail: String
public var consumedLowerBound: Int
public var usageDay: String
public init(policyRevision: Int, state: String, shielded: Bool, detail: String,
consumedLowerBound: Int, usageDay: String) {
self.policyRevision = policyRevision; self.state = state; self.shielded = shielded
self.detail = detail; self.consumedLowerBound = consumedLowerBound; self.usageDay = usageDay
}
}
@@ -0,0 +1,94 @@
import Foundation
import XCTest
@testable import PolicyCore
final class PolicyCoreTests: XCTestCase {
func date(_ value: String) -> Date { ISO8601DateFormatter().date(from: value)! }
var noon: Date { date("2026-09-17T10:00:00Z") }
func snapshot(_ policy: Policy = Policy(), bonus: Int = 0) -> Snapshot {
Snapshot(deviceId: "device", childId: "child", childName: "Example", policyRevision: 1,
policy: policy, day: "2026-09-17", earnedMinutes: bonus, serverTime: noon.timeIntervalSince1970)
}
func testRewardExtendsUsageNotWallClock() {
var observation = Observation()
observation.record(threshold: 30, day: "2026-09-17")
XCTAssertTrue(Decision.evaluate(snapshot(), observation: observation, now: noon).shielded)
XCTAssertFalse(Decision.evaluate(snapshot(bonus: 10), observation: observation, now: noon).shielded)
observation.record(threshold: 40, day: "2026-09-17")
XCTAssertTrue(Decision.evaluate(snapshot(bonus: 10), observation: observation, now: noon).shielded)
}
func testRefreshAndDuplicateCallbacksDoNotRefundUsage() {
var observation = Observation()
observation.record(threshold: 40, day: "2026-09-17")
observation.record(threshold: 30, day: "2026-09-17")
observation.record(threshold: 40, day: "2026-09-17")
observation.roll(to: "2026-09-17")
XCTAssertEqual(observation.reachedMinutes, 40)
}
func testBonusExpiresAndBaseResetsWithoutServer() {
var observation = Observation()
observation.record(threshold: 60, day: "2026-09-17")
let decision = Decision.evaluate(snapshot(bonus: 30), observation: observation,
now: date("2026-09-18T10:00:00Z"))
XCTAssertFalse(decision.shielded)
XCTAssertEqual(decision.budgetMinutes, 30)
}
func testBedtimeCannotBeBought() {
XCTAssertTrue(Decision.evaluate(snapshot(bonus: 30), observation: Observation(),
now: date("2026-09-17T18:00:00Z")).shielded)
XCTAssertTrue(Decision.evaluate(snapshot(bonus: 30), observation: Observation(),
now: date("2026-09-17T04:59:00Z")).shielded)
XCTAssertFalse(Decision.evaluate(snapshot(), observation: Observation(),
now: date("2026-09-17T05:00:00Z")).shielded)
}
func testZeroBaseAndZeroBonusFailClosed() {
let policy = Policy(dailyMinutes: 0, maxBonusMinutes: 0)
XCTAssertTrue(Decision.evaluate(snapshot(policy), observation: Observation(), now: noon).shielded)
XCTAssertEqual(policy.thresholds(startingBonus: 0), [])
}
func testThresholdLadderIncludesPartialCapAndRebasedRewards() {
let policy = Policy(bonusMinutes: 7, maxBonusMinutes: 23)
let thresholds = policy.thresholds(startingBonus: 10)
for budget in [30, 37, 44, 51, 53, 40, 47] { XCTAssertTrue(thresholds.contains(budget)) }
XCTAssertEqual(thresholds, thresholds.sorted())
}
func testEveryReachableRewardHasAThreshold() {
for step in 5...30 {
for cap in 0...60 {
let policy = Policy(dailyMinutes: 0, bonusMinutes: step, maxBonusMinutes: cap)
for initial in 0...cap {
let thresholds = Set(policy.thresholds(startingBonus: initial))
var bonus = initial
while bonus < cap {
bonus = min(bonus + step, cap)
XCTAssertTrue(thresholds.contains(bonus))
}
}
}
}
}
func testTimezoneMidnightAndDST() {
let policy = Policy()
XCTAssertEqual(policy.day(at: date("2026-09-17T22:01:00Z")), "2026-09-18")
XCTAssertTrue(policy.isAllowed(at: date("2026-10-24T05:00:00Z")))
XCTAssertFalse(policy.isAllowed(at: date("2026-10-25T05:00:00Z")))
XCTAssertTrue(policy.isAllowed(at: date("2026-10-25T06:00:00Z")))
}
func testValidationRejectsUnsupportedAndUnsafePolicies() {
XCTAssertThrowsError(try Policy(timezone: "Bogus/Zone").validate())
XCTAssertThrowsError(try Policy(bonusMinutes: 0).validate())
XCTAssertThrowsError(try Policy(allowedStart: 1200, allowedEnd: 420).validate())
var bad = snapshot(); bad.schemaVersion = 2
XCTAssertThrowsError(try bad.validate())
bad = snapshot(); bad.day = "2099-01-01"
XCTAssertThrowsError(try bad.validate())
}
func testWireRoundTripUsesServerFieldNames() throws {
let original = snapshot(bonus: 10)
let data = try WireCoding.encoder().encode(original)
XCTAssertTrue(String(decoding: data, as: UTF8.self).contains("\"policy_revision\""))
let decoded = try WireCoding.decoder().decode(Snapshot.self, from: data)
XCTAssertEqual(original, decoded)
try decoded.validate()
}
}