Compare commits
5 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
da62a0f5c6 | |
|
|
1a0d26f4ca | |
|
|
c9ab304460 | |
|
|
e32b65926f | |
|
|
e49a1738ec |
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "zwiftout",
|
||||
"version": "2.1.0",
|
||||
"version": "2.3.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"description": "Zwift workout generator command line tool and library",
|
||||
"author": "Rene Saarsoo <github@triin.net>",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
get value() {
|
||||
// To match Zwift, which gives 64 TSS for 1h of freeride.
|
||||
|
|
|
|||
|
|
@ -17,13 +17,10 @@ export const parseCliOptions = (): CliOptions => {
|
|||
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,
|
||||
// convert filenames array to just a single string.
|
||||
const { file, ...rest } = argParser.parse_args();
|
||||
return {
|
||||
file: file[0],
|
||||
...rest,
|
||||
};
|
||||
return argParser.parse_args();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Workout } from "../ast";
|
||||
import { fillRangeIntensities } from "./fillRangeIntensities";
|
||||
import { parseTokens } from "./parser";
|
||||
import { tokenize } from "./tokenizer";
|
||||
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))));
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
expect(
|
||||
parse(`
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { last } from "ramda";
|
||||
import { Interval, Workout, Comment } from "../ast";
|
||||
import { Duration } from "../Duration";
|
||||
import { ConstantIntensity, FreeIntensity, RangeIntensity } from "../Intensity";
|
||||
import { ConstantIntensity, FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity";
|
||||
import { ParseError } from "./ParseError";
|
||||
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") {
|
||||
intensity = new RangeIntensity(token.value[0], token.value[1]);
|
||||
tokens.shift();
|
||||
} else if (token.type === "intensity-range-end") {
|
||||
intensity = new RangeIntensityEnd(token.value);
|
||||
tokens.shift();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ export type RangeIntensityToken = {
|
|||
value: [number, number];
|
||||
loc: SourceLocation;
|
||||
};
|
||||
export type RangeIntensityEndToken = {
|
||||
type: "intensity-range-end";
|
||||
value: number;
|
||||
loc: SourceLocation;
|
||||
};
|
||||
export type CommentStartToken = {
|
||||
type: "comment-start";
|
||||
value?: undefined;
|
||||
|
|
@ -59,6 +64,7 @@ export type Token =
|
|||
| NumberToken
|
||||
| OffsetToken
|
||||
| RangeIntensityToken
|
||||
| RangeIntensityEndToken
|
||||
| CommentStartToken;
|
||||
|
||||
const toInteger = (str: string): number => {
|
||||
|
|
@ -81,10 +87,14 @@ const tokenizeValueParam = (text: string, loc: SourceLocation): Token => {
|
|||
if (/^[0-9]+rpm$/.test(text)) {
|
||||
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);
|
||||
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)) {
|
||||
return { type: "intensity", value: toFraction(toInteger(text)), loc };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue