Compare commits

...

4 Commits

Author SHA1 Message Date
Rene Saarsoo da62a0f5c6 v2.3.0 2022-03-30 14:11:23 +03:00
Rene Saarsoo 1a0d26f4ca Read file from stdin 2022-03-30 14:10:57 +03:00
Rene Saarsoo c9ab304460 v2.2.0 2021-11-08 17:43:44 +02:00
Rene Saarsoo e32b65926f Recognize ..XX% range intervals 2021-07-20 13:54:01 +03:00
8 changed files with 213 additions and 12 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "zwiftout", "name": "zwiftout",
"version": "2.1.1", "version": "2.3.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"description": "Zwift workout generator command line tool and library", "description": "Zwift workout generator command line tool and library",
"author": "Rene Saarsoo <github@triin.net>", "author": "Rene Saarsoo <github@triin.net>",

View File

@ -47,6 +47,26 @@ export class RangeIntensity implements Intensity {
} }
} }
export class RangeIntensityEnd implements Intensity {
constructor(private _end: number) {}
get value() {
return this._end;
}
get start(): number {
throw new Error("RangeIntensityEnd has no start");
}
get end() {
return this._end;
}
get zone() {
return intensityValueToZoneType(this.value);
}
}
export class FreeIntensity implements Intensity { export class FreeIntensity implements Intensity {
get value() { get value() {
// To match Zwift, which gives 64 TSS for 1h of freeride. // To match Zwift, which gives 64 TSS for 1h of freeride.

View File

@ -17,13 +17,10 @@ export const parseCliOptions = (): CliOptions => {
default: false, default: false,
}); });
argParser.add_argument("file", { nargs: 1 }); argParser.add_argument("file", {
nargs: "?",
default: 0, // Default to reading STDIN
});
// As we only allow one file as input, return argParser.parse_args();
// convert filenames array to just a single string.
const { file, ...rest } = argParser.parse_args();
return {
file: file[0],
...rest,
};
}; };

View File

@ -0,0 +1,45 @@
import { Interval, Workout } from "../ast";
import { FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity";
const fillIntensities = (prevInterval: Interval, interval: Interval): Interval => {
if (!(interval.intensity instanceof RangeIntensityEnd)) {
return interval;
}
if (prevInterval.intensity instanceof FreeIntensity) {
throw new Error("range-intensity-end interval can't be after free-intensity interval");
}
return {
...interval,
intensity: new RangeIntensity(prevInterval.intensity.end, interval.intensity.end),
};
};
// Given: [1, 2, 3, 4]
// Returns: [[1,2], [2,3], [3,4]]
const pairs = <T>(arr: T[]): T[][] => {
const result: T[][] = [];
for (let i = 1; i < arr.length; i++) {
result.push([arr[i - 1], arr[i]]);
}
return result;
};
const fillIntensitiesInIntervals = (intervals: Interval[]): Interval[] => {
if (intervals.length <= 1) {
if (intervals.length === 1 && intervals[0].intensity instanceof RangeIntensityEnd) {
throw new Error("range-intensity-end interval can't be the first interval");
}
return intervals;
}
return [intervals[0], ...pairs(intervals).map(([prev, curr]) => fillIntensities(prev, curr))];
};
export const fillRangeIntensities = (workout: Workout): Workout => {
return {
...workout,
intervals: fillIntensitiesInIntervals(workout.intervals),
};
};

View File

@ -1,6 +1,7 @@
import { Workout } from "../ast"; import { Workout } from "../ast";
import { fillRangeIntensities } from "./fillRangeIntensities";
import { parseTokens } from "./parser"; import { parseTokens } from "./parser";
import { tokenize } from "./tokenizer"; import { tokenize } from "./tokenizer";
import { validate } from "./validate"; import { validate } from "./validate";
export const parse = (source: string): Workout => validate(parseTokens(tokenize(source))); export const parse = (source: string): Workout => validate(fillRangeIntensities(parseTokens(tokenize(source))));

View File

@ -275,6 +275,131 @@ Ramp: 5:30 90%..100%
`); `);
}); });
it("parses end-of-range intervals after power-range interval", () => {
expect(
parse(`
Name: My Workout
Ramp: 5:00 50%..75%
Ramp: 5:00 ..100%
Ramp: 5:00 ..50%
`).intervals,
).toMatchInlineSnapshot(`
Array [
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": RangeIntensity {
"_end": 0.75,
"_start": 0.5,
},
"type": "Ramp",
},
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": RangeIntensity {
"_end": 1,
"_start": 0.75,
},
"type": "Ramp",
},
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": RangeIntensity {
"_end": 0.5,
"_start": 1,
},
"type": "Ramp",
},
]
`);
});
it("parses end-of-range intervals after normal interval", () => {
expect(
parse(`
Name: My Workout
Interval: 5:00 75%
Ramp: 5:00 ..100%
Ramp: 5:00 ..50%
`).intervals,
).toMatchInlineSnapshot(`
Array [
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": ConstantIntensity {
"_value": 0.75,
},
"type": "Interval",
},
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": RangeIntensity {
"_end": 1,
"_start": 0.75,
},
"type": "Ramp",
},
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": RangeIntensity {
"_end": 0.5,
"_start": 1,
},
"type": "Ramp",
},
]
`);
});
it("throws error when end-of-range intervals after free-ride interval", () => {
expect(
() =>
parse(`
Name: My Workout
FreeRide: 5:00
Ramp: 5:00 ..100%
Ramp: 5:00 ..50%
`).intervals,
).toThrowErrorMatchingInlineSnapshot(`"range-intensity-end interval can't be after free-intensity interval"`);
});
it("throws error when end-of-range intervals is the first interval", () => {
expect(
() =>
parse(`
Name: My Workout
Ramp: 5:00 ..50%
`).intervals,
).toThrowErrorMatchingInlineSnapshot(`"range-intensity-end interval can't be the first interval"`);
});
it("parses free-ride intervals", () => { it("parses free-ride intervals", () => {
expect( expect(
parse(` parse(`

View File

@ -1,7 +1,7 @@
import { last } from "ramda"; import { last } from "ramda";
import { Interval, Workout, Comment } from "../ast"; import { Interval, Workout, Comment } from "../ast";
import { Duration } from "../Duration"; import { Duration } from "../Duration";
import { ConstantIntensity, FreeIntensity, RangeIntensity } from "../Intensity"; import { ConstantIntensity, FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity";
import { ParseError } from "./ParseError"; import { ParseError } from "./ParseError";
import { IntervalType, OffsetToken, SourceLocation, Token } from "./tokenizer"; import { IntervalType, OffsetToken, SourceLocation, Token } from "./tokenizer";
@ -158,6 +158,9 @@ const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLoc
} else if (token.type === "intensity-range") { } else if (token.type === "intensity-range") {
intensity = new RangeIntensity(token.value[0], token.value[1]); intensity = new RangeIntensity(token.value[0], token.value[1]);
tokens.shift(); tokens.shift();
} else if (token.type === "intensity-range-end") {
intensity = new RangeIntensityEnd(token.value);
tokens.shift();
} else { } else {
break; break;
} }

View File

@ -47,6 +47,11 @@ export type RangeIntensityToken = {
value: [number, number]; value: [number, number];
loc: SourceLocation; loc: SourceLocation;
}; };
export type RangeIntensityEndToken = {
type: "intensity-range-end";
value: number;
loc: SourceLocation;
};
export type CommentStartToken = { export type CommentStartToken = {
type: "comment-start"; type: "comment-start";
value?: undefined; value?: undefined;
@ -59,6 +64,7 @@ export type Token =
| NumberToken | NumberToken
| OffsetToken | OffsetToken
| RangeIntensityToken | RangeIntensityToken
| RangeIntensityEndToken
| CommentStartToken; | CommentStartToken;
const toInteger = (str: string): number => { const toInteger = (str: string): number => {
@ -81,10 +87,14 @@ const tokenizeValueParam = (text: string, loc: SourceLocation): Token => {
if (/^[0-9]+rpm$/.test(text)) { if (/^[0-9]+rpm$/.test(text)) {
return { type: "cadence", value: toInteger(text), loc }; return { type: "cadence", value: toInteger(text), loc };
} }
if (/^[0-9]+%..[0-9]+%$/.test(text)) { if (/^[0-9]+%\.\.[0-9]+%$/.test(text)) {
const [from, to] = text.split("..").map(toInteger).map(toFraction); const [from, to] = text.split("..").map(toInteger).map(toFraction);
return { type: "intensity-range", value: [from, to], loc }; return { type: "intensity-range", value: [from, to], loc };
} }
if (/^\.\.[0-9]+%$/.test(text)) {
const [, /* _ */ to] = text.split("..").map(toInteger).map(toFraction);
return { type: "intensity-range-end", value: to, loc };
}
if (/^[0-9]+%$/.test(text)) { if (/^[0-9]+%$/.test(text)) {
return { type: "intensity", value: toFraction(toInteger(text)), loc }; return { type: "intensity", value: toFraction(toInteger(text)), loc };
} }