add astra draft
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user