diff --git a/package-lock.json b/package-lock.json index 78ea141..8f28193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "strucpp", - "version": "0.4.19", + "version": "0.4.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "strucpp", - "version": "0.4.19", + "version": "0.4.20", "license": "GPL-3.0-or-later", "dependencies": { "chevrotain": "^11.0.0" diff --git a/package.json b/package.json index fdfb86d..8417fb4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "strucpp", - "version": "0.4.19", + "version": "0.4.20", "description": "IEC 61131-3 Structured Text to C++ Compiler", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/backend/codegen.ts b/src/backend/codegen.ts index 0ed0ec0..6a1320a 100644 --- a/src/backend/codegen.ts +++ b/src/backend/codegen.ts @@ -47,7 +47,7 @@ import type { } from "../library/library-manifest.js"; import { getProjectNamespace, - parseDateLiteralToNs, + parseDateLiteralToDays, parseDtLiteralToNs, parseTimeLiteral, parseTodLiteralToNs, @@ -180,6 +180,31 @@ export interface CodeGenOptions { emitChunkMarkers?: boolean; } +/** + * Numeric and bit-string targets for the `TO_*` family — i.e. every + * elementary type whose runtime representation is "just an integer or + * a float." Used by `wrapTemporalArgForNumericConversion` to gate + * the temporal→ms scaling: STRING / WSTRING and temporal targets need + * different handling and stay out of this set. + */ +const NUMERIC_OR_BIT_CONVERSION_TARGETS = new Set([ + "BOOL", + "SINT", + "INT", + "DINT", + "LINT", + "USINT", + "UINT", + "UDINT", + "ULINT", + "REAL", + "LREAL", + "BYTE", + "WORD", + "DWORD", + "LWORD", +]); + /** * Default code generation options. */ @@ -3216,8 +3241,11 @@ export class CodeGenerator { return `${timeVal.nanoseconds}LL`; } case "DATE": - // DATE: int64 nanoseconds since Unix epoch (UTC), time-of-day 0. - return `${parseDateLiteralToNs(String(expr.value))}LL`; + // DATE: int64 days since Unix epoch (UTC). `iec_date.hpp` + // stores DATE as days (see `DT_FROM_DATE_AND_TOD`'s + // `iec_unwrap(date) * DT_NS_PER_DAY` math). Lowering to ns + // here would break every conversion / arithmetic helper. + return `${parseDateLiteralToDays(String(expr.value))}LL`; case "TIME_OF_DAY": // TOD: int64 nanoseconds since midnight. return `${parseTodLiteralToNs(String(expr.value))}LL`; @@ -3853,6 +3881,76 @@ export class CodeGenerator { return inner.kind === "LiteralExpression" && !inner.typePrefix; } + /** + * Wrap a temporal-typed argument with the right `*_TO_MS` helper + * before it's handed to a numeric / bit-string `TO_*` conversion. + * + * Why this lives in codegen and not in the runtime: + * - `IEC_TIME`, `IEC_LTIME`, `IEC_TOD`, `IEC_LTOD`, `IEC_DT`, + * `IEC_LDT`, `IEC_DATE`, `IEC_LDATE` are all + * `using ... = IECVar` aliases in `iec_var.hpp` — they + * collapse to the same C++ type after preprocessing. + * - A runtime overload `TO_UINT(IEC_TIME)` therefore CANNOT be + * distinguished from `TO_UINT(IEC_DATE)` by the C++ compiler; + * both bind to the same generic template and the raw `int64_t` + * underlying value gets `static_cast`ed straight to the target + * integer (low 16 / 32 bits of a nanosecond count for TIME). + * - The IEC type label only survives at the language layer. So + * the scaling has to happen at the call site, before the type + * identity is erased. + * + * Scaling chosen (matches `TO_TIME(integer)`'s established + * "integer means milliseconds" convention from OSCAT/CODESYS): + * - TIME / LTIME → `TIME_TO_MS` (ns since 0 → ms) + * - TOD / TIME_OF_DAY / LTOD / LTIME_OF_DAY → `TOD_TO_MS` + * (ns since midnight → ms since midnight, [0, 86_400_000)) + * - DT / DATE_AND_TIME / LDT / LDATE_AND_TIME → `DT_TO_MS` + * (ns since epoch → ms since epoch) + * - DATE / LDATE: NOT scaled — DATE is already stored as whole + * days, and "days since 1970-01-01" is the natural integer + * answer for `DATE_TO_INT` / etc. Callers wanting a different + * unit can compose with `DATE_TO_DAYS` (today, the identity). + * + * No wrap on temporal-target conversions (`TO_TIME(TIME)`, + * `INT_TO_TIME(ms)`, etc.) — those are either pass-through (same + * family) or handled by the existing `TO_TIME(integer)` runtime + * template which scales ms→ns going the other way. No wrap on + * non-temporal sources either (the generic numeric path already + * does the right thing). + */ + private wrapTemporalArgForNumericConversion( + argExpr: string, + fromTypeUpper: string, + toTypeUpper: string, + ): string { + // Only the numeric / bit-string targets — temporal targets stay + // pass-through and STRING targets need a separate format pipeline + // (out of scope for this helper). + if (!NUMERIC_OR_BIT_CONVERSION_TARGETS.has(toTypeUpper)) { + return argExpr; + } + if (fromTypeUpper === "TIME" || fromTypeUpper === "LTIME") { + return `TIME_TO_MS(${argExpr})`; + } + if ( + fromTypeUpper === "TOD" || + fromTypeUpper === "TIME_OF_DAY" || + fromTypeUpper === "LTOD" || + fromTypeUpper === "LTIME_OF_DAY" + ) { + return `TOD_TO_MS(${argExpr})`; + } + if ( + fromTypeUpper === "DT" || + fromTypeUpper === "DATE_AND_TIME" || + fromTypeUpper === "LDT" || + fromTypeUpper === "LDATE_AND_TIME" + ) { + return `DT_TO_MS(${argExpr})`; + } + return argExpr; + } + /** * For std-lib template functions (like LIMIT, MAX, MIN) where all params * share the same generic constraint, harmonize argument types so C++ template @@ -4034,18 +4132,53 @@ export class CodeGenerator { // 1. Check for *_TO_* conversion pattern (e.g., INT_TO_REAL -> TO_REAL) const conversion = this.stdRegistry.resolveConversion(nameUpper); if (conversion) { - const args = expr.arguments.map((arg) => - this.generateExpression(arg.value), - ); + const args = expr.arguments.map((arg, idx) => { + const generated = this.generateExpression(arg.value); + if (idx !== 0) return generated; + // Type-aware scaling for temporal sources. See the helper for + // the full rationale — short version: the C++ runtime aliases + // every temporal type to `IECVar` (so a `TIME` and a + // `DATE` are literally the same C++ type after compilation), + // and the only place that still knows "this expression is a + // TIME" is the codegen layer. We have to wrap the argument + // with `TIME_TO_MS` / `TOD_TO_MS` / `DT_TO_MS` here, otherwise + // `TO_UINT(time_var)` lowers to a `static_cast(raw_ns)` + // and the user sees the low 16 bits of the nanosecond count + // instead of the milliseconds they asked for. + return this.wrapTemporalArgForNumericConversion( + generated, + conversion.fromType.toUpperCase(), + conversion.toType.toUpperCase(), + ); + }); return `${conversion.cppName}(${args.join(", ")})`; } // 2. Check for standard function (may have different cppName) const stdFunc = this.stdRegistry.lookup(nameUpper); if (stdFunc) { - const args = expr.arguments.map((arg) => - this.generateExpression(arg.value), - ); + const args = expr.arguments.map((arg, idx) => { + let generated = this.generateExpression(arg.value); + // For the bare `TO_xxx(temporal_var)` spelling, `nameUpper` is + // a registered std function (not a `*_TO_*` form) so the source + // type isn't in the name — infer it from the argument's IEC + // type and apply the same temporal→ms wrap as the conversion + // branch above. Conversion std functions advertise + // `isConversion: true` and carry the target in + // `specificReturnType`, so we have everything needed without + // adding a new schema field. + if (idx === 0 && stdFunc.isConversion && stdFunc.specificReturnType) { + const fromType = this.inferExprType(arg.value); + if (fromType) { + generated = this.wrapTemporalArgForNumericConversion( + generated, + fromType.toUpperCase(), + stdFunc.specificReturnType.toUpperCase(), + ); + } + } + return generated; + }); this.harmonizeStdFuncArgs(args, expr.arguments, stdFunc); return `${stdFunc.cppName}(${args.join(", ")})`; } @@ -4879,6 +5012,30 @@ export class CodeGenerator { const timeVal = parseTimeLiteral(initialValue); return `${timeVal.nanoseconds}LL`; } + // Convert temporal calendar literals at the PROGRAM-init path — + // FB initialisers route through `generateExpression` which + // handles these in `generateLiteralExpression`, but PROGRAM VAR + // initialisers come through this helper with the literal as a + // raw string. Without these branches the PROGRAM constructor + // emits `D(DATE#1970-01-15)` verbatim and the C++ side fails + // to compile. Lowering rule matches the literal-expression + // path: DATE → days, TOD → ns since midnight, DT → ns since + // epoch. Same rule the runtime helpers consume. + if (upperInit.startsWith("D#") || upperInit.startsWith("DATE#")) { + return `${parseDateLiteralToDays(initialValue)}LL`; + } + if ( + upperInit.startsWith("TOD#") || + upperInit.startsWith("TIME_OF_DAY#") + ) { + return `${parseTodLiteralToNs(initialValue)}LL`; + } + if ( + upperInit.startsWith("DT#") || + upperInit.startsWith("DATE_AND_TIME#") + ) { + return `${parseDtLiteralToNs(initialValue)}LL`; + } // Convert IEC BOOL literals to C++ bool literals if (upperInit === "TRUE") return "true"; if (upperInit === "FALSE") return "false"; diff --git a/src/backend/type-codegen.ts b/src/backend/type-codegen.ts index 688cdc7..43d1328 100644 --- a/src/backend/type-codegen.ts +++ b/src/backend/type-codegen.ts @@ -23,7 +23,7 @@ import type { import { TypeRegistry, isElementaryType } from "../semantic/type-registry.js"; import { formatArrayType } from "./codegen-utils.js"; import { - parseDateLiteralToNs, + parseDateLiteralToDays, parseDtLiteralToNs, parseTimeLiteral, parseTodLiteralToNs, @@ -558,7 +558,7 @@ export class TypeCodeGenerator { return `${timeVal.nanoseconds}LL`; } case "DATE": - return `${parseDateLiteralToNs(String(expr.value))}LL`; + return `${parseDateLiteralToDays(String(expr.value))}LL`; case "TIME_OF_DAY": return `${parseTodLiteralToNs(String(expr.value))}LL`; case "DATE_AND_TIME": diff --git a/src/project-model.ts b/src/project-model.ts index e5521ca..bf90d69 100644 --- a/src/project-model.ts +++ b/src/project-model.ts @@ -240,18 +240,26 @@ export interface ProjectModelResult { */ /** * Parse an IEC 61131-3 DATE literal (`D#YYYY-MM-DD` / - * `DATE#YYYY-MM-DD`) into nanoseconds since the Unix epoch (UTC). - * Strucpp's runtime stores DATE as int64 nanoseconds, the same wire - * shape as DT — DATE just truncates the time-of-day component to - * 00:00:00. Returns 0 (Unix epoch) for any unparsable input rather - * than throwing, mirroring `parseTimeLiteral`. + * `DATE#YYYY-MM-DD`) into **days since the Unix epoch (UTC)**. + * + * Why days and not nanoseconds: `iec_date.hpp` stores `IEC_DATE` as + * signed days, and helpers like `DT_FROM_DATE_AND_TOD` multiply the + * raw stored value by `DT_NS_PER_DAY` to compose a DT — that math + * only works if DATE is days. An earlier version of this helper + * lowered to nanoseconds, which silently broke `DATE_TO_DAYS`, + * `DT_FROM_DATE_AND_TOD`, and every `TO_(DATE)` conversion + * (they'd return the raw ns count instead of the day count). + * + * Returns `0n` (Unix epoch) for any unparsable input rather than + * throwing, mirroring `parseTimeLiteral`. */ -export function parseDateLiteralToNs(literal: string): bigint { +export function parseDateLiteralToDays(literal: string): bigint { const stripped = literal.replace(/^(D|DATE)#/i, ""); const m = stripped.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!m) return 0n; + const MS_PER_DAY = 86_400_000n; const ms = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])); - return BigInt(ms) * 1_000_000n; + return BigInt(ms) / MS_PER_DAY; } /** diff --git a/src/runtime/include/iec_dt.hpp b/src/runtime/include/iec_dt.hpp index 4125498..0a8ab5e 100644 --- a/src/runtime/include/iec_dt.hpp +++ b/src/runtime/include/iec_dt.hpp @@ -110,6 +110,10 @@ inline int64_t DT_TO_NS(IEC_DT dt) noexcept { return iec_unwrap(dt); } +inline int64_t DT_TO_MS(IEC_DT dt) noexcept { + return iec_unwrap(dt) / 1000000LL; +} + inline int64_t DT_TO_SECONDS(IEC_DT dt) noexcept { return iec_unwrap(dt) / 1000000000LL; } diff --git a/src/runtime/include/iec_std_lib.hpp b/src/runtime/include/iec_std_lib.hpp index 7ec0e7b..a9c1b3a 100644 --- a/src/runtime/include/iec_std_lib.hpp +++ b/src/runtime/include/iec_std_lib.hpp @@ -839,6 +839,154 @@ inline auto TO_STRING(const T& src) noexcept -> decltype(WSTRING_TO_STRING(src)) return WSTRING_TO_STRING(src); } +// ============================================================================= +// WSTRING → Numeric Conversions +// ============================================================================= +// +// IEC 61131-3: WSTRING_TO_INT / WSTRING_TO_REAL / etc. all route through +// WSTRING_TO_STRING (lossy narrow-to-ASCII; same caveat the standard +// transcoding helpers document) and then reuse the STRING parsers +// already defined in iec_string.hpp. This keeps the parsing semantics +// (strtoul / strtol / strtod) byte-identical between the STRING and +// WSTRING surfaces, and the narrow conversion is correct for the +// numeric ASCII / Latin-1 subset users actually write into STRING +// literals. + +template +inline IEC_BOOL TO_BOOL(const IECWString& s) noexcept { + return TO_BOOL(WSTRING_TO_STRING(s)); +} +template +inline IEC_BOOL TO_BOOL(const IECWStringVar& s) noexcept { + return TO_BOOL(s.get()); +} + +template +inline IEC_SINT TO_SINT(const IECWString& s) noexcept { + return TO_SINT(WSTRING_TO_STRING(s)); +} +template +inline IEC_SINT TO_SINT(const IECWStringVar& s) noexcept { + return TO_SINT(s.get()); +} + +template +inline IEC_INT TO_INT(const IECWString& s) noexcept { + return TO_INT(WSTRING_TO_STRING(s)); +} +template +inline IEC_INT TO_INT(const IECWStringVar& s) noexcept { + return TO_INT(s.get()); +} + +template +inline IEC_DINT TO_DINT(const IECWString& s) noexcept { + return TO_DINT(WSTRING_TO_STRING(s)); +} +template +inline IEC_DINT TO_DINT(const IECWStringVar& s) noexcept { + return TO_DINT(s.get()); +} + +template +inline IEC_LINT TO_LINT(const IECWString& s) noexcept { + return TO_LINT(WSTRING_TO_STRING(s)); +} +template +inline IEC_LINT TO_LINT(const IECWStringVar& s) noexcept { + return TO_LINT(s.get()); +} + +template +inline IEC_USINT TO_USINT(const IECWString& s) noexcept { + return TO_USINT(WSTRING_TO_STRING(s)); +} +template +inline IEC_USINT TO_USINT(const IECWStringVar& s) noexcept { + return TO_USINT(s.get()); +} + +template +inline IEC_UINT TO_UINT(const IECWString& s) noexcept { + return TO_UINT(WSTRING_TO_STRING(s)); +} +template +inline IEC_UINT TO_UINT(const IECWStringVar& s) noexcept { + return TO_UINT(s.get()); +} + +template +inline IEC_UDINT TO_UDINT(const IECWString& s) noexcept { + return TO_UDINT(WSTRING_TO_STRING(s)); +} +template +inline IEC_UDINT TO_UDINT(const IECWStringVar& s) noexcept { + return TO_UDINT(s.get()); +} + +template +inline IEC_ULINT TO_ULINT(const IECWString& s) noexcept { + return TO_ULINT(WSTRING_TO_STRING(s)); +} +template +inline IEC_ULINT TO_ULINT(const IECWStringVar& s) noexcept { + return TO_ULINT(s.get()); +} + +template +inline IEC_REAL TO_REAL(const IECWString& s) noexcept { + return TO_REAL(WSTRING_TO_STRING(s)); +} +template +inline IEC_REAL TO_REAL(const IECWStringVar& s) noexcept { + return TO_REAL(s.get()); +} + +template +inline IEC_LREAL TO_LREAL(const IECWString& s) noexcept { + return TO_LREAL(WSTRING_TO_STRING(s)); +} +template +inline IEC_LREAL TO_LREAL(const IECWStringVar& s) noexcept { + return TO_LREAL(s.get()); +} + +template +inline IEC_BYTE TO_BYTE(const IECWString& s) noexcept { + return TO_BYTE(WSTRING_TO_STRING(s)); +} +template +inline IEC_BYTE TO_BYTE(const IECWStringVar& s) noexcept { + return TO_BYTE(s.get()); +} + +template +inline IEC_WORD TO_WORD(const IECWString& s) noexcept { + return TO_WORD(WSTRING_TO_STRING(s)); +} +template +inline IEC_WORD TO_WORD(const IECWStringVar& s) noexcept { + return TO_WORD(s.get()); +} + +template +inline IEC_DWORD TO_DWORD(const IECWString& s) noexcept { + return TO_DWORD(WSTRING_TO_STRING(s)); +} +template +inline IEC_DWORD TO_DWORD(const IECWStringVar& s) noexcept { + return TO_DWORD(s.get()); +} + +template +inline IEC_LWORD TO_LWORD(const IECWString& s) noexcept { + return TO_LWORD(WSTRING_TO_STRING(s)); +} +template +inline IEC_LWORD TO_LWORD(const IECWStringVar& s) noexcept { + return TO_LWORD(s.get()); +} + // ============================================================================= // Time Utilities // ============================================================================= diff --git a/src/version-build.ts b/src/version-build.ts index 25f6e99..4b79dd0 100644 --- a/src/version-build.ts +++ b/src/version-build.ts @@ -2,4 +2,4 @@ // Copyright (C) 2025 Autonomy / OpenPLC Project // AUTO-GENERATED by scripts/rebuild-libs.mjs from package.json. Do not edit by hand — // any changes are overwritten on the next build. -export const STRUCPP_VERSION_BUILD = "0.4.19"; +export const STRUCPP_VERSION_BUILD = "0.4.20"; diff --git a/tests/backend/codegen-functions.test.ts b/tests/backend/codegen-functions.test.ts index 884c1b5..cc35b5b 100644 --- a/tests/backend/codegen-functions.test.ts +++ b/tests/backend/codegen-functions.test.ts @@ -124,6 +124,131 @@ describe("Codegen - Function Calls", () => { expect(result.cppCode).toContain("TO_DINT("); }); + + // ------------------------------------------------------------------------- + // Temporal → numeric scaling + // ------------------------------------------------------------------------- + // The C++ runtime aliases every temporal type to `IECVar`, so + // `TO_UINT(time_var)` without codegen help would `static_cast` the raw + // nanosecond representation and lose all millisecond semantics. The + // codegen layer wraps the argument with the matching `*_TO_MS` helper + // before emitting the conversion so the C++ side sees an `int64_t` count + // of milliseconds — which is the inverse of `TO_TIME(ms_integer)`'s + // existing "ms input → ns storage" scaling. + + it("wraps TIME variable with TIME_TO_MS before TO_UINT (regression for low-16-bit-of-ns bug)", () => { + const result = compileAndCheck(` + PROGRAM Main + VAR t : TIME := T#5s; u : UINT; END_VAR + u := TO_UINT(t); + END_PROGRAM + `); + expect(result.cppCode).toContain("TO_UINT(TIME_TO_MS("); + }); + + it("wraps TIME variable with TIME_TO_MS for the explicit TIME_TO_UINT spelling", () => { + const result = compileAndCheck(` + PROGRAM Main + VAR t : TIME := T#5s; u : UINT; END_VAR + u := TIME_TO_UINT(t); + END_PROGRAM + `); + expect(result.cppCode).toContain("TO_UINT(TIME_TO_MS("); + }); + + it("wraps TIME variable with TIME_TO_MS for TO_REAL (returns ms as a float)", () => { + const result = compileAndCheck(` + PROGRAM Main + VAR t : TIME := T#5s; r : REAL; END_VAR + r := TO_REAL(t); + END_PROGRAM + `); + expect(result.cppCode).toContain("TO_REAL(TIME_TO_MS("); + }); + + it("wraps TIME variable with TIME_TO_MS for every numeric / bit target", () => { + // One sweep across the elementary numeric + bit targets — if any + // target ever forgets to go through the temporal scaler the + // assertion below pins it down. + const targets = [ + "SINT", "INT", "DINT", "LINT", + "USINT", "UDINT", "ULINT", + "LREAL", + "BYTE", "WORD", "DWORD", "LWORD", + ]; + for (const target of targets) { + const result = compileAndCheck(` + PROGRAM Main + VAR t : TIME := T#5s; out : ${target}; END_VAR + out := TO_${target}(t); + END_PROGRAM + `); + expect(result.cppCode).toContain(`TO_${target}(TIME_TO_MS(`); + } + }); + + it("wraps TOD variable with TOD_TO_MS for numeric conversions", () => { + const result = compileAndCheck(` + PROGRAM Main + VAR t : TOD := TOD#01:00:00; u : UDINT; END_VAR + u := TO_UDINT(t); + END_PROGRAM + `); + expect(result.cppCode).toContain("TO_UDINT(TOD_TO_MS("); + }); + + it("wraps DT variable with DT_TO_MS for numeric conversions", () => { + const result = compileAndCheck(` + PROGRAM Main + VAR d : DT := DT#1970-01-01-00:00:01; l : LINT; END_VAR + l := TO_LINT(d); + END_PROGRAM + `); + expect(result.cppCode).toContain("TO_LINT(DT_TO_MS("); + }); + + it("does NOT wrap DATE (days are the natural unit for DATE→numeric)", () => { + const result = compileAndCheck(` + PROGRAM Main + VAR d : DATE := DATE#1970-01-15; u : UINT; END_VAR + u := TO_UINT(d); + END_PROGRAM + `); + // DATE's int64_t storage is "days since 1970-01-01" — no scaling. + expect(result.cppCode).not.toContain("DATE_TO_MS"); + expect(result.cppCode).not.toContain("TIME_TO_MS"); + }); + + it("does NOT wrap on temporal-target conversions (avoid double-scaling)", () => { + // Temporal-target conversions stay pass-through at the C++ level + // (both sides are `IECVar`). Wrapping the source with + // `TIME_TO_MS` would double-scale; the inverse `TO_TIME(int_ms)` + // runtime template also relies on this branch staying out of its + // way for `INT_TO_TIME(ms_int)` to keep working. + const result = compileAndCheck(` + PROGRAM Main + VAR ms : DINT := 5000; t : TIME; END_VAR + t := DINT_TO_TIME(ms); + END_PROGRAM + `); + expect(result.cppCode).toContain("TO_TIME("); + // No wrap because the target is TIME — `TO_TIME(integer)` + // already scales ms → ns on the C++ side. + expect(result.cppCode).not.toContain("TIME_TO_MS"); + }); + + it("does NOT wrap when the source is plain numeric (existing behaviour preserved)", () => { + const result = compileAndCheck(` + PROGRAM Main + VAR i : INT := 5000; u : UINT; END_VAR + u := TO_UINT(i); + END_PROGRAM + `); + expect(result.cppCode).toContain("TO_UINT("); + expect(result.cppCode).not.toContain("TIME_TO_MS"); + expect(result.cppCode).not.toContain("TOD_TO_MS"); + expect(result.cppCode).not.toContain("DT_TO_MS"); + }); }); describe("function with VAR_OUTPUT", () => { diff --git a/tests/st-validation/data_types/string_conversions.st b/tests/st-validation/data_types/string_conversions.st new file mode 100644 index 0000000..eb2efa3 --- /dev/null +++ b/tests/st-validation/data_types/string_conversions.st @@ -0,0 +1,54 @@ +FUNCTION_BLOCK StringConversions + VAR_OUTPUT + (* STRING numeric parsers — strtol / strtoul / strtod under the hood, + so `'3.14'` parses to 3 as an integer (strtol stops at the '.') + and to 3.14 as a REAL. *) + str_to_uint : UINT; + str_to_int : INT; + str_to_dint : DINT; + str_to_lint : LINT; + str_to_real : REAL; + str_to_lreal : LREAL; + str_negative : INT; + str_hex_skip : UINT; + str_bool_true : BOOL; + str_bool_false : BOOL; + + (* WSTRING numeric parsers — route through WSTRING_TO_STRING so the + parse rules stay byte-identical with the STRING path. Anything + beyond the ASCII numeric subset is the user's responsibility + (the transcoder truncates to the low byte). *) + wstr_to_uint : UINT; + wstr_to_real : LREAL; + wstr_to_bool : BOOL; + END_VAR + VAR + s_pi : STRING := '3.14'; + s_half : STRING := '4.5'; + s_neg : STRING := '-42'; + s_trail : STRING := '12abc'; + s_true : STRING := 'TRUE'; + s_false : STRING := 'no'; + ws_pi : WSTRING := "3.14"; + ws_true : WSTRING := "true"; + END_VAR + + str_to_uint := TO_UINT(s_pi); (* 3 — strtoul stops at '.' *) + str_to_int := TO_INT(s_pi); (* 3 *) + str_to_dint := TO_DINT(s_pi); (* 3 *) + str_to_lint := TO_LINT(s_pi); (* 3 *) + (* `3.14` round-trips bit-exact through LREAL (double) but loses + precision narrowing to REAL (float). Test framework's ASSERT_EQ + does bit-exact float compare, so use an exactly-representable + fraction here (`4.5` = 9/2 = exact in IEEE-754) instead of `3.14`. *) + str_to_real := TO_REAL(s_half); (* 4.5 *) + str_to_lreal := TO_LREAL(s_pi); (* 3.14 *) + str_negative := TO_INT(s_neg); (* -42 *) + str_hex_skip := TO_UINT(s_trail); (* 12 — strtoul stops at 'a' *) + str_bool_true := TO_BOOL(s_true); (* TRUE *) + str_bool_false := TO_BOOL(s_false); (* FALSE *) + + wstr_to_uint := TO_UINT(ws_pi); (* 3 *) + wstr_to_real := TO_LREAL(ws_pi); (* 3.14 *) + wstr_to_bool := TO_BOOL(ws_true); (* TRUE *) +END_FUNCTION_BLOCK diff --git a/tests/st-validation/data_types/test_string_conversions.st b/tests/st-validation/data_types/test_string_conversions.st new file mode 100644 index 0000000..5b1a79a --- /dev/null +++ b/tests/st-validation/data_types/test_string_conversions.st @@ -0,0 +1,84 @@ +(* End-to-end coverage for STRING / WSTRING → numeric conversions. + STRING parsers route through `strtol` / `strtoul` / `strtod`; WSTRING + parsers route through `WSTRING_TO_STRING` first so the wide path + stays byte-identical with the narrow one. Pin both surfaces so a + future reroute (e.g. someone switching to a UTF-16-aware parser) + doesn't silently change observable behaviour. *) + +TEST 'TO_UINT of "3.14" stops at the dot (parses to 3)' + VAR uut : StringConversions; END_VAR + uut(); + ASSERT_EQ(uut.str_to_uint, 3); +END_TEST + +TEST 'TO_INT of "3.14" stops at the dot (parses to 3)' + VAR uut : StringConversions; END_VAR + uut(); + ASSERT_EQ(uut.str_to_int, 3); +END_TEST + +TEST 'TO_DINT of "3.14" stops at the dot (parses to 3)' + VAR uut : StringConversions; END_VAR + uut(); + ASSERT_EQ(uut.str_to_dint, 3); +END_TEST + +TEST 'TO_LINT of "3.14" stops at the dot (parses to 3)' + VAR uut : StringConversions; END_VAR + uut(); + ASSERT_EQ(uut.str_to_lint, 3); +END_TEST + +TEST 'TO_REAL of "4.5" parses the fraction (bit-exact via REAL)' + VAR uut : StringConversions; END_VAR + uut(); + ASSERT_EQ(uut.str_to_real, 4.5); +END_TEST + +TEST 'TO_LREAL of "3.14" parses the fraction at LREAL precision' + VAR uut : StringConversions; END_VAR + uut(); + ASSERT_EQ(uut.str_to_lreal, 3.14); +END_TEST + +TEST 'TO_INT of "-42" parses the sign' + VAR uut : StringConversions; END_VAR + uut(); + ASSERT_EQ(uut.str_negative, -42); +END_TEST + +TEST 'TO_UINT of "12abc" stops at the first non-digit (parses to 12)' + VAR uut : StringConversions; END_VAR + uut(); + ASSERT_EQ(uut.str_hex_skip, 12); +END_TEST + +TEST 'TO_BOOL of "TRUE" returns TRUE' + VAR uut : StringConversions; END_VAR + uut(); + ASSERT_EQ(uut.str_bool_true, TRUE); +END_TEST + +TEST 'TO_BOOL of "no" returns FALSE' + VAR uut : StringConversions; END_VAR + uut(); + ASSERT_EQ(uut.str_bool_false, FALSE); +END_TEST + +TEST 'WSTRING TO_UINT of "3.14" parses to 3 (matches STRING surface)' + VAR uut : StringConversions; END_VAR + uut(); + ASSERT_EQ(uut.wstr_to_uint, 3); +END_TEST + +TEST 'WSTRING TO_LREAL of "3.14" parses to 3.14 (matches STRING surface)' + VAR uut : StringConversions; END_VAR + uut(); + ASSERT_EQ(uut.wstr_to_real, 3.14); +END_TEST + +TEST 'WSTRING TO_BOOL of "true" returns TRUE' + VAR uut : StringConversions; END_VAR + uut(); + ASSERT_EQ(uut.wstr_to_bool, TRUE); +END_TEST diff --git a/tests/st-validation/data_types/test_time_types.st b/tests/st-validation/data_types/test_time_types.st index 34457b7..8b079d4 100644 --- a/tests/st-validation/data_types/test_time_types.st +++ b/tests/st-validation/data_types/test_time_types.st @@ -1,5 +1,54 @@ -TEST 'TIME literal stored in nanoseconds' - VAR uut : TimeTest; END_VAR +(* Regression suite for the temporal-to-numeric scaling fix. + The original bug: TO_UINT(T#5s) returned the low 16 bits of the + nanosecond count (61952) instead of the expected ms count (5000). + Codegen now wraps temporal args with the appropriate *_TO_MS + helper before the integer cast. These tests pin every elementary + temporal source so the scaling can't silently regress. *) + +TEST 'TIME_TO_DINT returns milliseconds' + VAR uut : TimeConversions; END_VAR uut(); - ASSERT_EQ(uut.result_ms, 1500000000); + ASSERT_EQ(uut.ms_via_dint, 1500); +END_TEST + +TEST 'TIME_TO_UINT returns milliseconds (low 16 bits when overflowing)' + VAR uut : TimeConversions; END_VAR + uut(); + ASSERT_EQ(uut.ms_via_uint, 1500); +END_TEST + +TEST 'TIME_TO_REAL returns milliseconds as float' + VAR uut : TimeConversions; END_VAR + uut(); + ASSERT_EQ(uut.ms_via_real, 1500.0); +END_TEST + +TEST 'TIME_TO_US returns microseconds (explicit helper, not codegen wrap)' + VAR uut : TimeConversions; END_VAR + uut(); + ASSERT_EQ(uut.unit_us, 1500000); +END_TEST + +TEST 'TIME_TO_S returns seconds as float' + VAR uut : TimeConversions; END_VAR + uut(); + ASSERT_EQ(uut.unit_seconds, 1.5); +END_TEST + +TEST 'TOD_TO_DINT returns milliseconds since midnight' + VAR uut : TimeConversions; END_VAR + uut(); + ASSERT_EQ(uut.tod_ms, 21600000); +END_TEST + +TEST 'DT_TO_LINT returns milliseconds since epoch' + VAR uut : TimeConversions; END_VAR + uut(); + ASSERT_EQ(uut.dt_ms, 1000); +END_TEST + +TEST 'DATE_TO_UINT returns days since 1970-01-01 (identity, no scaling)' + VAR uut : TimeConversions; END_VAR + uut(); + ASSERT_EQ(uut.date_days, 14); END_TEST diff --git a/tests/st-validation/data_types/time_types.st b/tests/st-validation/data_types/time_types.st index 3bcbc01..481a123 100644 --- a/tests/st-validation/data_types/time_types.st +++ b/tests/st-validation/data_types/time_types.st @@ -1,9 +1,43 @@ -PROGRAM TimeTest +FUNCTION_BLOCK TimeConversions + VAR_OUTPUT + (* TIME → integer scales ns→ms *) + ms_via_dint : DINT; + ms_via_uint : UINT; + ms_via_real : REAL; + + (* Explicit TIME_TO_ helpers still work and pick the unit + the user names, regardless of the generic TO_(TIME) ms + default. TIME_TO_US returns LINT (int64_t) on the runtime + side — declared wide enough to receive the raw value without + a narrowing assignment. Names are prefixed `unit_` so they + don't shadow the runtime helper names on the RHS. *) + unit_us : LINT; + unit_seconds : LREAL; + + (* TOD → integer scales ns→ms (since midnight) *) + tod_ms : DINT; + + (* DT → integer scales ns→ms (since 1970-01-01 epoch). Stored in + LINT because ms-since-epoch overflows 32-bit signed past 1970. *) + dt_ms : LINT; + + (* DATE → integer is identity: days since 1970-01-01. No scaling. *) + date_days : UINT; + END_VAR VAR - duration : TIME; - result_ms : DINT; + duration : TIME := T#1500ms; + midpoint : TOD := TOD#06:00:00; + moment : DT := DT#1970-01-01-00:00:01; + day : DATE := DATE#1970-01-15; END_VAR - duration := T#1500ms; - result_ms := TIME_TO_DINT(duration); -END_PROGRAM + ms_via_dint := TIME_TO_DINT(duration); (* 1500 *) + ms_via_uint := TIME_TO_UINT(duration); (* 1500 *) + ms_via_real := TIME_TO_REAL(duration); (* 1500.0 *) + unit_us := TIME_TO_US(duration); (* 1_500_000 *) + unit_seconds := TIME_TO_S(duration); (* 1.5 *) + + tod_ms := TOD_TO_DINT(midpoint); (* 21_600_000 — 6h in ms *) + dt_ms := TO_LINT(moment); (* 1000 — 1s in ms since epoch *) + date_days := TO_UINT(day); (* 14 — 14 days after epoch *) +END_FUNCTION_BLOCK